Files
adminandClaude Opus 5 5a9bbe6e92 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
2026-09-05 18:21:06 +02:00

8.4 KiB

Wurzelwerk — Ingest

Stufe 0 und 1 der Pipeline: Quellenregister und Roh-Erfassung. Keine Kodierung — was hier landet, soll sich beliebig oft neu kodieren lassen.

Dateien

Datei Zweck
sources.toml Quellenregister, einzige Wahrheit ueber die Quellen
schema.sql Postgres-Schema, vollstaendiger Stand fuer Neuinstallationen
migrations/ Aenderungen fuer bestehende Installationen, aufsteigend anwenden
poller.py Poller, zwei Senken: Postgres und gzip-JSONL
Containerfile Image fuer den Produktivbetrieb
wurzelwerk-ingest.container, wurzelwerk.network Quadlet-Units, rootless
ingest.env.example Vorlage fuer die Zugangsdaten

Deploy mit Postgres + Podman/Quadlet (rootless)

# 1. Schema einspielen (Neuinstallation)
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql

# 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/002-rohablage-und-felder.sql

# 2. Image bauen
podman build -t localhost/wurzelwerk-ingest:latest .

# 3. Konfiguration ablegen
mkdir -p ~/.config/wurzelwerk
cp ingest.env.example ~/.config/wurzelwerk/ingest.env
cp sources.toml       ~/.config/wurzelwerk/sources.toml
chmod 600 ~/.config/wurzelwerk/ingest.env
$EDITOR ~/.config/wurzelwerk/ingest.env

# 4. Quadlet installieren
mkdir -p ~/.config/containers/systemd
cp wurzelwerk-ingest.container wurzelwerk.network ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start wurzelwerk-ingest
journalctl --user -u wurzelwerk-ingest -f

# Damit der Dienst ohne offene Sitzung weiterlaeuft:
loginctl enable-linger "$USER"

sources.toml ist eingehaengt, nicht ins Image gebacken: eine neue Quelle ist ein systemctl --user restart wurzelwerk-ingest, kein Neubau.

Schatten-Ingest auf einem Zweitgeraet

Ohne Postgres, ohne Container:

python3 poller.py --sink jsonl:/pfad/zu/deinem/ablageordner

Legt <dir>/YYYYMMDD/YYYYMMDDHHMMSS.jsonl.gz an, dedupliziert ueber eine 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 Archiv. Steht der Produktiv-Poller einen Tag still, ist dieser Tag unwiederbringlich weg. Kodierungen lassen sich wiederholen, Rohdaten nie.

Betrieb

# Lizenzstatus im Register gegen die aktuelle robots.txt pruefen
python3 poller.py --check-robots        # Exit != 0 bei Abweichung

# ein einzelner Durchlauf
python3 poller.py --sink "$DATABASE_URL" --once

--check-robots gehoert in einen woechentlichen Cron. Ein Verlag, der seine robots.txt aendert, aendert damit seinen Nutzungsvorbehalt — und das ist der Punkt, an dem eine Quelle stillgelegt werden muss.

Aendert sich etwas, wird sources.toml angepasst, nie der Code. Eine gesperrte Quelle bleibt im Register stehen (aktiv = false), damit nachvollziehbar bleibt, warum sie fehlt. Der Poller verweigert den Start, wenn eine als gesperrt gefuehrte Quelle aktiv geschaltet ist.

Ueberschriften-Revisionen

-- welche Meldungen wurden nachtraeglich umbenannt
select quelle, fassungen, erste_fassung, letzte_fassung, zuletzt
from titel_revisionen order by zuletzt desc limit 20;

-- welche Redaktion revidiert am haeufigsten
select quelle, count(*) filter (where revisionen > 0) as revidiert,
       count(*) as gesamt,
       round(100.0 * count(*) filter (where revisionen > 0) / count(*), 1) as quote
from raw_items group by quelle order by quote desc;

Was die Verlage selbst labeln

-- 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

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

-- aktive Quellen, die seit ueber sechs Stunden nichts Neues liefern
select * from quellen_stillstand;

-- Fehler der letzten Stunde
select quelle, http_status, fehler, begonnen_am from poll_laeufe
where fehler is not null and begonnen_am > now() - interval '1 hour';

Arbeitsvorrat fuer Yantra

select * from unkodiert('qwen3-14b-q4/prompt-1') limit 500;

Liefert alle Items, die diese Kodierer-Version noch nicht gesehen hat. Eine neue Prompt-Version ist damit automatisch ein vollstaendiger Recode-Auftrag ueber die gesamte Historie, ohne dass ein Feed erneut abgerufen wird.

Verhalten

  • 15-Minuten-Takt, an den Slotgrenzen ausgerichtet (+30 s Versatz). Der Takt ist nicht frei waehlbar: ein RSS-Feed ist ein Fenster, kein Archiv. Ist das Fenster kuerzer als der Poll-Abstand, fallen Artikel zwischen zwei Abrufen heraus und sind unwiederbringlich weg. Gemessen am 2026-09-04: zeit.de haelt 0,6 h vor, dlf gar nicht messbar, spiegel und welt rund 5 h -- bei 2 h Takt sah man von der Zeit etwa jeden dritten Artikel. Der bedingte GET macht haeufiges Pollen billig: ein unveraenderter Abruf ist ein 304 ohne Body.
  • Revisionen werden mitgeschrieben. Redaktionen aendern Ueberschriften nach der Veroeffentlichung; frueher lief das in ein on conflict do nothing und blieb unsichtbar. raw_items haelt den aktuellen Stand, raw_item_versionen jede Fassung. Der Datensatz existiert sonst nirgends -- GDELT sieht jede URL genau einmal.
  • Bedingter GET ueber ETag/Last-Modified, im Register gespeichert. Im Test antworteten 4 von 12 Quellen im zweiten Durchlauf mit 304.
  • Dedup ueber unique (quelle, item_key); item_key ist guid, ersatzweise Link, ersatzweise ein Titel-Hash.
  • Fehlertoleranz: eine kaputte Quelle beendet den Durchlauf nicht, der 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 reichen als Kodierungsinput, und damit stellt sich die TDM-Frage nach § 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)

Erster Lauf ueber 12 aktive Quellen: 820 Items gesehen, 810 neu, gesamter Durchlauf unter 3 s. Zweiter Lauf unmittelbar danach: 1 neues Item.

Das Trigramm-Clustering findet quellenuebergreifende Dubletten sofort:

zeit    | handelsblatt | 1.00 | Bekennerschreiben: Erneut Sabotage am Stromnetz
spiegel | derstandard  | 0.92 | Niederlage fuer Donald Trump - Gericht in Missouri

Offen

  • Telegram-Fetcher (Telethon/MTProto). Register ist vorbereitet, Eintraege stehen auf aktiv = false. Die Bot-API kann fremde Kanaele nicht lesen.
  • Ersatz-URL fuer den BR24-Feed (aktuell 404).
  • Quellenpool von 12 auf ~40 erweitern; Regionalzeitungen ueber SearXNG suchen.