"""News ingestion — feedparser over the curated OPML feed list. The feed list is user-editable at digest-engine/feeds/curated-feeds.opml; each `` with an `xmlUrl` attribute is fetched. marxist.com is tagged in the OPML with `category="theory"` and that category is carried through onto every entry, because the political prompt treats it as the analytical basis rather than as one more headline source. """ import calendar import html import logging import os import re import xml.etree.ElementTree as ElementTree from datetime import datetime, timedelta, timezone LOG = logging.getLogger(__name__) DEFAULT_OPML_PATH = "/app/feeds/curated-feeds.opml" MAX_SUMMARY_CHARS = 1200 _TAG_RE = re.compile(r"<[^>]+>") def _plain_text(markup): return html.unescape(_TAG_RE.sub(" ", markup or "")).strip() def _read_opml(path): feeds = [] tree = ElementTree.parse(path) for outline in tree.iter("outline"): url = outline.get("xmlUrl") if not url: continue feeds.append( { "url": url, "title": outline.get("title") or outline.get("text") or url, "category": outline.get("category") or "news", } ) return feeds def _entry_time(entry): parsed = entry.get("published_parsed") or entry.get("updated_parsed") if not parsed: return None return datetime.fromtimestamp(calendar.timegm(parsed), tz=timezone.utc) def fetch(lookback_hours): opml_path = os.environ.get("NEWS_OPML_PATH", DEFAULT_OPML_PATH) max_per_feed = int(os.environ.get("NEWS_MAX_ENTRIES_PER_FEED", "15")) # Imported here, not at module scope, so a missing/broken optional dependency # degrades this one source instead of the whole run. try: import feedparser except ImportError: LOG.warning("news: feedparser is not installed, skipping", exc_info=True) return [] try: feeds = _read_opml(opml_path) except Exception: LOG.warning("news: could not read OPML at %s, returning nothing", opml_path, exc_info=True) return [] if not feeds: LOG.warning("news: no feeds with an xmlUrl in %s, skipping", opml_path) return [] since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours) entries = [] for feed in feeds: try: parsed = feedparser.parse(feed["url"]) if parsed.get("bozo") and not parsed.get("entries"): LOG.warning("news: feed %s did not parse, skipping", feed["url"]) continue kept = 0 for entry in parsed.entries: if kept >= max_per_feed: break published = _entry_time(entry) if published and published < since: continue entries.append( { "source": "news", "feed": feed["title"], "category": feed["category"], "title": entry.get("title", "").strip(), "link": entry.get("link", ""), "timestamp": published.isoformat() if published else None, "summary": _plain_text(entry.get("summary"))[:MAX_SUMMARY_CHARS], } ) kept += 1 except Exception: LOG.warning("news: feed %s failed, skipping", feed["url"], exc_info=True) LOG.info("news: %d entr(ies) from %d feed(s) in the last %sh", len(entries), len(feeds), lookback_hours) return entries