No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

172 líneas
7.8 KiB

  1. import logging
  2. import re
  3. import requests
  4. from services.dates import parse_datetime
  5. from services.normalization import normalize_headline
  6. from structs.headline import Headline
  7. logger = logging.getLogger(__name__)
  8. DEFAULT_TIMEOUT = 10
  9. MIN_HEADLINE_WORDS = 3
  10. # Common browser-like request headers. News sites frequently reject requests
  11. # that look like minimal bots, so these make Anya look like a regular browser.
  12. # Accept-Encoding is intentionally omitted so requests/urllib3 negotiates and
  13. # decompresses a response it can actually handle (avoids brotli-only responses
  14. # arriving as undecodable bytes).
  15. DEFAULT_HEADERS = {
  16. 'User-Agent': (
  17. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
  18. 'AppleWebKit/537.36 (KHTML, like Gecko) '
  19. 'Chrome/124.0.0.0 Safari/537.36'
  20. ),
  21. 'Accept': (
  22. 'text/html,application/xhtml+xml,application/xml;q=0.9,'
  23. 'image/avif,image/webp,image/apng,*/*;q=0.8'
  24. ),
  25. 'Accept-Language': 'en-US,en;q=0.9',
  26. 'Cache-Control': 'max-age=0',
  27. 'Upgrade-Insecure-Requests': '1',
  28. 'Sec-Fetch-Dest': 'document',
  29. 'Sec-Fetch-Mode': 'navigate',
  30. 'Sec-Fetch-Site': 'none',
  31. 'Sec-Fetch-User': '?1',
  32. 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
  33. 'sec-ch-ua-mobile': '?0',
  34. 'sec-ch-ua-platform': '"Windows"',
  35. }
  36. # Single streaming pass over the HTML: captures <time> markers (with an optional
  37. # datetime/title attribute or inner text) and <a>/<span> headline text, so every
  38. # headline can be associated with the most recent publication timestamp seen
  39. # before it in document order.
  40. _CANDIDATE_RE = re.compile(
  41. r"<time\b([^>]*)>(.*?)</time>" # group 1 attrs, group 2 text
  42. r"|(?:(?:datetime|dateTime)\s*=\s*[\"']([^\"']+)[\"'])" # group 3 datetime attr
  43. r"|<(?:article|li)\b[^>]*>" # container boundary (no group)
  44. r"|<(?:a|span)\b[^>]*>\s*([^<]*?)\s*</(?:a|span)>", # group 4 headline text
  45. re.IGNORECASE | re.DOTALL,
  46. )
  47. def _extract_candidates(source_content):
  48. """Yield ``(text, published_at)`` tuples in document order.
  49. Each headline is associated with the most recent publication timestamp seen
  50. before it. Timestamps reset at each ``<article>``/``<li>`` boundary so a
  51. headline with no date of its own does not inherit another story's timestamp.
  52. """
  53. candidates = []
  54. last_published = None
  55. for match in _CANDIDATE_RE.finditer(source_content):
  56. if match.group(1) is not None:
  57. # A <time ...>...</time> element: prefer an explicit datetime/title
  58. # attribute, otherwise fall back to its inner text.
  59. attrs = match.group(1)
  60. attr_match = re.search(r'(?:datetime|dateTime)\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) \
  61. or re.search(r'title\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE)
  62. raw = attr_match.group(1) if attr_match else match.group(2)
  63. parsed = parse_datetime(raw)
  64. if parsed is not None:
  65. last_published = parsed
  66. elif match.group(3) is not None:
  67. # A datetime attribute on some non-<time> element.
  68. parsed = parse_datetime(match.group(3))
  69. if parsed is not None:
  70. last_published = parsed
  71. elif match.group(4) is not None:
  72. candidates.append((match.group(4), last_published))
  73. else:
  74. # <article>/<li> boundary: a headline without its own timestamp
  75. # should not inherit the previous article's date.
  76. last_published = None
  77. return candidates
  78. def is_headline(text, stopwords=None):
  79. if not text or not isinstance(text, str):
  80. return False
  81. cleaned_text = text.strip()
  82. if not cleaned_text:
  83. return False
  84. words = cleaned_text.split()
  85. if len(words) < MIN_HEADLINE_WORDS:
  86. logger.debug("Text rejected as headline (fewer than %d words): %r", MIN_HEADLINE_WORDS, cleaned_text)
  87. return False
  88. if not any(c.isalnum() for c in cleaned_text):
  89. logger.debug("Text rejected as headline (no alphanumeric characters): %r", cleaned_text)
  90. return False
  91. if stopwords is not None:
  92. normalized = normalize_headline(cleaned_text, stopwords)
  93. if not normalized:
  94. logger.debug("Text rejected as headline (no meaningful tokens after stopword removal): %r", cleaned_text)
  95. return False
  96. return True
  97. def prepare_headlines(sources, stopwords, timeout=DEFAULT_TIMEOUT, headers=None):
  98. request_headers = headers if headers is not None else DEFAULT_HEADERS
  99. logger.info("Starting preparation of headlines for %d sources", len(sources) if sources else 0)
  100. headlines = []
  101. if not sources:
  102. logger.warning("No sources provided to prepare_headlines.")
  103. return headlines
  104. for idx, source in enumerate(sources, start=1):
  105. if not source or not source.strip():
  106. logger.warning("Skipping empty source at index %d", idx)
  107. continue
  108. source_url = source.strip()
  109. logger.info("Fetching source [%d/%d]: '%s'", idx, len(sources), source_url)
  110. try:
  111. response = requests.get(source_url, allow_redirects=True, timeout=timeout, headers=request_headers)
  112. logger.debug("Received HTTP response %d for '%s' (content length: %d bytes)",
  113. response.status_code, source_url, len(response.content))
  114. if response.status_code != 200:
  115. logger.warning("Source '%s' returned non-200 status code: %d", source_url, response.status_code)
  116. source_content = response.text
  117. except requests.exceptions.Timeout as e:
  118. logger.error("Request timed out for source '%s': %s", source_url, e, exc_info=True)
  119. continue
  120. except requests.exceptions.RequestException as e:
  121. logger.error("HTTP request failed for source '%s': %s", source_url, e, exc_info=True)
  122. continue
  123. except Exception as e:
  124. logger.error("Unexpected error fetching source '%s': %s", source_url, e, exc_info=True)
  125. continue
  126. logger.debug("Parsing HTML content from '%s' for headline candidates", source_url)
  127. try:
  128. candidates = _extract_candidates(source_content)
  129. logger.info("Found %d candidate tags in source '%s'", len(candidates), source_url)
  130. except Exception as e:
  131. logger.error("Regex extraction failed on content from '%s': %s", source_url, e, exc_info=True)
  132. continue
  133. source_headlines_count = 0
  134. for tag_idx, (link_text, published_at) in enumerate(candidates, start=1):
  135. cleaned_text = link_text.strip()
  136. if not cleaned_text:
  137. logger.debug("Skipping empty tag text at position %d from '%s'", tag_idx, source_url)
  138. continue
  139. if not is_headline(cleaned_text, stopwords):
  140. logger.debug("Skipping non-headline tag text at position %d from '%s': %r", tag_idx, source_url, cleaned_text)
  141. continue
  142. logger.debug("Processing tag [%d/%d] from '%s': %r", tag_idx, len(candidates), source_url, cleaned_text)
  143. try:
  144. normalized_headline = normalize_headline(cleaned_text, stopwords)
  145. headline = Headline(cleaned_text, normalized_headline, source_url, published_at)
  146. headlines.append(headline)
  147. source_headlines_count += 1
  148. except Exception as e:
  149. logger.error("Failed to normalize/create headline for text %r from '%s': %s", cleaned_text, source_url, e, exc_info=True)
  150. logger.info("Successfully extracted %d headlines from source '%s'", source_headlines_count, source_url)
  151. logger.info("Finished preparing headlines. Total headlines collected across all sources: %d", len(headlines))
  152. return headlines