Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

214 lignes
10 KiB

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