Optional meta-search source (SEARXNG_BASE_URL): per-query isolation, time_range=day, merged and deduplicated with the RSS feeds. Briefings still publish on RSS alone if the instance is down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
"""News collector: curated RSS feeds via requests + feedparser.
|
|
|
|
Each feed is fetched independently so that one broken feed never spoils the others.
|
|
Only if *every* feed fails does `fetch_news` raise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import html
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from html.parser import HTMLParser
|
|
|
|
import feedparser
|
|
import requests
|
|
|
|
from us2kr.models import NewsItem
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
USER_AGENT = "Mozilla/5.0 (compatible; US2KR/0.1; +https://github.com/us2kr) feed-reader"
|
|
FEED_TIMEOUT_SECONDS = 10.0
|
|
SUMMARY_MAX_CHARS = 300
|
|
|
|
# (source label, feed url). Order matters only for tie-breaking on identical timestamps.
|
|
FEEDS: list[tuple[str, str]] = [
|
|
("CNBC Top News", "https://www.cnbc.com/id/100003114/device/rss/rss.html"),
|
|
("CNBC Markets", "https://www.cnbc.com/id/20910258/device/rss/rss.html"),
|
|
("MarketWatch Top Stories", "https://feeds.content.dowjones.io/public/rss/mw_topstories"),
|
|
("MarketWatch MarketPulse", "https://feeds.content.dowjones.io/public/rss/mw_marketpulse"),
|
|
("Yahoo Finance", "https://finance.yahoo.com/news/rssindex"),
|
|
("Federal Reserve", "https://www.federalreserve.gov/feeds/press_all.xml"),
|
|
("Investing.com", "https://www.investing.com/rss/news_25.rss"),
|
|
]
|
|
|
|
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
|
|
_WS_RE = re.compile(r"\s+")
|
|
|
|
|
|
class _TextExtractor(HTMLParser):
|
|
"""Minimal HTML -> text: keeps text nodes, drops tags/scripts/styles."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self._parts: list[str] = []
|
|
self._skip = 0
|
|
|
|
def handle_starttag(self, tag: str, attrs) -> None:
|
|
if tag in ("script", "style"):
|
|
self._skip += 1
|
|
elif tag in ("br", "p", "div", "li"):
|
|
self._parts.append(" ")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag in ("script", "style") and self._skip:
|
|
self._skip -= 1
|
|
elif tag in ("p", "div", "li"):
|
|
self._parts.append(" ")
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if not self._skip:
|
|
self._parts.append(data)
|
|
|
|
def text(self) -> str:
|
|
return "".join(self._parts)
|
|
|
|
|
|
def strip_html(raw: str | None, max_chars: int = SUMMARY_MAX_CHARS) -> str:
|
|
"""Remove tags/entities, collapse whitespace and truncate to `max_chars`."""
|
|
if not raw:
|
|
return ""
|
|
parser = _TextExtractor()
|
|
try:
|
|
parser.feed(raw)
|
|
parser.close()
|
|
text = parser.text()
|
|
except Exception: # noqa: BLE001 - malformed HTML must not kill the collector
|
|
text = re.sub(r"<[^>]+>", " ", raw)
|
|
text = _WS_RE.sub(" ", html.unescape(text)).strip()
|
|
if len(text) > max_chars:
|
|
text = text[: max_chars - 1].rstrip() + "…"
|
|
return text
|
|
|
|
|
|
def normalize_title(title: str) -> str:
|
|
"""Lowercase, drop punctuation and collapse whitespace - used as a dedup key."""
|
|
return _WS_RE.sub(" ", _PUNCT_RE.sub("", title.lower())).strip()
|
|
|
|
|
|
def _entry_datetime(entry) -> datetime | None:
|
|
"""Return tz-aware UTC datetime from published_parsed / updated_parsed, else None."""
|
|
for key in ("published_parsed", "updated_parsed", "created_parsed"):
|
|
tm = entry.get(key)
|
|
if tm:
|
|
try:
|
|
return datetime.fromtimestamp(calendar.timegm(tm), tz=UTC)
|
|
except (OverflowError, ValueError, TypeError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _fetch_feed_bytes(url: str) -> bytes:
|
|
resp = requests.get(url, timeout=FEED_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT})
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
|
|
def _parse_feed(source: str, content: bytes, since: datetime) -> list[NewsItem]:
|
|
parsed = feedparser.parse(content)
|
|
if parsed.get("bozo") and not parsed.entries:
|
|
raise ValueError(f"unparseable feed: {parsed.get('bozo_exception')}")
|
|
items: list[NewsItem] = []
|
|
for entry in parsed.entries:
|
|
title = _WS_RE.sub(" ", html.unescape(entry.get("title") or "")).strip()
|
|
link = (entry.get("link") or "").strip()
|
|
if not title or not link:
|
|
continue
|
|
published = _entry_datetime(entry)
|
|
if published is None or published < since:
|
|
continue
|
|
summary = strip_html(entry.get("summary") or entry.get("description") or "")
|
|
items.append(NewsItem(title=title, summary=summary, link=link, source=source, published=published))
|
|
return items
|
|
|
|
|
|
def dedup_news(items: list[NewsItem]) -> list[NewsItem]:
|
|
"""Newest first, deduplicated by normalised title and by link (fragment/trailing slash ignored)."""
|
|
seen_titles: set[str] = set()
|
|
seen_links: set[str] = set()
|
|
unique: list[NewsItem] = []
|
|
# Stable sort newest first before dedup so the freshest duplicate is the one kept.
|
|
for item in sorted(items, key=lambda n: n.published, reverse=True):
|
|
tkey = normalize_title(item.title)
|
|
lkey = item.link.split("#", 1)[0].rstrip("/").lower()
|
|
if tkey in seen_titles or lkey in seen_links:
|
|
continue
|
|
seen_titles.add(tkey)
|
|
seen_links.add(lkey)
|
|
unique.append(item)
|
|
return unique
|
|
|
|
|
|
def fetch_news(since: datetime, max_items: int = 40) -> list[NewsItem]:
|
|
"""Collect headlines published at/after `since` from all feeds, deduplicated, newest first.
|
|
|
|
Raises RuntimeError only when every feed failed.
|
|
"""
|
|
if since.tzinfo is None:
|
|
since = since.replace(tzinfo=UTC)
|
|
else:
|
|
since = since.astimezone(UTC)
|
|
|
|
collected: list[NewsItem] = []
|
|
failures: list[str] = []
|
|
for source, url in FEEDS:
|
|
try:
|
|
content = _fetch_feed_bytes(url)
|
|
items = _parse_feed(source, content, since)
|
|
except Exception as exc: # noqa: BLE001 - per-feed isolation
|
|
log.warning("news feed %s failed: %s: %s", source, type(exc).__name__, str(exc)[:200])
|
|
failures.append(f"{source}: {type(exc).__name__}")
|
|
continue
|
|
log.info("news feed %s: %d items since %s", source, len(items), since.isoformat())
|
|
collected.extend(items)
|
|
|
|
if failures and len(failures) == len(FEEDS):
|
|
raise RuntimeError("all news feeds failed: " + "; ".join(failures))
|
|
|
|
unique = dedup_news(collected)
|
|
|
|
log.info(
|
|
"news: %d raw -> %d unique, returning %d (feeds failed: %d/%d)",
|
|
len(collected),
|
|
len(unique),
|
|
min(len(unique), max_items),
|
|
len(failures),
|
|
len(FEEDS),
|
|
)
|
|
return unique[:max_items]
|