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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user