Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

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