Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

headlines.py 6.7 KiB

4 dagar sedan
4 dagar sedan
4 dagar sedan
4 dagar sedan
4 dagar sedan
4 dagar sedan
4 dagar sedan
4 dagar sedan
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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. # Single streaming pass over the HTML: captures <time> markers (with an optional
  11. # datetime/title attribute or inner text) and <a>/<span> headline text, so every
  12. # headline can be associated with the most recent publication timestamp seen
  13. # before it in document order.
  14. _CANDIDATE_RE = re.compile(
  15. r"<time\b([^>]*)>(.*?)</time>" # group 1 attrs, group 2 text
  16. r"|(?:(?:datetime|dateTime)\s*=\s*[\"']([^\"']+)[\"'])" # group 3 datetime attr
  17. r"|<(?:article|li)\b[^>]*>" # container boundary (no group)
  18. r"|<(?:a|span)\b[^>]*>\s*([^<]*?)\s*</(?:a|span)>", # group 4 headline text
  19. re.IGNORECASE | re.DOTALL,
  20. )
  21. def _extract_candidates(source_content):
  22. """Yield ``(text, published_at)`` tuples in document order.
  23. Each headline is associated with the most recent publication timestamp seen
  24. before it. Timestamps reset at each ``<article>``/``<li>`` boundary so a
  25. headline with no date of its own does not inherit another story's timestamp.
  26. """
  27. candidates = []
  28. last_published = None
  29. for match in _CANDIDATE_RE.finditer(source_content):
  30. if match.group(1) is not None:
  31. # A <time ...>...</time> element: prefer an explicit datetime/title
  32. # attribute, otherwise fall back to its inner text.
  33. attrs = match.group(1)
  34. attr_match = re.search(r'(?:datetime|dateTime)\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) \
  35. or re.search(r'title\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE)
  36. raw = attr_match.group(1) if attr_match else match.group(2)
  37. parsed = parse_datetime(raw)
  38. if parsed is not None:
  39. last_published = parsed
  40. elif match.group(3) is not None:
  41. # A datetime attribute on some non-<time> element.
  42. parsed = parse_datetime(match.group(3))
  43. if parsed is not None:
  44. last_published = parsed
  45. elif match.group(4) is not None:
  46. candidates.append((match.group(4), last_published))
  47. else:
  48. # <article>/<li> boundary: a headline without its own timestamp
  49. # should not inherit the previous article's date.
  50. last_published = None
  51. return candidates
  52. def is_headline(text, stopwords=None):
  53. if not text or not isinstance(text, str):
  54. return False
  55. cleaned_text = text.strip()
  56. if not cleaned_text:
  57. return False
  58. words = cleaned_text.split()
  59. if len(words) < MIN_HEADLINE_WORDS:
  60. logger.debug("Text rejected as headline (fewer than %d words): %r", MIN_HEADLINE_WORDS, cleaned_text)
  61. return False
  62. if not any(c.isalnum() for c in cleaned_text):
  63. logger.debug("Text rejected as headline (no alphanumeric characters): %r", cleaned_text)
  64. return False
  65. if stopwords is not None:
  66. normalized = normalize_headline(cleaned_text, stopwords)
  67. if not normalized:
  68. logger.debug("Text rejected as headline (no meaningful tokens after stopword removal): %r", cleaned_text)
  69. return False
  70. return True
  71. def prepare_headlines(sources, stopwords, timeout=DEFAULT_TIMEOUT):
  72. logger.info("Starting preparation of headlines for %d sources", len(sources) if sources else 0)
  73. headlines = []
  74. if not sources:
  75. logger.warning("No sources provided to prepare_headlines.")
  76. return headlines
  77. for idx, source in enumerate(sources, start=1):
  78. if not source or not source.strip():
  79. logger.warning("Skipping empty source at index %d", idx)
  80. continue
  81. source_url = source.strip()
  82. logger.info("Fetching source [%d/%d]: '%s'", idx, len(sources), source_url)
  83. try:
  84. response = requests.get(source_url, allow_redirects=True, timeout=timeout, headers={'User-Agent': 'Anya news bot'})
  85. logger.debug("Received HTTP response %d for '%s' (content length: %d bytes)",
  86. response.status_code, source_url, len(response.content))
  87. if response.status_code != 200:
  88. logger.warning("Source '%s' returned non-200 status code: %d", source_url, response.status_code)
  89. source_content = response.text
  90. except requests.exceptions.Timeout as e:
  91. logger.error("Request timed out for source '%s': %s", source_url, e, exc_info=True)
  92. continue
  93. except requests.exceptions.RequestException as e:
  94. logger.error("HTTP request failed for source '%s': %s", source_url, e, exc_info=True)
  95. continue
  96. except Exception as e:
  97. logger.error("Unexpected error fetching source '%s': %s", source_url, e, exc_info=True)
  98. continue
  99. logger.debug("Parsing HTML content from '%s' for headline candidates", source_url)
  100. try:
  101. candidates = _extract_candidates(source_content)
  102. logger.info("Found %d candidate tags in source '%s'", len(candidates), source_url)
  103. except Exception as e:
  104. logger.error("Regex extraction failed on content from '%s': %s", source_url, e, exc_info=True)
  105. continue
  106. source_headlines_count = 0
  107. for tag_idx, (link_text, published_at) in enumerate(candidates, start=1):
  108. cleaned_text = link_text.strip()
  109. if not cleaned_text:
  110. logger.debug("Skipping empty tag text at position %d from '%s'", tag_idx, source_url)
  111. continue
  112. if not is_headline(cleaned_text, stopwords):
  113. logger.debug("Skipping non-headline tag text at position %d from '%s': %r", tag_idx, source_url, cleaned_text)
  114. continue
  115. logger.debug("Processing tag [%d/%d] from '%s': %r", tag_idx, len(candidates), source_url, cleaned_text)
  116. try:
  117. normalized_headline = normalize_headline(cleaned_text, stopwords)
  118. headline = Headline(cleaned_text, normalized_headline, source_url, published_at)
  119. headlines.append(headline)
  120. source_headlines_count += 1
  121. except Exception as e:
  122. logger.error("Failed to normalize/create headline for text %r from '%s': %s", cleaned_text, source_url, e, exc_info=True)
  123. logger.info("Successfully extracted %d headlines from source '%s'", source_headlines_count, source_url)
  124. logger.info("Finished preparing headlines. Total headlines collected across all sources: %d", len(headlines))
  125. return headlines