import logging import re import requests from services.dates import parse_datetime from services.feeds import looks_like_feed, parse_feed from services.normalization import is_excluded, normalize_headline from structs.headline import Headline logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 MIN_HEADLINE_WORDS = 3 # Common browser-like request headers. News sites frequently reject requests # that look like minimal bots, so these make Anya look like a regular browser. # Accept-Encoding is intentionally omitted so requests/urllib3 negotiates and # decompresses a response it can actually handle (avoids brotli-only responses # arriving as undecodable bytes). DEFAULT_HEADERS = { 'User-Agent': ( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/124.0.0.0 Safari/537.36' ), 'Accept': ( 'text/html,application/xhtml+xml,application/xml;q=0.9,' 'image/avif,image/webp,image/apng,*/*;q=0.8' ), 'Accept-Language': 'en-US,en;q=0.9', 'Cache-Control': 'max-age=0', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', } # Single streaming pass over the HTML: captures " # group 1 attrs, group 2 text r"|(?:(?:datetime|dateTime)\s*=\s*[\"']([^\"']+)[\"'])" # group 3 datetime attr r"|<(?:article|li)\b[^>]*>" # container boundary (no group) r"|<(?:a|span)\b[^>]*>\s*([^<]*?)\s*", # group 4 headline text re.IGNORECASE | re.DOTALL, ) def _extract_candidates(source_content): """Yield ``(text, published_at)`` tuples in document order. Each headline is associated with the most recent publication timestamp seen before it. Timestamps reset at each ``
``/``
  • `` boundary so a headline with no date of its own does not inherit another story's timestamp. """ candidates = [] last_published = None for match in _CANDIDATE_RE.finditer(source_content): if match.group(1) is not None: # A element: prefer an explicit datetime/title # attribute, otherwise fall back to its inner text. attrs = match.group(1) attr_match = re.search(r'(?:datetime|dateTime)\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) \ or re.search(r'title\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) raw = attr_match.group(1) if attr_match else match.group(2) parsed = parse_datetime(raw) if parsed is not None: last_published = parsed elif match.group(3) is not None: # A datetime attribute on some non-