# 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) ```sh # 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: ```sh python3 poller.py --sink jsonl:/pfad/zu/deinem/ablageordner ``` Legt `/YYYYMMDD/YYYYMMDDHHMMSS.jsonl.gz` an, dedupliziert ueber eine SQLite daneben, und die rohen Feeds unter `/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 ```sh # 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 ```sql -- 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 ```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 ```sql -- 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 ```sql 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 (``, `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.