"""News-Aggregator fuer das Aktuelle-Themen-Dashboard (#170 Phase 1).
Fetcht regelmaessig News-Headlines aus AI-erlaubenden, oeffentlich-rechtlichen
oder parlamentarischen Quellen:
- **Tagesschau-API** (https://www.tagesschau.de/api2u/news/) — strukturiertes
JSON mit ressort, tags, firstSentence pro Artikel.
- **Bundestag-Aktuellethemen-RSS**
(https://www.bundestag.de/static/appdata/includes/rss/aktuellethemen.rss)
— RSS mit Titel + Beschreibung pro Artikel.
**Bewusst NICHT verwendet:** RND.de (robots.txt bannt explizit ClaudeBot,
GPTBot, ChatGPT-User, CCBot, Google-Extended). RSS-Feeds privat-publizierter
Verlage werden nur dann angebunden, wenn AI-Verarbeitung explizit erlaubt ist.
**Compliance:**
- Volltexte werden NICHT persistiert. Nur Titel + erster Satz / Description.
- Kein User-Agent, der einen AI-Bot vortaeuscht (kein "ClaudeBot").
- Rate-Limiting: 1 Request pro Quelle pro Aufruf (kein Loop, kein Hammer).
Datenbank-Tabelle ``news_articles`` (siehe app/database.py):
url PK, titel, summary, datum (ISO), source, ressort, tags JSON,
summary_embedding BLOB, embedding_model, fetched_at.
"""
from __future__ import annotations
import json
import logging
import re
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
USER_AGENT = "GWOeAntragspruefer/1.0 (+https://gwoe.toppyr.de)"
TIMEOUT = 20
# ─────────────────────────────────────────────────────────────────────────────
# Quellen
# ─────────────────────────────────────────────────────────────────────────────
TAGESSCHAU_API = "https://www.tagesschau.de/api2u/news"
# Politische Tagesschau-Ressorts — Sport/Panorama/Sport rausgefiltert,
# weil sie selten zu parlamentarischen Antraegen passen.
TAGESSCHAU_RESSORTS = ["inland", "ausland", "wirtschaft", "wissen"]
BUNDESTAG_RSS = {
"bundestag-aktuell": (
"https://www.bundestag.de/static/appdata/includes/rss/aktuellethemen.rss"
),
"bundestag-presse": (
"https://www.bundestag.de/static/appdata/includes/rss/pressemitteilungen.rss"
),
"bundestag-hib": (
"https://www.bundestag.de/static/appdata/includes/rss/hib.rss"
),
}
# ─────────────────────────────────────────────────────────────────────────────
# HTTP-Helper
# ─────────────────────────────────────────────────────────────────────────────
def _http_get(url: str) -> Optional[bytes]:
"""GET mit ehrlichem User-Agent + Timeout. Gibt None bei Fehler."""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return r.read()
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
logger.warning("news fetch failed: %s — %s", url, e)
return None
def _strip_html(text: str) -> str:
"""Entfernt HTML-Tags + CDATA fuer Plaintext-Summaries."""
if not text:
return ""
text = re.sub(r"", r"\1", text, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
text = text.replace("&", "&").replace(" ", " ").replace(""", '"')
return re.sub(r"\s+", " ", text).strip()
# ─────────────────────────────────────────────────────────────────────────────
# Parser
# ─────────────────────────────────────────────────────────────────────────────
def fetch_tagesschau(ressorts: Optional[list[str]] = None) -> list[dict]:
"""Holt News aus der Tagesschau-API. Liefert Liste von Dicts mit den
Feldern: url, titel, summary, datum, source, ressort, tags.
Volltexte (``content``) werden bewusst nicht uebernommen — nur die in
der API verfuegbare ``firstSentence`` als Summary.
"""
ressorts = ressorts or TAGESSCHAU_RESSORTS
out: list[dict] = []
seen: set[str] = set()
for ressort in ressorts:
url = f"{TAGESSCHAU_API}?ressort={ressort}"
raw = _http_get(url)
if not raw:
continue
try:
data = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
logger.warning("tagesschau JSON parse failed: %s", url)
continue
for item in data.get("news") or []:
link = item.get("shareURL") or item.get("detailsweb")
if not link or link in seen:
continue
seen.add(link)
titel = (item.get("title") or "").strip()
if not titel:
continue
summary = (item.get("firstSentence") or "").strip()
datum = item.get("date") or ""
tags = [t.get("tag") for t in (item.get("tags") or []) if t.get("tag")]
out.append({
"url": link,
"titel": titel,
"summary": summary,
"datum": datum,
"source": "tagesschau",
"ressort": item.get("ressort") or ressort,
"tags": tags,
})
return out
_RSS_ITEM_RE = re.compile(r"