SmartestHome/digest-engine/ingest/news_rss.py

109 lines
3.6 KiB
Python

"""News ingestion — feedparser over the curated OPML feed list.
The feed list is user-editable at digest-engine/feeds/curated-feeds.opml; each
`<outline>` 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