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
+50 -2
View File
@@ -21,8 +21,9 @@ Kodierung — was hier landet, soll sich beliebig oft neu kodieren lassen.
# 1. Schema einspielen (Neuinstallation) # 1. Schema einspielen (Neuinstallation)
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql
# Bestehende Installation stattdessen migrieren: # Bestehende Installation stattdessen migrieren, aufsteigend:
# psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/001-revisionen.sql # psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/001-revisionen.sql
# psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/002-rohablage-und-felder.sql
# 2. Image bauen # 2. Image bauen
podman build -t localhost/wurzelwerk-ingest:latest . podman build -t localhost/wurzelwerk-ingest:latest .
@@ -57,7 +58,7 @@ python3 poller.py --sink jsonl:/pfad/zu/deinem/ablageordner
``` ```
Legt `<dir>/YYYYMMDD/YYYYMMDDHHMMSS.jsonl.gz` an, dedupliziert ueber eine Legt `<dir>/YYYYMMDD/YYYYMMDDHHMMSS.jsonl.gz` an, dedupliziert ueber eine
SQLite daneben. Als `systemd --user`-Unit mit `Restart=always` einrichten. SQLite daneben, und die rohen Feeds unter `<dir>/YYYYMMDD/feeds/`. Als `systemd --user`-Unit mit `Restart=always` einrichten.
Der Sinn ist nicht Redundanz um ihrer selbst willen: RSS-Feeds haben kein Der Sinn ist nicht Redundanz um ihrer selbst willen: RSS-Feeds haben kein
Archiv. Steht der Produktiv-Poller einen Tag still, ist dieser Tag Archiv. Steht der Produktiv-Poller einen Tag still, ist dieser Tag
@@ -96,6 +97,26 @@ select quelle, count(*) filter (where revisionen > 0) as revidiert,
from raw_items group by quelle order by quote desc; from raw_items group by quelle order by quote desc;
``` ```
### Was die Verlage selbst labeln
```sql
-- Themenverteilung je Quelle, ohne einen Kodierer bemueht zu haben
select quelle, k as kategorie, count(*)
from raw_items, unnest(kategorien) k
group by quelle, k order by count(*) desc limit 20;
```
### Speicherbedarf im Blick behalten
```sql
select pg_size_pretty(pg_total_relation_size('feed_abrufe')) as rohablage,
pg_size_pretty(pg_total_relation_size('raw_items')) as items;
-- Rohablage aelter als ein Jahr wegwerfen, falls es eng wird. raw_items
-- bleibt davon unberuehrt; nur die Moeglichkeit, neu zu parsen, entfaellt.
-- delete from feed_abrufe where abgerufen_am < now() - interval '1 year';
```
### Waechter ### Waechter
```sql ```sql
@@ -138,10 +159,37 @@ ueber die gesamte Historie, ohne dass ein Feed erneut abgerufen wird.
Link, ersatzweise ein Titel-Hash. Link, ersatzweise ein Titel-Hash.
- **Fehlertoleranz**: eine kaputte Quelle beendet den Durchlauf nicht, der - **Fehlertoleranz**: eine kaputte Quelle beendet den Durchlauf nicht, der
Fehler landet in `poll_laeufe`. Fehler landet in `poll_laeufe`.
- **Rohe Feed-Antworten** landen gezippt in `feed_abrufe`, dedupliziert ueber
den sha256 der Rohbytes. Der Parser wird sich aendern; nur mit den Rohbytes
laesst sich eine spaetere Verbesserung rueckwirkend auf die Historie
anwenden. Kompression rund 22 %, etwa 20 kB je abgelegtem Abruf.
- **Der Parser nimmt mit, was im XML steht**: 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.
- **Items ohne Titel** werden weiterhin uebersprungen, aber gezaehlt
(`poll_laeufe.items_uebersprungen`) statt stillschweigend verworfen.
- **Kein Volltext-Abruf.** Nur der Feed selbst wird geholt. Titel und Teaser - **Kein Volltext-Abruf.** Nur der Feed selbst wird geholt. Titel und Teaser
reichen als Kodierungsinput, und damit stellt sich die TDM-Frage nach reichen als Kodierungsinput, und damit stellt sich die TDM-Frage nach
§ 44b UrhG gar nicht erst. § 44b UrhG gar nicht erst.
## Migration bestehender Installationen
`001` und `002` sind idempotent und koennen im laufenden Betrieb eingespielt
werden; danach den Poller neu starten.
Nach `002` sind die neuen Spalten auf Bestandszeilen zunaechst leer. Der
naechste Lauf traegt sie fuer alle Items nach, die die Quelle noch ausliefert
(in der Ausgabe als `nachgetr`), ohne sie als Revision zu zaehlen. Items, die
inzwischen aus dem Feed gefallen sind, behalten leere Zusatzfelder - die Daten
sind nicht mehr abrufbar.
Der Inhalts-Hash bleibt bewusst auf Titel, Teaser und URL beschraenkt. Naehme
er die neuen Felder auf, gaelte beim ersten Lauf nach dem Update jedes
Bestandsitem als revidiert.
## Gemessen (2026-09-04) ## Gemessen (2026-09-04)
Erster Lauf ueber 12 aktive Quellen: 820 Items gesehen, 810 neu, gesamter Erster Lauf ueber 12 aktive Quellen: 820 Items gesehen, 810 neu, gesamter
+47
View File
@@ -0,0 +1,47 @@
-- Migration 002: Rohe Feed-Antworten ablegen, Parser-Felder ergaenzen
--
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/002-rohablage-und-felder.sql
--
-- Setzt 001 voraus. Bestandszeilen bekommen die neuen Spalten leer; sie
-- fuellen sich, sobald die Quelle das Item das naechste Mal ausliefert.
-- Der Inhalts-Hash bleibt bewusst auf Titel/Teaser/URL beschraenkt, sonst
-- gaelte beim ersten Lauf nach dem Update jedes Bestandsitem als revidiert.
begin;
alter table raw_items
add column if not exists position integer,
add column if not exists volltext text,
add column if not exists kategorien text[],
add column if not exists medien jsonb,
add column if not exists links text[],
add column if not exists aktualisiert timestamptz;
comment on column raw_items.kategorien is
'Vom Verlag selbst vergebene Themenlabel - Vorfilter und Prompt-Kontext';
comment on column raw_items.position is
'Rang im Feed beim ersten Sehen. Spaeter nicht fortgeschrieben';
create table if not exists feed_abrufe (
id bigserial primary key,
quelle text not null references quellen(id),
slot timestamptz not null,
abgerufen_am timestamptz not null default now(),
http_status integer,
header jsonb,
koerper_hash text not null,
koerper bytea not null,
bytes_roh integer,
bytes_gz integer,
unique (quelle, koerper_hash)
);
create index if not exists feed_abrufe_quelle_idx
on feed_abrufe (quelle, abgerufen_am desc);
alter table poll_laeufe
add column if not exists items_uebersprungen integer not null default 0,
add column if not exists bytes_roh integer;
commit;
+190 -39
View File
@@ -46,7 +46,12 @@ NS = {
"a": "http://www.w3.org/2005/Atom", "a": "http://www.w3.org/2005/Atom",
"dc": "http://purl.org/dc/elements/1.1/", "dc": "http://purl.org/dc/elements/1.1/",
"content": "http://purl.org/rss/1.0/modules/content/", "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 _lauf = True
@@ -135,7 +140,12 @@ def _erst(item, pfade):
# Feed-Abruf und -Parsing # Feed-Abruf und -Parsing
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def hole(url, etag=None, last_modified=None, timeout=30): 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"} kopf = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
if etag: if etag:
kopf["If-None-Match"] = etag kopf["If-None-Match"] = etag
@@ -147,23 +157,75 @@ def hole(url, etag=None, last_modified=None, timeout=30):
roh = r.read() roh = r.read()
if r.headers.get("Content-Encoding") == "gzip": if r.headers.get("Content-Encoding") == "gzip":
roh = gzip.decompress(roh) roh = gzip.decompress(roh)
return (r.status, roh, header = {k: r.headers.get(k) for k in HEADER_MERKEN
r.headers.get("ETag"), r.headers.get("Last-Modified")) 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: except urllib.error.HTTPError as err:
if err.code == 304: if err.code == 304:
return 304, None, etag, last_modified return 304, None, etag, last_modified, {}
raise 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): 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) wurzel = ET.fromstring(roh)
eintraege = wurzel.findall(".//item") or wurzel.findall(".//a:entry", NS) eintraege = wurzel.findall(".//item") or wurzel.findall(".//a:entry", NS)
aus = [] aus, uebersprungen = [], 0
for it in eintraege: for pos, it in enumerate(eintraege):
titel = _erst(it, ["title", "a:title"]) titel = _erst(it, ["title", "a:title"])
if not titel: if not titel:
continue # ohne Titel ist ein Item fuer uns wertlos uebersprungen += 1
continue
link = _erst(it, ["link", "a:id"]) link = _erst(it, ["link", "a:id"])
if not link: if not link:
@@ -176,20 +238,36 @@ def parse_feed(roh):
if not guid: if not guid:
guid = hashlib.sha256(titel.encode()).hexdigest()[:32] 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 = { eintrag = {
"titel": klartext(titel), "titel": klartext(titel),
"url": link, "url": link,
"guid": guid, "guid": guid,
"teaser": klartext(_erst(it, ["description", "content:encoded", "position": pos, # Reihenfolge im Feed = oft die Gewichtung
"a:summary", "a:content"])), "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"]), "autor": _erst(it, ["author", "dc:creator", "a:author/a:name"]),
"pubdate": datum(_erst(it, ["pubDate", "dc:date", "kategorien": _kategorien(it),
"a:published", "a:updated"])), "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["inhalt_hash"] = inhalt_hash(
eintrag["titel"], eintrag["teaser"], eintrag["url"]) eintrag["titel"], eintrag["teaser"], eintrag["url"])
aus.append(eintrag) aus.append(eintrag)
return aus return aus, uebersprungen
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -241,7 +319,7 @@ class JsonlSenke:
frisch.append(it) frisch.append(it)
self.db.commit() self.db.commit()
if not frisch: if not frisch:
return 0, 0 return 0, 0, 0
tag = self.ziel / slot.strftime("%Y%m%d") tag = self.ziel / slot.strftime("%Y%m%d")
tag.mkdir(exist_ok=True) tag.mkdir(exist_ok=True)
pfad = tag / (slot.strftime("%Y%m%d%H%M%S") + ".jsonl.gz") pfad = tag / (slot.strftime("%Y%m%d%H%M%S") + ".jsonl.gz")
@@ -252,7 +330,20 @@ class JsonlSenke:
{"quelle": quelle["id"], "slot": slot.isoformat(), {"quelle": quelle["id"], "slot": slot.isoformat(),
"abgerufen_am": _jetzt().isoformat(), **it}, "abgerufen_am": _jetzt().isoformat(), **it},
ensure_ascii=False, default=str) + "\n") 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): def notiere(self, *a, **kw):
pass pass
@@ -305,14 +396,17 @@ class PostgresSenke:
ist. Bleibt der Hash gleich, liefert das WHERE keine Zeile zurueck und ist. Bleibt der Hash gleich, liefert das WHERE keine Zeile zurueck und
es passiert nichts. es passiert nichts.
""" """
neu = geaendert = 0 neu = geaendert = nachgetragen = 0
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
for it in items: for it in items:
cur.execute(""" cur.execute("""
insert into raw_items insert into raw_items
(quelle, item_key, url, titel, titel_norm, teaser, (quelle, item_key, url, titel, titel_norm, teaser,
autor, pubdate, slot, inhalt_hash, roh) autor, pubdate, slot, inhalt_hash, roh,
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) 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 on conflict (quelle, item_key) do update set
url = excluded.url, url = excluded.url,
titel = excluded.titel, titel = excluded.titel,
@@ -320,33 +414,57 @@ class PostgresSenke:
teaser = excluded.teaser, teaser = excluded.teaser,
autor = excluded.autor, autor = excluded.autor,
pubdate = excluded.pubdate, 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, inhalt_hash = excluded.inhalt_hash,
roh = excluded.roh, roh = excluded.roh
revisionen = raw_items.revisionen + 1,
zuletzt_geaendert = now()
where raw_items.inhalt_hash is distinct from excluded.inhalt_hash where raw_items.inhalt_hash is distinct from excluded.inhalt_hash
or raw_items.kategorien is null
returning id, (xmax = 0) as ist_neu returning id, (xmax = 0) as ist_neu
""", (quelle["id"], it["guid"], it["url"], it["titel"], """, (quelle["id"], it["guid"], it["url"], it["titel"],
norm_titel(it["titel"]), it["teaser"], it["autor"], norm_titel(it["titel"]), it["teaser"], it["autor"],
it["pubdate"], slot, it["inhalt_hash"], 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() zeile = cur.fetchone()
if not zeile: if not zeile:
continue # unveraendert continue # unveraendert, nichts zu tun
rid, ist_neu = zeile rid, ist_neu = zeile
if ist_neu:
neu += 1 # Ob der Inhalt wirklich neu ist, sagt der Unique-Index der
else: # Fassungstabelle - zuverlaessiger als ein Vergleich in
geaendert += 1 # RETURNING, wo die Zeile schon fortgeschrieben ist.
cur.execute(""" cur.execute("""
insert into raw_item_versionen insert into raw_item_versionen
(raw_item_id, slot, titel, teaser, url, inhalt_hash) (raw_item_id, slot, titel, teaser, url, inhalt_hash)
values (%s, %s, %s, %s, %s, %s) values (%s, %s, %s, %s, %s, %s)
on conflict (raw_item_id, inhalt_hash) do nothing on conflict (raw_item_id, inhalt_hash) do nothing
returning id
""", (rid, slot, it["titel"], it["teaser"], it["url"], """, (rid, slot, it["titel"], it["teaser"], it["url"],
it["inhalt_hash"])) 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() self.conn.commit()
return neu, geaendert return neu, geaendert, nachgetragen
def merke_zustand(self, qid, etag, last_modified, geaendert): def merke_zustand(self, qid, etag, last_modified, geaendert):
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
@@ -359,16 +477,43 @@ class PostgresSenke:
""", (etag, last_modified, geaendert, qid)) """, (etag, last_modified, geaendert, qid))
self.conn.commit() 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, def notiere(self, quelle, slot, dauer_ms, status, gesehen, neu,
geaendert, fehler): geaendert, uebersprungen, bytes_roh, fehler):
with self.conn.cursor() as cur: with self.conn.cursor() as cur:
cur.execute(""" cur.execute("""
insert into poll_laeufe insert into poll_laeufe
(quelle, slot, dauer_ms, http_status, (quelle, slot, dauer_ms, http_status, items_gesehen,
items_gesehen, items_neu, items_geaendert, fehler) items_neu, items_geaendert, items_uebersprungen,
values (%s, %s, %s, %s, %s, %s, %s, %s) bytes_roh, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (quelle, slot, dauer_ms, status, gesehen, neu, geaendert, """, (quelle, slot, dauer_ms, status, gesehen, neu, geaendert,
fehler)) uebersprungen, bytes_roh, fehler))
self.conn.commit() self.conn.commit()
def close(self): def close(self):
@@ -390,16 +535,19 @@ def durchlauf(quellen, senke, slot):
continue continue
t0 = time.monotonic() t0 = time.monotonic()
etag, lm = senke.zustand(q["id"]) etag, lm = senke.zustand(q["id"])
status = gesehen = neu = geaendert = 0 status = gesehen = neu = geaendert = uebersprungen = bytes_roh = 0
nachgetragen = 0
fehler = None fehler = None
try: 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: if status == 304:
pass pass
else: 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) gesehen = len(items)
neu, geaendert = senke.schreibe(q, items, slot) neu, geaendert, nachgetragen = senke.schreibe(q, items, slot)
if hasattr(senke, "merke_zustand"): if hasattr(senke, "merke_zustand"):
senke.merke_zustand(q["id"], n_etag, n_lm, neu > 0) senke.merke_zustand(q["id"], n_etag, n_lm, neu > 0)
except Exception as e: # eine Quelle darf nicht except Exception as e: # eine Quelle darf nicht
@@ -407,13 +555,16 @@ def durchlauf(quellen, senke, slot):
status = getattr(e, "code", None) status = getattr(e, "code", None)
dauer = int((time.monotonic() - t0) * 1000) dauer = int((time.monotonic() - t0) * 1000)
senke.notiere(q["id"], slot, dauer, status, gesehen, neu, geaendert, senke.notiere(q["id"], slot, dauer, status, gesehen, neu, geaendert,
fehler) uebersprungen, bytes_roh, fehler)
ges_neu += neu ges_neu += neu
ges_geaendert += geaendert ges_geaendert += geaendert
ges_gesehen += gesehen ges_gesehen += gesehen
marke = "!" if fehler else (" " if status != 304 else ".") marke = "!" if fehler else (" " if status != 304 else ".")
print(f" {marke} {q['id']:14} {str(status):>4} " 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) + (f" {fehler}" if fehler else ""), flush=True)
return ges_gesehen, ges_neu, ges_geaendert return ges_gesehen, ges_neu, ges_geaendert
+38
View File
@@ -50,6 +50,12 @@ create table if not exists raw_items (
abgerufen_am timestamptz not null default now(), abgerufen_am timestamptz not null default now(),
slot timestamptz not null, -- auf den Poll-Takt abgerundet slot timestamptz not null, -- auf den Poll-Takt abgerundet
sprache text not null default 'de', sprache text not null default 'de',
position integer, -- Rang beim ersten Sehen
volltext text, -- content:encoded, falls zusaetzlich da
kategorien text[], -- <category> / dc:subject / Atom-term
medien jsonb, -- enclosure und Media RSS
links text[], -- Verweise aus dem Teaser-HTML
aktualisiert timestamptz, -- Atom <updated>, neben pubdate
inhalt_hash text not null, -- md5 ueber titel|teaser|url inhalt_hash text not null, -- md5 ueber titel|teaser|url
revisionen integer not null default 0, revisionen integer not null default 0,
zuletzt_geaendert timestamptz, zuletzt_geaendert timestamptz,
@@ -58,6 +64,10 @@ create table if not exists raw_items (
unique (quelle, item_key) unique (quelle, item_key)
); );
comment on column raw_items.kategorien is
'Vom Verlag selbst vergebene Themenlabel - Vorfilter und Prompt-Kontext';
comment on column raw_items.position is
'Rang im Feed beim ersten Sehen. Spaeter nicht fortgeschrieben';
comment on column raw_items.revisionen is comment on column raw_items.revisionen is
'Wie oft die Quelle das Item nach der Erstveroeffentlichung geaendert hat'; 'Wie oft die Quelle das Item nach der Erstveroeffentlichung geaendert hat';
@@ -108,6 +118,32 @@ join raw_item_versionen v on v.raw_item_id = r.id
where r.revisionen > 0 where r.revisionen > 0
group by r.id, r.quelle, r.url, r.revisionen; group by r.id, r.quelle, r.url, r.revisionen;
-- --------------------------------------------------------------------------
-- Rohe Feed-Antworten. Der Parser wird sich aendern; nur mit den Rohbytes
-- laesst sich eine spaetere Verbesserung rueckwirkend auf die Historie
-- anwenden. Dieselbe Ueberlegung wie bei der Trennung raw_items / codings.
--
-- Dedupliziert ueber (quelle, koerper_hash): ein unveraenderter Feed wird
-- nicht zweimal abgelegt, und ein 304 hat ohnehin keinen Koerper.
-- --------------------------------------------------------------------------
create table if not exists feed_abrufe (
id bigserial primary key,
quelle text not null references quellen(id),
slot timestamptz not null,
abgerufen_am timestamptz not null default now(),
http_status integer,
header jsonb, -- Date, Age, Cache-Control, ...
koerper_hash text not null, -- sha256 ueber die Rohbytes
koerper bytea not null, -- gzip
bytes_roh integer,
bytes_gz integer,
unique (quelle, koerper_hash)
);
create index if not exists feed_abrufe_quelle_idx
on feed_abrufe (quelle, abgerufen_am desc);
-- -------------------------------------------------------------------------- -- --------------------------------------------------------------------------
-- Kodierungen. Eine Zeile pro (Item, Kodierer-Version). Die Nutzlast bleibt -- Kodierungen. Eine Zeile pro (Item, Kodierer-Version). Die Nutzlast bleibt
-- absichtlich jsonb, solange nicht gemessen ist, bis zu welcher CAMEO-Ebene -- absichtlich jsonb, solange nicht gemessen ist, bis zu welcher CAMEO-Ebene
@@ -154,6 +190,8 @@ create table if not exists poll_laeufe (
items_gesehen integer not null default 0, items_gesehen integer not null default 0,
items_neu integer not null default 0, items_neu integer not null default 0,
items_geaendert integer not null default 0, items_geaendert integer not null default 0,
items_uebersprungen integer not null default 0, -- ohne Titel, unbrauchbar
bytes_roh integer,
fehler text fehler text
); );