import logging from datetime import date, datetime, timezone from email.utils import parsedate_to_datetime logger = logging.getLogger(__name__) # Common absolute-date formats encountered in news markup when ISO parsing fails. _FALLBACK_FORMATS = ( "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%B %d, %Y", "%b %d, %Y", "%B %d, %Y %I:%M %p", "%b %d, %Y %I:%M %p", ) def parse_datetime(value): """Best-effort parse of a publish timestamp into a ``datetime``. Handles ISO 8601 (including trailing ``Z`` and offsets), RFC 2822/email dates, and a few common human formats. Returns ``None`` when the value cannot be parsed — relative strings like "3 hours ago" are intentionally skipped. """ if not value or not isinstance(value, str): return None text = value.strip() if not text: return None try: return datetime.fromisoformat(text) except ValueError: pass try: return parsedate_to_datetime(text) except (TypeError, ValueError, OverflowError): pass for fmt in _FALLBACK_FORMATS: try: return datetime.strptime(text, fmt) except ValueError: pass logger.debug("Could not parse datetime from value %r", value) return None def parse_date_arg(value): """Parse a ``YYYY-MM-DD`` CLI argument, raising a friendly error otherwise.""" try: return date.fromisoformat(value) except ValueError: raise ValueError("Invalid date %r; expected YYYY-MM-DD" % value) def date_part(dt): """Return ``dt`` as a ``date``, normalized to UTC when timezone-aware.""" if dt.tzinfo is not None: return dt.astimezone(timezone.utc).date() return dt.date() def filter_by_date_window(headlines, since=None, until=None): """Keep headlines whose publication date falls in ``[since, until]``. ``since``/``until`` are ``datetime.date`` (or ``None`` for an open bound). When either bound is set, headlines with no parsable publication date are dropped because their recency cannot be established; without any bound they are all kept. Returns ``(kept, dropped_undated)``. """ kept = [] dropped_undated = 0 bounding = since is not None or until is not None for h in headlines: if h.published_at is None: if bounding: dropped_undated += 1 continue kept.append(h) continue d = date_part(h.published_at) if since is not None and d < since: continue if until is not None and d > until: continue kept.append(h) return kept, dropped_undated