Rohe Feed-Antworten ablegen und Parser erweitern

Der Abruf warf bisher fast alles weg, was im XML stand. Neu ausgewertet
werden Kategorien (<category>, dc:subject, Atom-term), content:encoded
zusaetzlich zur kurzen description, Medien-URLs aus enclosure und Media
RSS, Verweise aus dem Teaser-HTML und die Position im Feed beim ersten
Sehen. Die Kategorien sind fuer Stufe 2 der wertvollste Posten: vom
Verlag selbst vergebene Themenlabel, brauchbar als Vorfilter und als
Prompt-Kontext. Gemessen an zwoelf Quellen tragen 189 von 814 Items
Kategorien, 315 einen laengeren Volltext, 430 Medien.

Ausserdem landen die rohen Antwortbytes gezippt in feed_abrufe,
dedupliziert ueber ihren sha256. Der Parser wird sich weiter aendern;
nur mit den Rohbytes laesst sich eine Verbesserung rueckwirkend auf die
Historie anwenden - dieselbe Ueberlegung wie bei der Trennung von
raw_items und codings. Kompression rund 22 %, etwa 20 kB je Abruf.

Items ohne Titel werden weiter uebersprungen, jetzt aber gezaehlt.

Bestandszeilen bekommen die neuen Felder beim naechsten Lauf nachgetragen,
ohne als Revision zu zaehlen; der Inhalts-Hash bleibt dafuer auf
Titel/Teaser/URL beschraenkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAdZb3EifGb9nvQ5Zp2TE
This commit is contained in:
2026-09-05 18:21:06 +02:00
co-authored by Claude Opus 5
parent 4983662514
commit 5a9bbe6e92
4 changed files with 325 additions and 41 deletions
+190 -39
View File
@@ -46,7 +46,12 @@ NS = {
"a": "http://www.w3.org/2005/Atom",
"dc": "http://purl.org/dc/elements/1.1/",
"content": "http://purl.org/rss/1.0/modules/content/",
"media": "http://search.yahoo.com/mrss/",
}
# Header, die wir zum Abruf mitschreiben. Kosten nichts und beantworten
# spaeter Fragen zur Zwischenspeicherung, die man sonst nicht mehr stellen kann.
HEADER_MERKEN = ("Date", "Age", "Cache-Control", "Content-Type",
"Content-Length", "Server", "ETag", "Last-Modified")
_lauf = True
@@ -135,7 +140,12 @@ def _erst(item, pfade):
# Feed-Abruf und -Parsing
# --------------------------------------------------------------------------
def hole(url, etag=None, last_modified=None, timeout=30):
"""Bedingter GET. Gibt (status, bytes|None, etag, last_modified) zurueck."""
"""Bedingter GET.
Gibt (status, bytes|None, etag, last_modified, header) zurueck. Die
Rohbytes werden bewusst durchgereicht und nicht nur geparst: der Parser
wird sich aendern, die Historie soll dann rueckwirkend davon profitieren.
"""
kopf = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
if etag:
kopf["If-None-Match"] = etag
@@ -147,23 +157,75 @@ def hole(url, etag=None, last_modified=None, timeout=30):
roh = r.read()
if r.headers.get("Content-Encoding") == "gzip":
roh = gzip.decompress(roh)
return (r.status, roh,
r.headers.get("ETag"), r.headers.get("Last-Modified"))
header = {k: r.headers.get(k) for k in HEADER_MERKEN
if r.headers.get(k)}
return (r.status, roh, r.headers.get("ETag"),
r.headers.get("Last-Modified"), header)
except urllib.error.HTTPError as err:
if err.code == 304:
return 304, None, etag, last_modified
return 304, None, etag, last_modified, {}
raise
_HREF_RE = re.compile(r'href=["\']([^"\']+)["\']', re.I)
def _kategorien(it):
"""<category>, dc:subject, Atom-term. Vom Verlag selbst vergebene Label."""
aus = []
for el in it.findall("category") + it.findall("dc:subject", NS):
if _text(el):
aus.append(_text(el))
for el in it.findall("a:category", NS):
if el.get("term"):
aus.append(el.get("term"))
# Reihenfolge erhalten, Dubletten raus
return list(dict.fromkeys(aus))
def _medien(it):
"""enclosure und Media RSS. Acht von zwoelf Quellen liefern Bilder."""
aus = []
for el in it.findall("enclosure"):
if el.get("url"):
aus.append({"url": el.get("url"), "typ": el.get("type"),
"bytes": el.get("length"), "rolle": "enclosure"})
for tag, rolle in (("media:content", "content"),
("media:thumbnail", "thumbnail")):
for el in it.findall(tag, NS):
if el.get("url"):
aus.append({"url": el.get("url"), "typ": el.get("type"),
"bytes": el.get("fileSize"), "rolle": rolle})
return aus
def _links(*html_stuecke):
"""Verlinkungen aus dem Teaser-HTML, bevor klartext() die Tags entfernt.
GDELT haelt das Gegenstueck als PAGE_LINKS im GKG: welche Meldung auf
welche verweist, ist ein eigenes Signal.
"""
aus = []
for st in html_stuecke:
if st:
aus += _HREF_RE.findall(html.unescape(st))
return list(dict.fromkeys(aus))
def parse_feed(roh):
"""RSS 2.0, RDF und Atom auf eine gemeinsame Form bringen."""
"""RSS 2.0, RDF und Atom auf eine gemeinsame Form bringen.
Gibt (items, uebersprungen) zurueck. Uebersprungen wird nur, was keinen
Titel hat - das wird gezaehlt statt stillschweigend verworfen.
"""
wurzel = ET.fromstring(roh)
eintraege = wurzel.findall(".//item") or wurzel.findall(".//a:entry", NS)
aus = []
for it in eintraege:
aus, uebersprungen = [], 0
for pos, it in enumerate(eintraege):
titel = _erst(it, ["title", "a:title"])
if not titel:
continue # ohne Titel ist ein Item fuer uns wertlos
uebersprungen += 1
continue
link = _erst(it, ["link", "a:id"])
if not link:
@@ -176,20 +238,36 @@ def parse_feed(roh):
if not guid:
guid = hashlib.sha256(titel.encode()).hexdigest()[:32]
# Kurzfassung und Volltext getrennt halten: frueher gewann die kurze
# description und content:encoded fiel unter den Tisch.
teaser_html = _erst(it, ["description", "a:summary"])
voll_html = _erst(it, ["content:encoded", "a:content"])
if not teaser_html:
teaser_html = voll_html
eintrag = {
"titel": klartext(titel),
"url": link,
"guid": guid,
"teaser": klartext(_erst(it, ["description", "content:encoded",
"a:summary", "a:content"])),
"position": pos, # Reihenfolge im Feed = oft die Gewichtung
"teaser": klartext(teaser_html),
"volltext": klartext(voll_html) if voll_html is not teaser_html else None,
"autor": _erst(it, ["author", "dc:creator", "a:author/a:name"]),
"pubdate": datum(_erst(it, ["pubDate", "dc:date",
"a:published", "a:updated"])),
"kategorien": _kategorien(it),
"medien": _medien(it),
"links": _links(teaser_html, voll_html),
"pubdate": datum(_erst(it, ["pubDate", "dc:date", "a:published"])),
"aktualisiert": datum(_erst(it, ["a:updated", "dc:modified"])),
}
if eintrag["pubdate"] is None:
eintrag["pubdate"] = eintrag["aktualisiert"]
# Der Hash bleibt bewusst auf Titel/Teaser/URL beschraenkt. Naehme er
# die neuen Felder auf, gaelte beim ersten Lauf nach dem Update jedes
# Bestandsitem als revidiert.
eintrag["inhalt_hash"] = inhalt_hash(
eintrag["titel"], eintrag["teaser"], eintrag["url"])
aus.append(eintrag)
return aus
return aus, uebersprungen
# --------------------------------------------------------------------------
@@ -241,7 +319,7 @@ class JsonlSenke:
frisch.append(it)
self.db.commit()
if not frisch:
return 0, 0
return 0, 0, 0
tag = self.ziel / slot.strftime("%Y%m%d")
tag.mkdir(exist_ok=True)
pfad = tag / (slot.strftime("%Y%m%d%H%M%S") + ".jsonl.gz")
@@ -252,7 +330,20 @@ class JsonlSenke:
{"quelle": quelle["id"], "slot": slot.isoformat(),
"abgerufen_am": _jetzt().isoformat(), **it},
ensure_ascii=False, default=str) + "\n")
return neu, geaendert
return neu, geaendert, 0
def speichere_feed(self, quelle, slot, status, header, koerper):
"""Rohes Feed-XML gezippt ablegen, dedupliziert ueber den Hash."""
if not koerper:
return False
h = hashlib.sha256(koerper).hexdigest()
tag = self.ziel / slot.strftime("%Y%m%d") / "feeds"
tag.mkdir(parents=True, exist_ok=True)
pfad = tag / f"{quelle['id']}-{h[:16]}.xml.gz"
if pfad.exists():
return False
pfad.write_bytes(gzip.compress(koerper))
return True
def notiere(self, *a, **kw):
pass
@@ -305,14 +396,17 @@ class PostgresSenke:
ist. Bleibt der Hash gleich, liefert das WHERE keine Zeile zurueck und
es passiert nichts.
"""
neu = geaendert = 0
neu = geaendert = nachgetragen = 0
with self.conn.cursor() as cur:
for it in items:
cur.execute("""
insert into raw_items
(quelle, item_key, url, titel, titel_norm, teaser,
autor, pubdate, slot, inhalt_hash, roh)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
autor, pubdate, slot, inhalt_hash, roh,
position, volltext, kategorien, medien, links,
aktualisiert)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s)
on conflict (quelle, item_key) do update set
url = excluded.url,
titel = excluded.titel,
@@ -320,33 +414,57 @@ class PostgresSenke:
teaser = excluded.teaser,
autor = excluded.autor,
pubdate = excluded.pubdate,
position = coalesce(raw_items.position,
excluded.position),
volltext = excluded.volltext,
kategorien = excluded.kategorien,
medien = excluded.medien,
links = excluded.links,
aktualisiert = excluded.aktualisiert,
inhalt_hash = excluded.inhalt_hash,
roh = excluded.roh,
revisionen = raw_items.revisionen + 1,
zuletzt_geaendert = now()
roh = excluded.roh
where raw_items.inhalt_hash is distinct from excluded.inhalt_hash
or raw_items.kategorien is null
returning id, (xmax = 0) as ist_neu
""", (quelle["id"], it["guid"], it["url"], it["titel"],
norm_titel(it["titel"]), it["teaser"], it["autor"],
it["pubdate"], slot, it["inhalt_hash"],
json.dumps(it, ensure_ascii=False, default=str)))
json.dumps(it, ensure_ascii=False, default=str),
it["position"], it["volltext"], it["kategorien"],
json.dumps(it["medien"], ensure_ascii=False),
it["links"], it["aktualisiert"]))
zeile = cur.fetchone()
if not zeile:
continue # unveraendert
continue # unveraendert, nichts zu tun
rid, ist_neu = zeile
if ist_neu:
neu += 1
else:
geaendert += 1
# Ob der Inhalt wirklich neu ist, sagt der Unique-Index der
# Fassungstabelle - zuverlaessiger als ein Vergleich in
# RETURNING, wo die Zeile schon fortgeschrieben ist.
cur.execute("""
insert into raw_item_versionen
(raw_item_id, slot, titel, teaser, url, inhalt_hash)
values (%s, %s, %s, %s, %s, %s)
on conflict (raw_item_id, inhalt_hash) do nothing
returning id
""", (rid, slot, it["titel"], it["teaser"], it["url"],
it["inhalt_hash"]))
inhalt_neu = cur.fetchone() is not None
if ist_neu:
neu += 1
elif inhalt_neu:
geaendert += 1
cur.execute("""
update raw_items
set revisionen = revisionen + 1,
zuletzt_geaendert = now()
where id = %s
""", (rid,))
else:
nachgetragen += 1 # nur die neuen Felder gefuellt
self.conn.commit()
return neu, geaendert
return neu, geaendert, nachgetragen
def merke_zustand(self, qid, etag, last_modified, geaendert):
with self.conn.cursor() as cur:
@@ -359,16 +477,43 @@ class PostgresSenke:
""", (etag, last_modified, geaendert, qid))
self.conn.commit()
def speichere_feed(self, quelle, slot, status, header, koerper):
"""Rohes Feed-XML gezippt ablegen, dedupliziert ueber den Hash.
Der Parser wird sich aendern. Nur mit den Rohbytes laesst sich eine
spaetere Verbesserung rueckwirkend auf die Historie anwenden - dieselbe
Ueberlegung, aus der raw_items und codings getrennt sind.
"""
if not koerper:
return False
h = hashlib.sha256(koerper).hexdigest()
gz = gzip.compress(koerper)
with self.conn.cursor() as cur:
cur.execute("""
insert into feed_abrufe
(quelle, slot, http_status, header, koerper_hash,
koerper, bytes_roh, bytes_gz)
values (%s, %s, %s, %s, %s, %s, %s, %s)
on conflict (quelle, koerper_hash) do nothing
returning id
""", (quelle["id"], slot, status,
json.dumps(header, ensure_ascii=False), h,
gz, len(koerper), len(gz)))
neu = cur.fetchone() is not None
self.conn.commit()
return neu
def notiere(self, quelle, slot, dauer_ms, status, gesehen, neu,
geaendert, fehler):
geaendert, uebersprungen, bytes_roh, fehler):
with self.conn.cursor() as cur:
cur.execute("""
insert into poll_laeufe
(quelle, slot, dauer_ms, http_status,
items_gesehen, items_neu, items_geaendert, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s)
(quelle, slot, dauer_ms, http_status, items_gesehen,
items_neu, items_geaendert, items_uebersprungen,
bytes_roh, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (quelle, slot, dauer_ms, status, gesehen, neu, geaendert,
fehler))
uebersprungen, bytes_roh, fehler))
self.conn.commit()
def close(self):
@@ -390,16 +535,19 @@ def durchlauf(quellen, senke, slot):
continue
t0 = time.monotonic()
etag, lm = senke.zustand(q["id"])
status = gesehen = neu = geaendert = 0
status = gesehen = neu = geaendert = uebersprungen = bytes_roh = 0
nachgetragen = 0
fehler = None
try:
status, roh, n_etag, n_lm = hole(q["url"], etag, lm)
status, roh, n_etag, n_lm, header = hole(q["url"], etag, lm)
if status == 304:
pass
else:
items = parse_feed(roh)
bytes_roh = len(roh)
senke.speichere_feed(q, slot, status, header, roh)
items, uebersprungen = parse_feed(roh)
gesehen = len(items)
neu, geaendert = senke.schreibe(q, items, slot)
neu, geaendert, nachgetragen = senke.schreibe(q, items, slot)
if hasattr(senke, "merke_zustand"):
senke.merke_zustand(q["id"], n_etag, n_lm, neu > 0)
except Exception as e: # eine Quelle darf nicht
@@ -407,13 +555,16 @@ def durchlauf(quellen, senke, slot):
status = getattr(e, "code", None)
dauer = int((time.monotonic() - t0) * 1000)
senke.notiere(q["id"], slot, dauer, status, gesehen, neu, geaendert,
fehler)
uebersprungen, bytes_roh, fehler)
ges_neu += neu
ges_geaendert += geaendert
ges_gesehen += gesehen
marke = "!" if fehler else (" " if status != 304 else ".")
print(f" {marke} {q['id']:14} {str(status):>4} "
f"{gesehen:4} gesehen {neu:4} neu {geaendert:3} rev {dauer:5} ms"
f"{gesehen:4} gesehen {neu:4} neu {geaendert:3} rev "
f"{uebersprungen:2} uebrg"
+ (f" {nachgetragen:4} nachgetr" if nachgetragen else "")
+ f" {dauer:5} ms"
+ (f" {fehler}" if fehler else ""), flush=True)
return ges_gesehen, ges_neu, ges_geaendert