16 Quellen, gut 100.000 Wörter Rohmaterial je Woche, 2.800 Wörter Ergebnis. Schritt für Schritt: 32 Knoten, jede Klickfolge, jeder Code-Knoten vollständig zum Kopieren.
Es gibt einen Moment, den jeder kennt, der versucht, ein Fachgebiet zu verfolgen. Man hat sich einen Nachrichtenstrom gebaut, ein paar Newsletter, Feeds, vielleicht einen automatisierten Pressespiegel, und am Freitag stellt man fest: Man hat dreihundert Meldungen gesehen und weiß trotzdem nicht, was diese Woche eigentlich passiert ist.
Das liegt nicht an der Menge. Es liegt daran, dass Meldungen im Überfluss vorhanden sind und Einordnung nicht. Meldungen bekommt man überall. Was fehlt, ist die Verbindung: dass mehrere Ereignisse derselben Woche zum selben Thema gehören. Dass jemand eine Entwicklung ganz anders bewertet als der Rest. Dass etwas, das am Montag klein aussah, bis Freitag groß geworden ist.
Und Einordnung liegt nun einmal an anderen Orten als Meldungen. Sie steckt in Podcasts, in langen YouTube-Videos und auf Webseiten wie The Batch, Artificial Analysis oder Epoch AI, überall dort, wo sich jemand eine Stunde Zeit genommen hat.
Der Haken ist immer derselbe: Genau dieses Material ist automatisch am schwersten zu greifen. Ein Podcast ist eine Tondatei. Ein Video ist eine Tondatei mit Bildern. Und die ergiebigsten Webseiten haben oft gar keinen Feed, man muss sich ihre Beiträge aus der Sitemap zusammensuchen und den Text von der Seite selbst holen.
Genau das ist der Grund, warum solche Quellen in kaum einem automatisierten Nachrichtenstrom auftauchen. Nicht, weil sie unwichtig wären, sondern weil sie unbequem sind.
Diese Anleitung beschreibt, wie man trotzdem drankommt. Am Ende steht ein System, das täglich sammelt, Tonspuren abtippt, jeden Fund auf ein paar Sätze verdichtet, und sonntags um sechs Uhr morgens eine wöchentliche Zusammenfassung von rund 2.800 Wörtern schreibt, die die Woche ordnet statt sie aufzulisten.
Was das kostet: null Euro für die Spracherkennung, ein paar Cent im Monat fürs Verdichten.
Um diese Anleitung verständlicher zu machen, begleiten dich drei Personen:
Die typischen Büro-Charaktere: Die kompetente IT-Kollegin, der selbsternannte Experte und der ehrliche Anfänger. Diese drei Perspektiven helfen dir, typische Stolperfallen zu erkennen und n8n wirklich zu verstehen.
Tanja ist die IT-Expertin. Sie weiß, wie n8n funktioniert, erklärt geduldig und strukturiert – und lässt sich von schlechten Ratschlägen nicht aus der Ruhe bringen. Wenn du eine Frage hast, hat Tanja die Antwort.
Bernd ist der selbsternannte „Experte“, der alles besser weiß – und meistens falsch liegt. Seine Abkürzungen und sein Halbwissen führen regelmäßig zu Problemen. Er steht für alle gefährlichen Mythen und schlechten Praktiken, die du vermeiden solltest.
Ulf ist der Lernende, genau wie du. Er stellt die Fragen, die dir im Kopf herumschwirren, und braucht manchmal einen Vergleich aus dem Alltag, um IT zu verstehen. Wenn Ulf etwas nicht versteht, ist das völlig in Ordnung – dafür ist Tanja da.
„Und… Action!“
Freitagnachmittag, 16:20 Uhr. Ulf klappt den Laptop halb zu und schiebt ihn von sich weg.
Ulf: „Ich hab diese Woche bestimmt dreihundert Meldungen gelesen. Und wenn du mich jetzt fragst, was diese Woche passiert ist, sage ich dir: keine Ahnung.“
Tanja: „Weil du Meldungen gelesen hast. Meldungen sind der Rohstoff. Was dir fehlt, ist jemand, der sie am Ende der Woche nebeneinanderlegt.“
Bernd: „Ich hab dafür einen Ordner mit Lesezeichen. Sonntags gehe ich den durch.“
Ulf: „Und, gehst du ihn durch?“
Bernd: „Sonntags ist bei mir immer viel los.“
Tanja: „Genau deshalb bauen wir das jetzt so, dass es sonntags um sechs von selbst passiert. Und zwar aus den Quellen, die sich sonst keiner automatisch holt.“
Woher das Material kommt: drei Wege, vier Arten von Quellen
Sechzehn Quellen im Beispielaufbau, und keine davon ist ein Nachrichtenticker. Sie zerfallen in vier Gruppen, und die technisch anspruchsvollste ist zugleich die inhaltlich ergiebigste.
Webseiten, die wir selbst auslesen
Das ist der Teil, den es sonst kaum irgendwo gibt. Diese Seiten veröffentlichen Analysen, Benchmarks und Studien, aber sie haben keinen brauchbaren Feed. Wer sie automatisch lesen will, muss sich ihre Beiträge aus der Sitemap (dem maschinenlesbaren Inhaltsverzeichnis unter /sitemap.xml) zusammensuchen und den Text anschließend von der Seite selbst holen. Genau das baut Phase 5.
| Seite | Was dort steht | Weg | Frequenz |
|---|---|---|---|
| The Batch(deeplearning.ai) | wöchentliche Einordnung von Andrew Ngs Team | sitemap.xml mit lastmod | 26 Beiträge in 30 Tagen |
| Artificial Analysis | Modell-Benchmarks und Vergleichsanalysen | sitemap.xml, Beiträge unter /articles/ | mehrmals wöchentlich |
| Epoch AI | Forschungsnotizen und Datenauswertungen | Sitemap ohne lastmod, Datum aus der Übersichtsseite | unregelmäßig |
| AppliedAI Institute | Studien und Positionspapiere auf Deutsch | sitemap.xml, 176 Adressen | 17 in 30 Tagen |
| KI-Buzzer | deutschsprachige Blogbeiträge | sitemap.xml | 3 in 30 Tagen |
| KI-Beratung | Praxisbeiträge auf Deutsch | Feed vorhanden, aber nur 249 Zeichen Anriss → Volltext von der Seite | 28 in 30 Tagen |
Ulf: „Sitemap. Ist das so eine Landkarte?“
Tanja: „Fast. Es ist das Inhaltsverzeichnis, das eine Webseite für Suchmaschinen bereitlegt. Alle Adressen, oft mit dem Datum der letzten Änderung. Wenn ich es lesen kann, muss ich mich nicht durch die Seite klicken, ich weiß sofort, was neu ist.“
Der letzte Eintrag der Tabelle ist der Sonderfall, der die Gruppe gut beschreibt: KI-Beratung hat einen Feed. Nur liefert der einen Anriss von 249 Zeichen, und aus 249 Zeichen lässt sich nichts einordnen. Der Strang bedient deshalb beide Fälle: Seiten ganz ohne Feed und Feeds ohne Text.
Und eine Warnung vorweg, weil sie beim Bauen zweimal zugeschlagen hat: Manche Server antworten auf jedenunbekannten Pfad mit HTTP 200 und ihrer Startseite. Bei einer geprüften Domain lieferten sieben verschiedene Feed-Adressen alle „200“ zurück, jedes Mal byte-gleiches HTML. Ein Statuscode allein beweist keinen Feed. Der Inhalt muss angesehen werden.
Bernd: „Wenn 200 zurückkommt, ist die Sache doch in Ordnung. 200 heißt okay. Steht überall.“
Tanja: „200 heißt: Der Server hat dir etwas geschickt. Was er dir geschickt hat, steht da nicht. Bei einer Domain haben wir sieben verschiedene Feed-Adressen ausprobiert. Alle sieben antworteten mit 200 und schickten jedes Mal dieselbe Startseite.“
Ulf: „Also wie ein Briefkasten, der immer denselben Werbeprospekt ausspuckt, egal was man reinwirft.“
Tanja: „Genau so. Deshalb prüfen wir später nicht den Statuscode, sondern ob im Text tatsächlich <item> oder <entry> vorkommt.“
Podcasts
Drei Sendungen im Beispielaufbau: Der KI-Podcast (ARD), KI-Update (heise) und KI verstehen (Deutschlandfunk). Ihre Feeds enthalten die Adresse der Tondatei, den Rest muss eine Spracherkennung erledigen. Eine 49-Minuten-Folge ergibt rund 9.000 Wörter. Zusammen sind das gut fünf Stunden Ton pro Woche.
YouTube-Kanäle
Sechs Kanäle. Jeder hat einen Kanal-Feed, der Titel und Beschreibung liefert. Das eigentliche Material sind die automatischen Untertitel, an die wir mit einem eigenen kleinen Dienst herankommen. Ein 30-Minuten-Video ergibt rund 5.000 Wörter. Vier Videos aus vier Kanälen brachten im Test 29.323 Wörter.
Blogs mit Volltext-Feed
Die einfachste Gruppe: Manche Blogs liefern im Feed den kompletten Text mit. Dann ist die Arbeit schon getan, es muss nur eingesammelt werden.
Was dabei zusammenkommt, ist der eigentliche Punkt: rund fünf Stunden Ton, ein Dutzend Videos und die Beiträge von sechs Webseiten. Und das jede Woche.
Rechne die Menge einmal mit den Zahlen aus dieser Anleitung nach, weil die Größenordnung überrascht. Eine 49-Minuten-Folge ergab 9.078 Wörter, also rund 185 Wörter je Tonminute. Die gemessenen 302,5 Tonminuten je Woche sind damit etwa 56.000 Wörter allein aus den Podcasts. Ein Dutzend Videos zu im Schnitt 3.400 Wörtern sind weitere rund 41.000. Die Webseiten kommen obendrauf. Es sind also gut 100.000 Wörter Rohmaterial je Woche, aus denen am Sonntag ein Text von 2.800 Wörtern wird, ein Verhältnis von rund 1 zu 35.
Voraussetzungen
Die Basis: eine leere, funktionsfähige n8n-Installation
Diese Anleitung beginnt bei einem leeren n8n, das läuft und eine PostgreSQL-Datenbank neben sich hat. Du brauchst also nicht schon irgendetwas gebaut zu haben, aber n8n und Postgres müssen stehen.
n8n ist ein Baukasten für Abläufe: Man zieht Kästchen auf eine Fläche, verbindet sie mit Pfeilen, und jedes Kästchen tut eine Sache: eine Webseite abrufen, in eine Datenbank schreiben, eine E-Mail verschicken. Ein solcher Ablauf heißt Workflow. „Self-hosted“ bedeutet: n8n läuft auf deiner eigenen Hardware, nicht bei einem Anbieter.
Wer das noch nicht hat, findet die vollständige Einrichtung auf der Synology DiskStation in diesem Artikel. Er behandelt n8n, PostgreSQL und Docker, also die Technik, die ein Programm samt allem, was es braucht, in ein abgeschottetes Päckchen namens Container packt:
Mein digitaler KI Newsroom auf der Synology NAS, dort steht in Abschnitt 3.1 die komplette Datenbank-Einrichtung, und er verweist seinerseits auf eine reine Installationsanleitung für die DiskStation DS1621+ mit PostgreSQL.
Das Verhältnis der beiden Texte in einem Satz: Der Newsroom-Artikel baut eine Nachrichtenredaktion, die täglich Meldungen verarbeitet. Diese Anleitung baut etwas anderes, ein System, das Einordnung sammelt. Beide teilen sich nur das Fundament. Du kannst diese hier bauen, ohne den anderen gelesen zu haben.
Die Hardware
Ein Server, der immer läuft. Im beschriebenen Aufbau ist das eine Synology DiskStation (ein Netzwerkspeicher, umgangssprachlich NAS: eine kleine Kiste mit Festplatten, die im Heimnetz steht und rund um die Uhr an ist). Es geht genauso mit einem Mini-PC oder einem Raspberry Pi der neueren Generation, mit einer entscheidenden Einschränkung, gleich unten.
Auf Synology heißt die Docker-Verwaltungsoberfläche Container Manager. Wir brauchen sie einmal, um einen zusätzlichen Container anzulegen.
Die Zugänge
Fünf Stück, und nur zwei davon kosten überhaupt etwas:
| Zugang | Typ in n8n | Wofür | Kosten |
|---|---|---|---|
| Postgres | postgres | alle Datenbankknoten | kostenlos |
| Groq | groqApi | Podcastfolgen abtippen | 0 € im kostenlosen Tarif |
| OpenAI | openAiApi | Verdichten mit gpt-4o-mini | Bruchteile eines Cent je Fund |
| Anthropic | anthropicApi | Blöcke planen und schreiben | ein Lauf je Woche |
| SMTP | smtp | der Mailversand | kostenlos |
Ein API-Schlüssel (API: die Schnittstelle, über die zwei Programme miteinander reden; der Schlüssel ist die Zugangskennung dafür) ist bei allen dreien eine Zeichenkette, die man im Konto des Anbieters erzeugt und in n8n hinterlegt.
Trage Schlüssel und Passwörter immer selbst ein. Kein Anlass rechtfertigt, sie jemand anderem zu diktieren, auch keinem hilfsbereiten Chatbot.
Bernd: „Ich schick meine Schlüssel immer schnell rüber, wenn jemand beim Einrichten hilft. Geht schneller.“
Tanja: „Ein API-Schlüssel ist deine Kreditkarte. Wer ihn hat, kann auf deine Rechnung arbeiten, solange, bis du es merkst. Du trägst ihn selbst ein. Immer.“
Und die eine Einschränkung, die alles entscheidet
Die Anlage sollte über einen Privatanschluss ins Internet gehen, nicht über ein Rechenzentrum.
Das klingt nach einer Nebensächlichkeit und ist die wichtigste Zeile dieser Anleitung. YouTube schränkt Anfragen aus Rechenzentren stark ein. Dieselbe Software, dasselbe Video, aber von einem gemieteten Server aus kommt statt eines Transkripts die Meldung „Sign in to confirm you’re not a bot“. Vom heimischen DSL- oder Kabelanschluss aus kommt das Transkript.
Das ist nicht vermutet, sondern gemessen: Dasselbe Video, das in einer Cloud-Umgebung abgewiesen wurde, lieferte von der DiskStation aus 2.160 Wörter. Der Privatanschluss war im Test also deutlich zuverlässiger.
Chancenlos ist ein gemieteter Server damit nicht. Er braucht aber zusätzliche Maßnahmen, die diese Anleitung nicht behandelt, etwa PO-Token, Cookies oder eine andere Client-Einstellung in yt-dlp. Und weil YouTube daran laufend etwas ändert, ist das nichts, was man einmal einrichtet und dann vergisst. Podcasts, Blogs und Webseiten laufen auf einem gemieteten Server unverändert.
Bernd: „Ich nehm für so was immer einen VPS. Fünf Euro im Monat, läuft immer, viel schneller als so eine NAS.“
Tanja: „Dann bekommst du bei jedem YouTube-Video ‚Sign in to confirm you’re not a bot‘. Das haben wir gemessen: dasselbe Video, dieselbe Software. Aus dem Rechenzentrum abgewiesen, von der DiskStation aus 2.160 Wörter.“
Ulf: „Und daran kann man nichts drehen?“
Tanja: „Nicht mit den Mitteln aus dieser Anleitung. Es gibt Wege, PO-Token, Cookies, andere Client-Einstellungen in yt-dlp. Nur ändert YouTube daran laufend etwas, und dann sitzt du jede zweite Woche daran. Deshalb steht das hier ganz oben und nicht im Anhang.“
Ulf: „Und der Rest?“
Tanja: „Podcasts, Blogs und Webseiten laufen auf einem gemieteten Server unverändert. Es geht wirklich nur um YouTube.“
Der Bauplan in der Vogelperspektive
Zwei Workflows, verschiedene Takte.
A20 Wochenquellen sammeln läuft täglich um 04:00. Vier Stränge hängen am selben Zeitplan-Knoten:
Täglich 04:00 ─┬→ Quellen einsammeln → In ki_artikel schreiben ─┬→ YouTube-Transkripte
│ ├→ Podcast-Transkripte
│ └→ Verdichten
└→ Webseiten einsammeln → Seitentexte nachladen
A21 Wochenzusammenfassung schreiben läuft sonntags um 06:00, eine gerade Kette: Material holen → aufbereiten → Blöcke planen → jeden Block einzeln schreiben → Rahmen setzen → zusammenbauen → verschicken.
Ulf: „Warum zwei Workflows? Sonntags einmal alles machen wäre doch einfacher.“
Tanja: „Rechne mit: fünf Podcastfolgen und acht Videos in einem einzigen Sonntagslauf sind zwanzig Minuten Laufzeit. Und wenn in Minute achtzehn etwas schiefgeht, ist alles davor auch weg.“
Ulf: „Und täglich?“
Tanja: „Täglich sind es ein bis drei Stücke. Und wenn eine Quelle am Sonntag gerade nicht erreichbar ist, fehlt sie in der Zusammenfassung. Täglich gesammelt liegt sie längst in der Datenbank.“
Bernd: „Ich würd’s trotzdem in einen Workflow packen. Weniger Klickerei.“
Tanja: „Weniger Klickerei beim Bauen, mehr Ärger im Betrieb. Das ist fast immer ein schlechter Tausch.“
Die Reihenfolge beim Bauen ist nicht beliebig: A20 zuerst und vollständig, A21 danach. A21 verarbeitet ausschließlich Zeilen, für die bereits eine brauchbare zusammenfassung vorliegt. Gibt es die noch nicht, schreibt es eine Zusammenfassung über nichts.
Phase 0: Alles kommt in eine Tabelle
Die erste Entscheidung wird getroffen, bevor ein einziger Knoten existiert.
Bernd: „Vier Quellenarten, vier Tabellen. Sauber getrennt. So macht man das.“
Tanja: „Und wenn du am Sonntag wissen willst, was diese Woche insgesamt reingekommen ist, fragst du dann viermal und klebst die Ergebnisse zusammen?“
Bernd: „…“
Tanja: „Eine Tabelle. Eine Spalte, die sagt, woher die Zeile stammt. Fertig.“
Vier verschiedene Quellenarten, nämlich Videos, Podcasts, Blogs und Webseiten, verführen dazu, vier Tabellen anzulegen. Tu es nicht. Der Grund ist einfach: Du willst später eine Suche über den Gesamtbestand machen können, nicht vier Abfragen zusammenfügen. Und die Sache, die alle vier gemeinsam haben, es ist Text, er hat eine Adresse, ein Datum und eine Quelle, ist größer als das, was sie unterscheidet.
Also: eine Tabelle, eine Spalte source_type zur Unterscheidung. Das Transkript eines Videos landet in derselben Spalte content wie der Text einer Webseite.
Schritt 0.1: Die Verbindung zur Datenbank herstellen
Falls du in n8n noch keinen Postgres-Zugang eingerichtet hast:
Klickfolge: In n8n einloggen → Create new workflow → Add first step → „+“ → nach Postgres suchen → Execute a SQL query wählen → bei Credential to connect with auf Create a new Credential.
Die Werte findest du im Synology Container Manager unter Container → dein Postgres-Container → Details:
| Feld | Wert |
|---|---|
| Host | IP-Adresse der NAS, oder der Containername, wenn beide im selben Docker-Netz liegen |
| Database | meist postgres oder n8n_db, was bei der Installation als POSTGRES_DB gesetzt wurde |
| User | der vergebene Name, oft postgres |
| Password | dein festgelegtes Passwort |
Erwartetes Ergebnis: Nach dem Speichern erscheint oben ein grünes Feld mit „Connection tested successfully“.
Ulf: „Und wenn da kein grünes Feld kommt?“
Tanja: „Dann stimmt einer der vier Werte nicht, und in neun von zehn Fällen ist es der Host. Liegen n8n und Postgres im selben Docker-Netz, gehört dort der Containername hinein, nicht die IP-Adresse der NAS.“
Schritt 0.2: Die Tabelle anlegen
Schließe das Credential-Fenster, du bist wieder im Postgres-Knoten. Lösche den Inhalt des Feldes Query und füge ein:
CREATE TABLE ki_artikel (
id SERIAL PRIMARY KEY,
url VARCHAR(500) UNIQUE NOT NULL,
url_normalized TEXT UNIQUE, -- ohne UNIQUE wirkt "Skip on Conflict" nicht
title TEXT NOT NULL,
description TEXT,
content TEXT, -- Transkript oder Seitentext
source VARCHAR(200) NOT NULL,
source_type TEXT, -- 'youtube' | 'podcast' | 'blog'
status VARCHAR(50),
published_date TIMESTAMP,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
subkategorie VARCHAR(80) DEFAULT NULL,
zusammenfassung TEXT, -- die verdichteten 3-5 Sätze
dauer_sekunden INTEGER -- Länge von Podcastfolge oder Video
);
CREATE INDEX idx_ki_artikel_status ON ki_artikel(status, published_date DESC);
Klicke auf Execute step.
Erwartetes Ergebnis: Der Knoten läuft grün durch. SELECT count(*) FROM ki_artikel antwortet mit 0, ohne zu meckern.
Wenn du den KI-Newsroom bereits gebaut hast, existiert
ki_artikelschon. Dann legst du keine neue Tabelle an, sondern ergänzt die drei fehlenden Spalten:ALTER TABLE ki_artikel ADD COLUMN IF NOT EXISTS zusammenfassung TEXT; ALTER TABLE ki_artikel ADD COLUMN IF NOT EXISTS dauer_sekunden INTEGER; ALTER TABLE ki_artikel ADD COLUMN IF NOT EXISTS url_normalized TEXT UNIQUE;Und eine Falle darin:
ADD COLUMN IF NOT EXISTSlegt den eindeutigen Zwang nur dann mit an, wenn die Spalte wirklich neu entsteht. Gibt esurl_normalizedschon, aber ohneUNIQUE, passiert gar nichts, und zwar geräuschlos. Sieh nach, und wenn der Zwang fehlt, hol ihn nach:ALTER TABLE ki_artikel ADD CONSTRAINT ki_artikel_url_normalized_key UNIQUE (url_normalized);
Warum das UNIQUE keine Feinheit ist
Alle Insert-Knoten laufen später mit der Einstellung Skip on Conflict, also „wenn es das schon gibt, überspring es“. Diese Einstellung braucht einen eindeutigen Zwang in der Datenbank, sonst greift sie nicht.
Dabei arbeiten zwei Zwänge nebeneinander, und sie tun Verschiedenes. Der auf url fängt die buchstabengleicheAdresse ab, also denselben Beitrag, der zweimal im selben Feed steht. Der auf url_normalized fängt die Varianten derselben Adresse ab, und das ist der Fall, der in der Praxis auftritt: einmal mit www., einmal ohne, einmal mit ?utm_source=newsletter angehängt.
Für dich ist das ein Beitrag. Für eine Datenbank ohne diesen zweiten Zwang sind es drei. Sie wächst dann still und stetig um solche Varianten, ohne dass irgendwo etwas rot wird, und du merkst es erst, wenn die Zusammenfassung dieselbe Podcastfolge dreimal erwähnt.
Bernd: „Doppelte kann man doch hinterher rauswerfen. Ein Klick.“
Tanja: „Kannst du. Nur wirst du sie nicht als Doppelte erkennen. Es ist nicht zweimal dieselbe Adresse, es ist einmal mit www., einmal ohne und einmal mit ?utm_source=newsletter hinten dran.“
Ulf: „Und der Zwang auf url fängt das nicht ab?“
Tanja: „Der fängt nur die buchstabengleiche Adresse ab. Für die Varianten brauchst du den zweiten Zwang, auf der aufgeräumten Adresse. Ein Wort beim Anlegen, und das Thema ist für immer erledigt.“
Die Statusfolge
Jede Zeile durchläuft eine feste Kette. Sie zu kennen erspart später viel Rätselraten:
GESAMMELT ──┬─ (YouTube, Podcast) ─→ TRANSKRIBIERT ─→ VERDICHTET
│ └→ OHNE_TRANSKRIPT (Sackgasse)
└─ (Blog, Webseite) ─→ VERDICHTET
└→ OHNE_INHALT (Text unbrauchbar)
A21 wählt sein Material aber nicht über diesen Status aus, sondern über die Spalte zusammenfassung. Es nimmt jede Zeile, für die eine brauchbare Verdichtung vorliegt. Das ist robuster: Es holt genau das, was tatsächlich verdichtet ist, unabhängig davon, welchen Weg die Zeile durch die Kette genommen hat.
Der Status bleibt trotzdem nützlich. An ihm sieht man beim Blick in die Datenbank, wo etwas hängt. Was auf OHNE_TRANSKRIPT steht, wird nie wieder angefasst; das ist Absicht und wird in Phase 4 noch wichtig.
Ulf: „Wenn der Status nicht entscheidet, wozu ist er dann da?“
Tanja: „Zum Hinsehen. Der Status ist die Anzeigetafel. Wenn sich morgens zwanzig Zeilen auf OHNE_TRANSKRIPTstapeln, weißt du in einer Sekunde, welche Quelle kaputt ist. Ausgewählt wird trotzdem über die Spalte, in der der fertige Text steht. Anzeigen und Auswählen sind zwei verschiedene Aufgaben.“
Phase 1: Der Dienst, ohne den nichts geht
Hier entsteht das Herzstück: ein kleiner eigener Dienst auf der NAS, der zwei Dinge kann. Er holt YouTube-Untertitel, und er rechnet Podcast-Audiodateien klein.
Ulf: „Ein eigener Dienst? Kann n8n das nicht selbst?“
Tanja: „Nein, und der Grund dafür ist lehrreich. Beide Wege sind ohne Hilfsmittel versperrt.“
Warum ein eigener Dienst? Weil beide Wege sonst versperrt sind.
Die offizielle YouTube-Schnittstelle hilft nicht. Der Abruf von Untertiteln verlangt dort die ausdrückliche Freigabe des Video-Eigentümers. Der naheliegende Umweg über die Videoseite scheitert ebenfalls: YouTube liefert dort eine Untertiteladresse mit dem Parameter exp=xpe aus, und das ist die Markierung „PO-Token erforderlich“. Ein PO-Token (Proof of Origin) ist kein Cookie, sondern wird von der JavaScript-Maschinerie des YouTube-Players zur Laufzeit erzeugt. Eine reine Serveranfrage kann ihn nicht haben. Das Ergebnis ist besonders tückisch: kein Fehler, sondern HTTP 200 mit null Bytes Inhalt. Alles sieht in Ordnung aus, es kommt nur nichts an.
Bernd: „Ich hab mir so was mal selbst gebaut. Untertiteladresse aus der Seite ziehen, abrufen, fertig. Lief eine Woche.“
Tanja: „Und danach kam nichts mehr an, aber es stand auch kein Fehler da.“
Bernd: „Woher weißt du das?“
Tanja: „Weil das der Normalfall ist. Du bekommst HTTP 200 und null Bytes. Der Abruf gilt als erfolgreich, die Datei ist leer. Das ist die unangenehmste Sorte Fehler, weil sie sich als Erfolg tarnt.“
Was funktioniert, ist yt-dlp, ein weit verbreitetes Werkzeug, das YouTube-Inhalte abruft. Es umgeht die Sache, indem es sich gegenüber YouTube als anderer Client ausgibt; im Protokoll steht die verräterische Zeile „Downloading android vr player API JSON“. Dieser Client verlangt für Untertitel kein Token.
Schritt 1.1: Den Netznamen herausfinden
Das ist der Schritt, den man nicht überspringen darf. Der neue Container muss in dasselbe Docker-Netz wie n8n, sonst finden sich die beiden nicht.
Der naheliegende Name ist falsch. Bei einem Projekt namens n8n wäre der Docker-Standardname n8n_default. Tatsächlich heißt das Netz auf einer Synology in aller Regel anders.
Bernd: „Das Projekt heißt n8n, also heißt das Netz n8n_default. Muss man nicht nachsehen, das ist der Standard.“
Tanja: „Sieh trotzdem nach. Es dauert zehn Sekunden, und auf einer Synology heißt es fast nie so.“
Klickfolge: Container Manager öffnen → linke Leiste Netzwerk → den Eintrag aufklappen, in dessen Zeile unter Verbundene Container dein n8n-Container steht.
Erwartetes Ergebnis: Du siehst den echten Netznamen, das Subnetz und die verbundenen Container. Im Beispiel: n8n_n8n-network, Subnetz 172.21.0.0/16, darin n8n-app, n8n-postgres und metabase.
Schreib dir diesen Namen auf. Er kommt gleich vor.

n8n_n8n-network, nicht n8n_defaultSchritt 1.2: Die beiden Dateien anlegen
Klickfolge: File Station öffnen → in den Ordner docker wechseln → Erstellen → Neuer Ordner → Name yt-dlp.
Achte auf die Schreibweise. Der Ordnername muss später zeichengenau in der Compose-Datei stehen. Ein fehlender Bindestrich führt dazu, dass der Container zwar startet, aber sein Programm nicht findet und sich sofort wieder beendet, ein Fehlerbild, das nach „Docker kaputt“ aussieht und keines ist.
Ulf: „Ein fehlender Bindestrich. Und dann sieht es aus, als wäre Docker kaputt?“
Tanja: „Genau. Der Container startet, sucht sein Programm, findet es nicht und beendet sich. Du siehst nur, dass er sofort wieder aus ist. Die Ursache steht nirgends. Deshalb: Ordnername und volumes:-Zeile zeichenweise vergleichen, bevor du irgendetwas anderes suchst.“
Lege in diesem Ordner zwei Dateien ab.
Datei 1: compose.yaml, der Bauplan des Containers. Die Zeile mit name: ganz unten trägt den Netznamen aus Schritt 1.1; die Zeile unter volumes: trägt deinen Ordnernamen.
services:
ytdlp:
# Schlankes Python-Grundabbild. yt-dlp und ffmpeg werden beim Start geholt:
# yt-dlp, weil YouTube haeufig etwas aendert; ffmpeg, um Podcast-Audio unter
# die 25-MB-Grenze der Spracherkennung zu bringen (gemessen: bis 45 MB je Folge).
image: python:3.12-slim
container_name: ytdlp-transkript
restart: unless-stopped
working_dir: /app
volumes:
# ACHTUNG: hier muss DEIN Ordnername stehen.
- /volume2/docker/yt-dlp/app.py:/app/app.py:ro
environment:
PORT: "8080"
ZEITLIMIT_SEKUNDEN: "120"
AUDIO_ZEITLIMIT_SEKUNDEN: "600"
# Kein "ports:" - der Dienst ist absichtlich NUR im Docker-Netz
# erreichbar, nicht aus dem Heimnetz und nicht von aussen.
networks:
- ki
command: >
sh -c "apt-get update -qq &&
apt-get install -y --no-install-recommends -qq ffmpeg &&
rm -rf /var/lib/apt/lists/* &&
pip install --no-cache-dir --quiet --upgrade yt-dlp &&
exec python /app/app.py"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/gesund',timeout=10)"]
interval: 60s
timeout: 15s
retries: 3
# Erster Start dauert laenger, weil ffmpeg installiert wird.
start_period: 300s
security_opt:
- no-new-privileges:true
mem_limit: 1g
pids_limit: 256
networks:
ki:
# Hier gehoert DEIN Netzname aus Schritt 1.1 hinein.
external: true
name: n8n_n8n-network
Drei Dinge an dieser Datei verdienen einen Blick, weil sie bewusst so sind:
Kein ports:-Abschnitt. Der Dienst veröffentlicht keinen Anschluss nach außen. Er ist ausschließlich innerhalb des Docker-Netzes erreichbar, weder aus dem Heimnetz noch aus dem Internet. Nur n8n kommt heran.
Kein Docker-Socket. Man liest gelegentlich Anleitungen, die n8n Zugriff auf die Docker-Steuerung geben. Das käme Administratorrechten auf der ganzen NAS gleich. Hier passiert das nicht.
yt-dlp wird bei jedem Start frisch geholt. Das kostet ein bis zwei Minuten beim Hochfahren und erspart die halbe Wartung: Wenn YouTube etwas ändert, genügt in der Regel ein Neustart des Containers.
Bernd: „Ich häng bei so was immer den Docker-Socket rein. Dann kann n8n alles selbst steuern, superpraktisch.“
Tanja: „Wer den Docker-Socket hat, ist Administrator der ganzen NAS. Jeder Fehler in einem Workflow ist dann ein Fehler mit Vollzugriff. Wir lassen ihn draußen, und der Dienst bekommt nicht einmal einen Anschluss ins Heimnetz.“
Ulf: „Und wie kommt n8n dann dran?“
Tanja: „Über das gemeinsame Docker-Netz. Innerhalb des Netzes erreichbar, von außen nicht. Genau so viel Zugang wie nötig.“
Datei 2: app.py, der eigentliche Dienst, rund 170 Zeilen, ausschließlich Standardbibliothek, keine Fremdpakete. Er stellt drei Adressen bereit:
GET /transkript?v=<VIDEO_ID>&lang=de → YouTube-Untertitel als Fließtext (JSON)
GET /audio?url=<AUDIO_URL>&kbps=32 → Podcast-Audio klein gerechnet (MP3)
GET /gesund → Selbstauskunft mit Versionsnummern
Die Datei ist vollständig hier. Sie enthält den HTTP-Server, das Routing für alle drei Adressen, die Prüfung der Parameter und die Fehlerbehandlung. Kopiere sie unverändert in app.py; anzupassen ist nichts.app.py, vollständiger Code zum Kopieren (169 Zeilen)
#!/usr/bin/env python3
"""
Dienst fuer den Wochenrueckblick (Arbeitsliste Nr. 47, Workflow A20).
Zwei Endpunkte:
GET /transkript?v=<VIDEO_ID>&lang=de YouTube-Untertitel als Fliesstext (JSON)
GET /audio?url=<AUDIO_URL>&kbps=32 Podcast-Audio klein gerechnet (MP3, binaer)
Warum /audio: Die Spracherkennung nimmt hoechstens 25 MB je Datei an. Gemessen am
18.08.2026 liegen sechs von zehn Podcastfolgen darueber (bis 45 MB). ffmpeg rechnet
sie auf Mono, 16 kHz und niedrige Bitrate herunter - aus 46 Minuten werden rund 11 MB.
Laedt nie ein Video herunter. Kein Port nach aussen, nur im Docker-Netz erreichbar.
"""
import json
import os
import re
import subprocess
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs, unquote
PORT = int(os.environ.get("PORT", "8080"))
ZEITLIMIT = int(os.environ.get("ZEITLIMIT_SEKUNDEN", "120"))
AUDIO_ZEITLIMIT = int(os.environ.get("AUDIO_ZEITLIMIT_SEKUNDEN", "600"))
VIDEO_MUSTER = re.compile(r"^[A-Za-z0-9_-]{11}$")
def hole_untertitel(video_id: str, sprache: str):
"""Ruft yt-dlp auf und gibt (text, gefundene_sprache) zurueck."""
with tempfile.TemporaryDirectory() as ordner:
befehl = [
"yt-dlp",
"--write-auto-subs",
"--write-subs",
"--skip-download",
"--sub-langs", f"{sprache}.*",
"--sub-format", "json3",
"--no-warnings",
"--quiet",
"-o", os.path.join(ordner, "%(id)s.%(ext)s"),
f"https://www.youtube.com/watch?v={video_id}",
]
lauf = subprocess.run(befehl, capture_output=True, text=True, timeout=ZEITLIMIT)
dateien = sorted(f for f in os.listdir(ordner) if f.endswith(".json3"))
if not dateien:
fehler = (lauf.stderr or lauf.stdout or "").strip()[:300]
raise RuntimeError(fehler or "keine Untertitelspur gefunden")
bevorzugt = [f for f in dateien if "-orig" not in f] or dateien
pfad = os.path.join(ordner, bevorzugt[0])
gefunden = bevorzugt[0].split(".")[-2]
with open(pfad, encoding="utf-8") as fh:
daten = json.load(fh)
stuecke = []
for ereignis in daten.get("events", []):
for segment in ereignis.get("segs", []) or []:
stuecke.append(segment.get("utf8", ""))
text = " ".join("".join(stuecke).split())
return text, gefunden
def hole_audio(quelle: str, kbps: int) -> bytes:
"""Laedt die Audiodatei und rechnet sie klein. Gibt MP3-Bytes zurueck."""
befehl = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", quelle,
"-vn", # kein Bild
"-ac", "1", # mono
"-ar", "16000", # 16 kHz, mehr braucht Spracherkennung nicht
"-b:a", f"{kbps}k",
"-f", "mp3", "pipe:1",
]
lauf = subprocess.run(befehl, capture_output=True, timeout=AUDIO_ZEITLIMIT)
if lauf.returncode != 0 or not lauf.stdout:
fehler = (lauf.stderr or b"").decode("utf-8", "ignore").strip()[:300]
raise RuntimeError(fehler or "ffmpeg lieferte nichts")
return lauf.stdout
class Handler(BaseHTTPRequestHandler):
def _json(self, code, nutzlast):
koerper = json.dumps(nutzlast, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(koerper)))
self.end_headers()
self.wfile.write(koerper)
def _binaer(self, daten: bytes, typ: str, name: str):
self.send_response(200)
self.send_header("Content-Type", typ)
self.send_header("Content-Length", str(len(daten)))
self.send_header("Content-Disposition", f'attachment; filename="{name}"')
self.end_headers()
self.wfile.write(daten)
def _gesund(self):
def fassung(befehl):
try:
lauf = subprocess.run(befehl, capture_output=True, text=True, timeout=20)
return (lauf.stdout or lauf.stderr or "").strip().splitlines()[0][:60]
except Exception as fehler: # noqa: BLE001
return f"fehlt ({fehler})"
return self._json(200, {
"ok": True,
"dienst": "ytdlp-transkript",
"yt_dlp": fassung(["yt-dlp", "--version"]),
"ffmpeg": fassung(["ffmpeg", "-version"]),
})
def _transkript(self, frage):
video = (frage.get("v") or [""])[0].strip()
sprache = (frage.get("lang") or ["de"])[0].strip()
if not VIDEO_MUSTER.match(video):
return self._json(400, {"ok": False, "fehler": "ungueltige Video-ID"})
if not re.match(r"^[a-zA-Z-]{2,10}$", sprache):
return self._json(400, {"ok": False, "fehler": "ungueltige Sprache"})
try:
text, gefunden = hole_untertitel(video, sprache)
except subprocess.TimeoutExpired:
return self._json(504, {"ok": False, "video": video, "fehler": "Zeitlimit"})
except Exception as fehler: # noqa: BLE001
return self._json(200, {"ok": False, "video": video, "fehler": str(fehler)[:300]})
return self._json(200, {
"ok": True, "video": video, "sprache": gefunden,
"woerter": len(text.split()) if text else 0,
"zeichen": len(text), "text": text,
})
def _audio(self, frage):
quelle = unquote((frage.get("url") or [""])[0]).strip()
try:
kbps = max(8, min(64, int((frage.get("kbps") or ["32"])[0])))
except ValueError:
kbps = 32
zerlegt = urlparse(quelle)
if zerlegt.scheme not in ("http", "https") or not zerlegt.netloc:
return self._json(400, {"ok": False, "fehler": "nur http(s)-Adressen"})
try:
daten = hole_audio(quelle, kbps)
except subprocess.TimeoutExpired:
return self._json(504, {"ok": False, "fehler": "Zeitlimit beim Umrechnen"})
except Exception as fehler: # noqa: BLE001
return self._json(200, {"ok": False, "fehler": str(fehler)[:300]})
return self._binaer(daten, "audio/mpeg", "folge.mp3")
def do_GET(self):
zerlegt = urlparse(self.path)
frage = parse_qs(zerlegt.query)
if zerlegt.path in ("/", "/gesund"):
return self._gesund()
if zerlegt.path == "/transkript":
return self._transkript(frage)
if zerlegt.path == "/audio":
return self._audio(frage)
return self._json(404, {"ok": False, "fehler": "unbekannter Pfad"})
def log_message(self, format, *args): # noqa: A002
print("%s - %s" % (self.address_string(), format % args), flush=True)
if __name__ == "__main__":
print(f"ytdlp-transkript hoert auf Port {PORT}", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
Zwei Stellen lohnen den Blick. hole_untertitel() ruft yt-dlp mit --skip-download auf. Das Video wird also nie geladen, nur die Untertitelspur. Und hole_audio() schickt die Podcast-Datei durch ffmpeg auf Mono und 16 kHz; warum das nötig ist, steht in Phase 4.
Zwei Absicherungen sind eingebaut und sollten es bleiben: Die Video-Kennung wird gegen ein Muster geprüft (^[A-Za-z0-9_-]{11}$), und subprocess läuft ohne Shell. Damit lässt sich über die Adresszeile kein Befehl einschleusen.
Ulf: „Was heißt ‚ohne Shell‘?“
Tanja: „Wenn du einen Befehl über die Kommandozeile startest, darf dort alles Mögliche mitgeschrieben werden, Semikolons, Pipes, weitere Befehle. Ohne Shell übergibst du das Programm und seine Argumente einzeln. Dann ist ein Semikolon einfach ein Semikolon und kein zweiter Befehl.“
Bernd: „Da kommt doch eh nur eine Video-ID rein.“
Tanja: „Da kommt rein, was jemand in die Adresszeile schreibt. Deshalb steht darüber noch die Prüfung auf elf erlaubte Zeichen. Zwei Zeilen, und das Thema ist erledigt.“

Schritt 1.3: Das Projekt anlegen
Klickfolge: Container Manager → linke Leiste Projekt → Knopf Erstellen → Projektname yt-dlp → als Pfad den Ordner /volume2/docker/yt-dlp wählen → Quelle auf vorhandene docker-compose.yml verwenden → Weiter → Fertig.
Erwartetes Ergebnis: Ein Terminalfenster zeigt, wie das Grundabbild geladen wird. Nach ein bis zwei Minuten steht dort:
Container ytdlp-transkript Created
Container ytdlp-transkript Starting
Container ytdlp-transkript Started
Exit Code: 0


Wenn stattdessen dies erscheint:
network n8n_default declared as external, but could not be found
Dann steht in deiner compose.yaml noch der geratene Netzname statt des echten. Zurück zu Schritt 1.1.

Eine Meldung, die auf Synology regelmäßig auftaucht und harmlos ist:
Your kernel does not support PIDs limit capabilities or the cgroup is not mounted.
PIDs limit discarded
Der Kern der DiskStation unterstützt die Prozessgrenze nicht. Der Container läuft trotzdem. Die Zeile pids_limit: 256wirkt auf diesem System einfach nicht. Gut zu wissen, wenn man sie für eine Sicherheitsmaßnahme hält.
Ulf: „Da steht eine Warnung. Muss ich die reparieren?“
Tanja: „Nein. Der Kern der DiskStation kennt diese Prozessgrenze nicht, also wirft er sie weg. Der Container läuft. Wichtig ist nur, dass du weißt: pids_limit ist auf diesem System keine Schutzmaßnahme, sondern eine Zeile ohne Wirkung.“
Schritt 1.4: Die Gesundheitsprüfung
Jetzt die erste Messung, aus n8n heraus, denn nur von dort ist der Dienst überhaupt erreichbar.
Klickfolge: In n8n einen Wegwerf-Workflow anlegen → Manual Trigger → daran einen HTTP Request-Knoten → URL http://ytdlp:8080/gesund → Execute workflow.
Erwartetes Ergebnis:
{
"ok": true,
"dienst": "ytdlp-transkript",
"yt_dlp": "2026.07.04",
"ffmpeg": "ffmpeg version 7.1.5-0+deb13u1"
}
Meldet /gesund beide Versionen, steht der Dienst.
Schritt 1.5: Die eigentliche Prüfung mit vier Videos aus vier Kanälen
Jetzt entscheidet sich, ob dein Anschluss durchgelassen wird. Nimm vier Videos aus vier verschiedenen Kanälen und rufe nacheinander auf:
http://ytdlp:8080/transkript?v=<VIDEO_ID>&lang=de
Die Video-Kennung ist die elfstellige Zeichenfolge hinter watch?v= in der YouTube-Adresse.
Erwartetes Ergebnis, je Video eine Antwort dieser Art:
{ "ok": true, "video": "h0KKp3YX7hQ", "sprache": "de",
"woerter": 23053, "zeichen": 146311, "text": "..." }
Im Beispielaufbau lieferten vier von vier Kanälen, zusammen 29.323 Wörter in 27 Sekunden, ohne einen einzigen Fehlschlag.

Kommt stattdessen bei jedem Video „Sign in to confirm you’re not a bot“, geht deine Anlage über ein Rechenzentrum ins Netz. Der YouTube-Teil funktioniert dann nicht, und keine Einstellung ändert das.
Bernd: „Ein Video reicht doch zum Testen. Wenn eins geht, gehen alle.“
Tanja: „Nein. Genau so ist es uns passiert. Video eins lieferte 5.184 Wörter. Video zwei und drei wurden abgewiesen. Die Sperre greift nicht bei der ersten Anfrage, sondern danach.“
Ulf: „Also testet man mit vier Videos aus vier Kanälen, damit man nicht auf den ersten Treffer hereinfällt.“
Tanja: „So ist es. Ein einzelner Erfolg ist kein Beleg, wenn die Sperre erst beim zweiten Versuch zuschlägt.“
Warum vier Videos und nicht eines? Weil eines auch dann durchgeht, wenn die Sperre erst nach der ersten Anfrage greift. Genau so ist es beim Bauen passiert: Video eins lieferte 5.184 Wörter, Video zwei und drei wurden abgewiesen.
Phase 2: Der erste Strang, das Einsammeln
Jetzt entsteht A20.
Schritt 2.1: Zeitplan
Klickfolge: Neuer Workflow, Name A20 Wochenquellen sammeln → Add First Step → Schedule Trigger → Trigger Interval: Days, Trigger at Hour 4, Trigger at Minute 0.
Der Knoten heißt im Folgenden Täglich 04:00.
Schritt 2.2: Der Code-Knoten Quellen einsammeln
Ein einziger Code-Knoten liest alle Feeds. Das ist die entscheidende Bauentscheidung dieses Workflows: Eine neue Quelle hinzuzufügen ist damit eine Zeile Code, nicht ein neuer Knoten, kein neuer Workflow.
Bernd: „Sechzehn Quellen, sechzehn Knoten. Dann sieht man auf einen Blick, was läuft.“
Tanja: „Und beim siebzehnten ziehst du wieder einen Knoten auf die Fläche, verbindest ihn und hoffst, dass du nichts vergessen hast. Bei einem Code-Knoten mit drei Listen ist eine neue Quelle eine Zeile.“
Ulf: „Aber sieht man dann noch, welche Quelle klemmt?“
Tanja: „Gute Frage. Ja, dafür bauen wir gleich ein Protokoll ein, das je Quelle sagt, ob sie geantwortet hat. Das ist übersichtlicher als sechzehn Kästchen.“
Die drei Listen am Anfang sind das, was du anpasst:
const YT = [
['Christoph Magnussen','UCDx6L69jmKBJbNu5GnkCilg'],
['Digitale Profis','UCv90NdTyTp7ZPPRvvSZaS5w'],
['Everlast AI','UC8T5gQ4U4GbI2h8kYCkEcvg'],
['Felicia Simon','UCM2u6Uvi5XBBlh5GDv4otsg'],
['Niklas Steenfatt','UCzsfkUFa1_4F4cZeSLv5dFQ'],
['Sascha Hoffmann','UCp4UhJ7LbBphg5d4tBvyF7A'],
];
const PODCASTS = [
['Der KI-Podcast (ARD)', ['https://feeds.br.de/der-ki-podcast/feed.xml',
'https://www.ardaudiothek.de/sendung/der-ki-podcast/65505255/feed']],
['KI-Update (heise)', ['https://kiupdate.podigee.io/feed/mp3',
'https://www.heise.de/rss/heise-Ki-Update.rdf']],
['KI verstehen (DLF)', ['https://www.deutschlandfunk.de/ki-verstehen-102.xml',
'https://podcast-mp3.dradio.de/podcast/ki_verstehen.xml']],
];
const BLOGS = [
['One Useful Thing','https://www.oneusefulthing.org/feed'],
['KI-Beratung','https://www.kiberatung.de/blog/rss.xml'],
];
Der Kanal-Feed liegt für jeden YouTube-Kanal unter https://www.youtube.com/feeds/videos.xml?channel_id=<ID>. Die Kanal-ID beginnt mit UC und findet sich im Quelltext der Kanalseite.
Bei den Podcasts steht je Sendung eine Kandidatenliste. Der Code probiert sie der Reihe nach durch und bleibt beim ersten Feed stehen, der <item>-Einträge enthält. Das ist kein Schönheitsfehler, sondern die pragmatische Antwort auf ein reales Problem: Podcast-Feedadressen sind oft nicht dokumentiert, und zwei Versuche sind billiger als eine Stunde Suche.
Zwei Anmerkungen zu KI-Beratung, weil die Quelle hier nur halb bedient wird. Erstens ist ihr Feed nirgends deklariert und liegt nicht an der Domainwurzel. /blog/rss.xml findet man nur, wenn man danach sucht. Zweitens liefert er bloß 249 Zeichen Anriss; den Volltext holt später der vierte Strang aus Phase 5 nach. Eingesammelt wird die Quelle also hier, gelesen wird sie dort.
Ein Stolperstein, der leicht zu übersehen ist: Die <link>-Elemente in diesem Feed zeigen auf die Webflow-Baustellenadresse kiberatung.webflow.io statt auf die Live-Domain. Wer sie unverändert übernimmt, speichert Adressen, die später niemand aufrufen kann, beim Einlesen umschreiben.
Vier Bausteine im Code lohnen die Erwähnung:
Der Zeitfilter nimmt 14 Tage statt sieben. Das ist Absicht: Fällt ein nächtlicher Lauf aus, holt der nächste die Lücke von selbst auf.
Die URL-Normalisierung wirft Tracking-Parameter (utm_*, fbclid, gclid und Verwandte) weg, vereinheitlicht www. und Groß-/Kleinschreibung. Sie erzeugt den Wert für url_normalized, an dem die Entdoppelung hängt.
Ulf: „Warum reicht die Adresse nicht so, wie sie ist?“
Tanja: „Weil dieselbe Seite unter vielen Adressen erreichbar ist. Einmal mit www., einmal ohne, einmal mit ?utm_source=newsletter dahinter. Für dich ist das ein Artikel, für die Datenbank sind es drei. Die Normalisierung schneidet den Ballast weg, und was übrig bleibt, ist der Wert, an dem die Entdoppelung hängt.“
Die Feed-Prüfung. Diese vier Zeilen sind die Umsetzung der Warnung von ganz oben:
if (roh.indexOf('<entry') < 0) {
protokoll.push({ quelle: name, art: 'youtube', ok: false, im_feed: 0,
fehler: 'Antwort ist kein Atom-Feed (kein <entry>)' });
continue;
}
Ohne sie sähe das Protokoll aus wie „Feed in Ordnung, nichts Neues“, obwohl in Wahrheit eine Startseite geantwortet hat.
Das sichtbare Protokoll. Der Knoten hängt am ersten Ergebnis ein Feld _protokoll an, in dem je Quelle steht, ob sie geantwortet hat und wie viele Treffer sie lieferte:
return eindeutig.map((g, i) => ({
json: i === 0 ? Object.assign({}, g, { _protokoll: protokoll }) : g
}));
Das klingt nach Kleinkram und ist es nicht. Beim ersten Lauf lieferte der Blog null Zeilen, und niemand konnte sagen, ob der Feed nicht geantwortet hatte oder ob es schlicht nichts Neues gab, weil das Protokoll nur ins Systemlog geschrieben wurde. Was man zur Fehlersuche braucht, gehört sichtbar in den Datenstrom. Mit dem sichtbaren Protokoll war die Frage beim nächsten Lauf in drei Sekunden beantwortet: „ok, 0 Treffer“. Der Feed lebte, es gab nur nichts.
Bernd: „Für so was gibt es doch Logs. Ich schreib alles mit console.log raus.“
Tanja: „Und dann suchst du im Systemprotokoll des Containers nach dem Lauf von vorgestern. Wir hängen das Protokoll an das erste Ergebnis. Dann steht es im Lauf selbst, direkt neben den Daten, auf die es sich bezieht.“
Zusätzlich misst der Knoten per HEAD-Anfrage die Dateigrößen der Podcast-Folgen und schreibt sie ins Protokoll. Wozu, gleich in Phase 4.Quellen einsammeln, vollständiger Code zum Kopieren (211 Zeilen)
// A20 Stufe 1: Feeds einsammeln.
// Schreibt nach ki_artikel mit source_type podcast|youtube|blog und Status GESAMMELT.
const helfer = this.helpers;
const TAGE = 14;
const grenze = Date.now() - TAGE * 24 * 3600 * 1000;
const UA = 'Mozilla/5.0 (compatible; mein-newsroom/1.0)';
const hole = async (url) => String(await helfer.httpRequest({
method: 'GET', url, headers: { 'User-Agent': UA }, timeout: 25000,
}));
// URL-Normalisierung: Tracking-Parameter weg, Host vereinheitlichen.
const normalisiere = (roh) => {
try {
const u = new URL(roh);
u.hash = '';
const weg = new Set(['fbclid','gclid','dclid','msclkid','igshid','mc_cid','mc_eid','mkt_tok','yclid','cmpid']);
for (const k of Array.from(u.searchParams.keys())) {
const s = k.toLowerCase();
if (s.startsWith('utm_') || weg.has(s)) u.searchParams.delete(k);
}
u.hostname = u.hostname.replace(/^www\./i, '').toLowerCase();
u.protocol = 'https:';
if (u.pathname === '/') u.pathname = '';
return u.toString();
} catch (e) { return roh; }
};
const entkerne = (s) => String(s || '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/<[^>]+>/g, ' ')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
.replace(/\s+/g, ' ').trim();
const feld = (block, tag) => {
const m = block.match(new RegExp('<' + tag + '[^>]*>([\\s\\S]*?)<\\/' + tag + '>'));
return m ? entkerne(m[1]) : '';
};
const gefunden = [];
const protokoll = [];
// ---------- YouTube ----------
const YT = [
['Christoph Magnussen','UCDx6L69jmKBJbNu5GnkCilg'],
['Digitale Profis','UCv90NdTyTp7ZPPRvvSZaS5w'],
['Everlast AI','UC8T5gQ4U4GbI2h8kYCkEcvg'],
['Felicia Simon','UCM2u6Uvi5XBBlh5GDv4otsg'],
['Niklas Steenfatt','UCzsfkUFa1_4F4cZeSLv5dFQ'],
['Sascha Hoffmann','UCp4UhJ7LbBphg5d4tBvyF7A'],
];
for (const paar of YT) {
const name = paar[0], kid = paar[1];
try {
const roh = await hole('https://www.youtube.com/feeds/videos.xml?channel_id=' + kid);
// HTTP 200 heisst nicht Feed. Kommt eine Fehlerseite oder eine Bot-Abwehr
// zurueck, saehe das Protokoll sonst aus wie "Feed in Ordnung, nichts Neues".
if (roh.indexOf('<entry') < 0) {
protokoll.push({ quelle: name, art: 'youtube', ok: false, im_feed: 0,
fehler: 'Antwort ist kein Atom-Feed (kein <entry>)' });
continue;
}
const teile = roh.split('<entry>').slice(1);
let neu = 0;
for (const t of teile) {
const id = feld(t, 'yt:videoId');
const datum = feld(t, 'published');
if (!id || !datum) continue;
if (new Date(datum).getTime() < grenze) continue;
const url = 'https://www.youtube.com/watch?v=' + id;
gefunden.push({
title: feld(t, 'media:title') || feld(t, 'title'),
url, url_normalized: normalisiere(url),
source: name, source_type: 'youtube', status: 'GESAMMELT',
description: feld(t, 'media:description').slice(0, 4000),
published_date: new Date(datum).toISOString(),
dauer_sekunden: null,
});
neu++;
}
protokoll.push({ quelle: name, art: 'youtube', ok: true, treffer: neu, im_feed: teile.length });
} catch (e) {
protokoll.push({ quelle: name, art: 'youtube', ok: false, fehler: String(e.message || e).slice(0, 100) });
}
}
// ---------- Podcasts: Kandidaten der Reihe nach durchprobieren ----------
const PODCASTS = [
['Der KI-Podcast (ARD)', ['https://feeds.br.de/der-ki-podcast/feed.xml',
'https://www.ardaudiothek.de/sendung/der-ki-podcast/65505255/feed']],
['KI-Update (heise)', ['https://kiupdate.podigee.io/feed/mp3',
'https://www.heise.de/rss/heise-Ki-Update.rdf']],
['KI verstehen (DLF)', ['https://www.deutschlandfunk.de/ki-verstehen-102.xml',
'https://podcast-mp3.dradio.de/podcast/ki_verstehen.xml']],
];
for (const paar of PODCASTS) {
const name = paar[0], kandidaten = paar[1];
let erfolg = false;
for (const adresse of kandidaten) {
let roh;
try { roh = await hole(adresse); }
catch (e) {
protokoll.push({ quelle: name, art: 'podcast', kandidat: adresse, ok: false,
fehler: String(e.message || e).slice(0, 80) });
continue;
}
if (roh.indexOf('<item') < 0) {
protokoll.push({ quelle: name, art: 'podcast', kandidat: adresse, ok: false, fehler: 'kein RSS mit <item>' });
continue;
}
const teile = roh.split('<item').slice(1);
let neu = 0;
for (const t of teile) {
const link = (t.match(/<link[^>]*>([\s\S]*?)<\/link>/) || [])[1];
const datum = feld(t, 'pubDate');
if (!link || !datum) continue;
if (new Date(datum).getTime() < grenze) continue;
const audio = (t.match(/<enclosure[^>]*url="([^"]+)"/) || [])[1] || '';
const dauer = feld(t, 'itunes:duration');
let sek = null;
if (/^\d+$/.test(dauer)) sek = Number(dauer);
else if (/:/.test(dauer)) {
const p = dauer.split(':').map(Number);
sek = p.length === 3 ? p[0]*3600 + p[1]*60 + p[2] : p[0]*60 + p[1];
}
const sauber = entkerne(link);
gefunden.push({
title: feld(t, 'title'),
url: sauber, url_normalized: normalisiere(sauber),
source: name, source_type: 'podcast', status: 'GESAMMELT',
// Die Audioadresse wandert in die Beschreibung - Stufe 2b liest sie dort wieder aus.
description: (feld(t, 'description') + (audio ? ' | AUDIO: ' + audio : '')).slice(0, 4000),
published_date: new Date(datum).toISOString(),
dauer_sekunden: sek,
});
neu++;
}
protokoll.push({ quelle: name, art: 'podcast', kandidat: adresse, ok: true, treffer: neu, im_feed: teile.length });
erfolg = true;
break;
}
if (!erfolg) protokoll.push({ quelle: name, art: 'podcast', ok: false, fehler: 'kein Kandidat lieferte einen Feed' });
}
// ---------- Blogs ----------
const BLOGS = [
['One Useful Thing','https://www.oneusefulthing.org/feed'],
['KI-Beratung','https://www.kiberatung.de/blog/rss.xml'],
];
for (const paar of BLOGS) {
const name = paar[0], adresse = paar[1];
try {
const roh = await hole(adresse);
if (roh.indexOf('<item') < 0) {
protokoll.push({ quelle: name, art: 'blog', ok: false, im_feed: 0,
fehler: 'Antwort ist kein RSS mit <item>' });
continue;
}
const teile = roh.split('<item').slice(1);
let neu = 0;
for (const t of teile) {
let link = entkerne((t.match(/<link[^>]*>([\s\S]*?)<\/link>/) || [])[1] || '');
const datum = feld(t, 'pubDate');
if (!link || !datum) continue;
if (new Date(datum).getTime() < grenze) continue;
// KI-Beratung: die <link>-Elemente zeigen auf die Webflow-Baustellenadresse.
link = link.replace('kiberatung.webflow.io', 'www.kiberatung.de');
gefunden.push({
title: feld(t, 'title'),
url: link, url_normalized: normalisiere(link),
source: name, source_type: 'blog', status: 'GESAMMELT',
description: feld(t, 'description').slice(0, 4000),
published_date: new Date(datum).toISOString(),
dauer_sekunden: null,
});
neu++;
}
protokoll.push({ quelle: name, art: 'blog', ok: true, treffer: neu, im_feed: teile.length });
} catch (e) {
protokoll.push({ quelle: name, art: 'blog', ok: false, fehler: String(e.message || e).slice(0, 100) });
}
}
// Audiogroessen messen: die Spracherkennung nimmt hoechstens 25 MB an.
for (const g of gefunden) {
if (g.source_type !== 'podcast') continue;
const m = String(g.description).match(/AUDIO: (\S+)/);
if (!m) continue;
try {
const a = await helfer.httpRequest({ method:'HEAD', url:m[1], timeout:20000,
returnFullResponse:true, ignoreHttpStatusErrors:true, headers:{'User-Agent':UA} });
const len = a.headers && (a.headers['content-length'] || a.headers['Content-Length']);
if (len) protokoll.push({ quelle:g.source, art:'audiogroesse',
mb: Math.round(Number(len)/1048576*10)/10,
minuten: g.dauer_sekunden ? Math.round(g.dauer_sekunden/60) : null });
} catch (e) {
protokoll.push({ quelle:g.source, art:'audiogroesse', fehler:String(e.message||e).slice(0,60) });
}
}
// Innerhalb des Laufs entdoppeln
const gesehen = new Set();
const eindeutig = gefunden.filter(g => {
if (gesehen.has(g.url_normalized)) return false;
gesehen.add(g.url_normalized);
return true;
});
// Protokoll sichtbar im Datenstrom statt nur im Log.
return eindeutig.map((g, i) => ({ json: i === 0 ? Object.assign({}, g, { _protokoll: protokoll }) : g }));
Anzupassen sind nur die drei Listen YT, PODCASTS und BLOGS sowie die Kennung in UA. Alles andere läuft unverändert.
Schritt 2.3: Der Insert-Knoten
Klickfolge: Postgres-Knoten anhängen → Operation Insert → Schema public → Table ki_artikel → Mapping Column Mode: Map Each Column Manually → Spalten zuordnen (title, url, url_normalized, source, source_type, status, description, published_date, dauer_sekunden) → unter Optionen Skip on Conflict einschalten, Abgleich über url_normalized.
Zusätzlich unter Settings: On Error → Continue (using regular output).
Die manuelle Zuordnung ist Pflicht, nicht Geschmackssache. Bei automatischer Zuordnung würde das Zusatzfeld _protokoll als Spaltenname gelten und den Insert zerlegen.
Bernd: „Automatische Zuordnung. Spart dir neun Klicks.“
Tanja: „Und kostet dich den ganzen Knoten. Wir haben eben ein Feld _protokoll angehängt. Bei automatischer Zuordnung sucht Postgres eine Spalte mit diesem Namen, findet keine und bricht ab. Die neun Klicks machst du einmal.“
Erwartetes Ergebnis beim ersten Lauf: Im Beispielaufbau 46 Funde gesammelt, 46 geschrieben, Laufzeit 1,775 Sekunden. Prüfe in der Datenbank:
SELECT source_type, count(*) FROM ki_artikel GROUP BY 1;
Es sollten youtube und podcast auftauchen, alle mit Status GESAMMELT.
Phase 3: Der zweite Strang, YouTube abtippen
Drei Knoten, angehängt am Insert-Knoten (nicht hintereinander an den vorigen Strang, warum, kommt gleich).
Knoten Offene Videos holen, ein Postgres-Knoten:
SELECT id, url
FROM ki_artikel
WHERE status = 'GESAMMELT'
AND source_type = 'youtube'
AND (content IS NULL OR content = '')
AND (published_date IS NULL OR published_date > now() - interval '21 days')
ORDER BY published_date DESC
LIMIT 25
Wichtig: Stelle den Knoten unter Settings auf Execute Once. Steht er auf „je Eingangszeile ausführen“ und liefert der Insert-Knoten zehn Zeilen, läuft die Abfrage zehnmal, und jedes Video wird zehnmal abgetippt.
Ulf: „Zehnmal dasselbe Video? Wie kann das passieren?“
Tanja: „Weil ein n8n-Knoten standardmäßig einmal je Eingangszeile läuft. Kommen zehn Zeilen vom Insert an, läuft die Abfrage zehnmal, und jedes Mal holt sie dieselben offenen Videos. Der Schalter heißt Execute Once und bedeutet: einmal für alle Eingänge, egal wie viele kommen.“
Knoten Transkripte holen, ein Code-Knoten, der drei Videos gleichzeitig abruft:
const GLEICHZEITIG = 3;
const alle = $input.all().filter(i => i.json && i.json.id);
if (!alle.length) return [];
// Beweislast umgekehrt: OHNE_TRANSKRIPT wird NUR gesetzt, wenn ein Grund positiv
// als dauerhaft erkannt ist. Alles andere, auch ein unbekannter Fehler, bleibt auf
// GESAMMELT und ist morgen wieder dran. Ein Video mehrfach zu versuchen kostet
// Sekunden. Eines zu Unrecht abzuschreiben kostet den Inhalt fuer immer, und es
// faellt niemandem auf.
const DAUERHAFT = /keine Video-Kennung|keine Untertitelspur gefunden|zu kurz oder leer|no subtitles|subtitles are not available|video unavailable|private video|this video is private|has been removed|been terminated|members-only|join this channel|age-restricted|confirm your age/i;
// Die Bot-Pruefung von YouTube haengt an Adresse und Abfragehaeufigkeit, nicht am
// Video. Beim naechsten Versuch geht es fast immer wieder. Sie steht deshalb
// ausdruecklich hier und schlaegt jede Dauerhaft-Erkennung. Genau dieser Fall hat
// am 26.08.2026 zwei Videos stillgelegt.
const BOTPRUEFUNG = /not a bot|sign in to confirm you.?re not|bot.?pruefung|bot.?prüfung/i;
const dauerhaft = (grund) => !BOTPRUEFUNG.test(grund) && DAUERHAFT.test(grund);
const ergebnis = [];
const spaeter = [];
let naechster = 0;
const arbeiter = async () => {
while (true) {
const k = naechster++;
if (k >= alle.length) return;
const zeile = alle[k].json;
const m = String(zeile.url).match(/[?&]v=([A-Za-z0-9_-]{11})/);
// Keine Video-Kennung in der Adresse. Das aendert sich nie, also dauerhaft.
if (!m) {
ergebnis.push({ id: zeile.id, status: 'OHNE_TRANSKRIPT', content: null,
hinweis: 'keine Video-Kennung in der Adresse' });
continue;
}
let grund = '';
try {
const t = await this.helpers.httpRequest({ method:'GET',
url: 'http://ytdlp:8080/transkript?v=' + m[1] + '&lang=de',
json: true, timeout: 180000 });
if (t && t.ok && t.text && t.woerter > 50) {
ergebnis.push({ id: zeile.id, status: 'TRANSKRIBIERT', content: t.text, woerter: t.woerter });
continue;
}
grund = String((t && t.fehler) || 'zu kurz oder leer');
} catch (e) {
grund = String((e && e.message) || e);
}
if (dauerhaft(grund)) {
ergebnis.push({ id: zeile.id, status: 'OHNE_TRANSKRIPT', content: null,
hinweis: grund.slice(0, 140) });
} else {
spaeter.push({ id: zeile.id, grund: grund.slice(0, 200) });
}
}
};
await Promise.all(Array.from({ length: GLEICHZEITIG }, () => arbeiter()));
// Uebersprungene nur an eine vorhandene Zeile anhaengen. Ein eigenes Objekt ohne
// "id" wuerde den Update-Knoten dahinter zerlegen, siehe Phase 5.
const zeilen = ergebnis.map(r => ({ json: r }));
if (zeilen.length && spaeter.length) zeilen[0].json._spaeter = spaeter;
return zeilen;
Vier Dinge daran sind wichtig.
Die Schwelle woerter > 50. Ein Video mit fünf Wörtern Untertitel hat faktisch keinen.
Nichts bricht ab. Fehlen die Untertitel dauerhaft, bekommt der Datensatz OHNE_TRANSKRIPT und der Lauf geht weiter.
Nur ein positiv erkannter Grund darf abschreiben. Das ist die wichtigste Zeile dieses Knotens, und sie ist teuer gelernt. Offene Videos holen sucht status = “GESAMMELT“. Was einmal auf OHNE_TRANSKRIPT steht, ist damit nie wiederdran. Ein Zeitlimit, ein Neustart des Containers, ein Netzaussetzer: Das sind Zustände von heute Nacht, keine Eigenschaften des Videos.
Der naheliegende Bau ist eine Liste vorübergehender Fehler, die man ausnimmt. Der naheliegende Bau ist falsch, und warum, steht gleich unten. Richtig ist die Umkehrung: Der Knoten schreibt OHNE_TRANSKRIPT nur, wenn der Grund in DAUERHAFT steht, also keine Video-Kennung in der Adresse, keine Untertitelspur vorhanden, zu kurz, Video gelöscht, privat, nur für Mitglieder, altersbeschränkt. Alles andere, auch ein Fehler, den niemand vorhergesehen hat, bleibt auf GESAMMELT und ist morgen wieder dran. Die übersprungenen Kennungen hängen im Feld _spaeter an der ersten Ergebniszeile, damit man sie sieht.
Die Rechnung dahinter ist nicht knapp: Ein Video mehrfach zu versuchen kostet ein paar Sekunden. Ein Video zu Unrecht abzuschreiben kostet den Inhalt dauerhaft, und es fällt niemandem auf, weil nichts rot wird.
Die Bot-Prüfung steht ausdrücklich in der Liste, und sie schlägt jede Dauerhaft-Erkennung. Sie hängt an der Adresse und der Abfragehäufigkeit, nicht am Video; beim nächsten Versuch geht es fast immer wieder. Sie steht wortgleich auch im Podcast-Zweig, obwohl sie dort gar nicht auftreten kann, denn Groq prüft keine Bots. Der Grund ist Absicht: Zwei Listen, die dasselbe leisten sollen, laufen auseinander, wenn man sie getrennt pflegt. Genau das ist hier passiert.
Übersprungene Zeilen bekommen kein eigenes Ergebnisobjekt. Ein Objekt ohne id würde den Update-Knoten dahinter zerlegen, genau der Fehler aus Phase 5. Sind alle Videos übersprungen worden, gibt der Knoten [] zurück, und der Zweig macht in dieser Nacht schlicht nichts.
Und bleibt GESAMMELT darf nicht für immer heißen. Deshalb die 21 Tage in der Abfrage oben. Ein Video, das drei Wochen lang bei jedem Versuch scheitert, fällt aus der Auswahl, ohne dass es dafür einen Status braucht. Das Fenster ist bewusst größer als die 14 Tage, mit denen gesammelt wird, und deutlich größer als die sieben Tage, die A21 betrachtet. Ohne diese Grenze würden unerreichbare Videos die 25 Plätze der Abfrage nach und nach dauerhaft belegen.
Der Unfall, der diesen Knoten so aussehen lässt
Der erste Bau dieses Knotens hatte eine Liste vorübergehender Fehler: Zeitlimit, ECONNRESET, 429, 502, 503, 504. Alles andere galt als dauerhaft. Das klingt vernünftig und ist es nicht.
Beim Nachsehen der aufbewahrten Läufe standen zwei Videos auf OHNE_TRANSKRIPT, beide aus demselben Lauf, beide mit derselben Meldung:
ERROR: [youtube] XNKASdMB2Mw: Sign in to confirm you're not a bot
Das ist YouTubes Bot-Prüfung. Sie ist der denkbar klarste vorübergehende Fehler: Sie hängt an der Adresse und daran, wie oft von dort gefragt wurde, nicht am Video. Beim nächsten Versuch geht es fast immer wieder.
Nur stand sie in keiner der sechs Zeilen der Liste. Kein rate limit, kein 429, kein timeout, kein ECONNRESET. Der Knoten hat sie als unbekannt und damit als dauerhaft eingestuft, und zwei Videos waren abgeschrieben. Es ist derselbe Unfall wie der mit der Deutschlandfunk-Folge in Phase 4, nur im anderen Zweig, und niemandem ist er aufgefallen.
Die Lehre, und sie ist allgemeiner als dieser Knoten: Eine Liste der Fehler, die man kennt, ist immer unvollständig. Wer sie zur Grundlage einer unumkehrbaren Entscheidung macht, hat die Beweislast falsch herum gelegt. Der unbekannte Fall muss auf der harmlosen Seite landen, nicht auf der endgültigen.
Bernd: „Ein Fehler ist ein Fehler. Rein damit in den catch, Status setzen, weiter.“
Tanja: „Und damit hast du das Video für immer stillgelegt. Ein Zeitlimit sagt dir nichts über das Video, es sagt dir etwas über das Netz von heute Nacht.“
Bernd: „Dann schreib eben die vorübergehenden Fehler in eine Liste und nimm die aus. Zeitlimit, 429, 503, fertig.“
Tanja: „Das war unser erster Bau. Er hat zwei Videos gekostet.“
Ulf: „Warum? Die Liste sah doch vollständig aus.“
Tanja: „Sie sah vollständig aus. Dann kam ‚Sign in to confirm you’re not a bot‘, und das stand in keiner der sechs Zeilen. Der Knoten hielt es für unbekannt, unbekannt hieß dauerhaft, und die beiden Videos waren abgeschrieben.“
Ulf: „Und jetzt?“
Tanja: „Jetzt läuft es andersherum. Abgeschrieben wird nur, was ich positiv als dauerhaft erkenne: keine Untertitelspur, Video gelöscht, privat, nur für Mitglieder, keine Kennung in der Adresse. Alles andere bleibt liegen und ist morgen wieder dran, auch ein Fehler, den ich noch nie gesehen habe.“
Bernd: „Dann probierst du kaputte Videos ewig weiter.“
Tanja: „Drei Wochen, dann fallen sie aus der Abfrage. Und selbst wenn: Ein Video mehrmals zu versuchen kostet Sekunden. Eines zu Unrecht abzuschreiben kostet den Inhalt für immer, und es merkt keiner.“
Knoten Transkript speichern: Postgres, Operation Update, Abgleich über id, gesetzt werden content und status.
Erwartetes Ergebnis: 25 Videos in 37 Sekunden, null Fehlschläge, 88.448 Wörter. In der Datenbank landeten 555.078 Zeichen, im Schnitt rund 3.400 Wörter je Video.
Warum die Stränge als getrennte Zweige hängen
Alle drei Bearbeitungsstränge hängen am selben Insert-Knoten statt hintereinander. Der Grund: So laufen die Podcasts auch dann, wenn an einem Tag kein einziges Video offen ist, und sie laufen erst, nachdem die Funde des Tages in der Datenbank stehen.
Ein Wort zur Reihenfolge, weil nebeneinander im Bild nicht gleichzeitig in der Ausführung heißt. n8n arbeitet die Zweige nacheinander ab. Welcher zuerst drankommt, hängt von der eingestellten Ausführungsreihenfolge und der Anordnung auf der Fläche ab, nicht davon, wie das Bild aussieht. Für diesen Aufbau ist das unkritisch, wissen sollte man es trotzdem: Läuft Offene Verdichtungen holen vor dem Speichern der Transkripte, verdichtet es das Material des Vortags, und das von heute Nacht kommt morgen dran. Wer das nicht will, ordnet den Verdichtungszweig auf der Fläche unter die beiden Transkriptionszweige oder hängt ihn direkt hinter Transkript speichern.
Bernd: „Ich häng die immer hintereinander. Eins nach dem anderen, sauber der Reihe nach.“
Tanja: „Dann steht die Kette an jedem Tag still, an dem kein Video offen ist. Kein Video, keine Ausgabe, und alles dahinter bekommt nie ein Startsignal. Die Podcasts hätten dann tagelang stillgestanden, ohne dass irgendwo etwas rot geworden wäre.“

Phase 4: Der dritte Strang, Podcasts und die 25-Megabyte-Mauer
Podcasts sind reiner Ton. Eine schreibende KI kann nicht hören, also muss abgetippt werden. Das ist der einzige laufende Kostenpunkt der ganzen Anlage, und er kostet, richtig gemacht, null Euro.
Die Messung, die alles verändert hat
Bevor irgendetwas gebaut wurde, wurden die Dateigrößen der vorhandenen Folgen gemessen. Spracherkennungsdienste nehmen typischerweise höchstens 25 MB je Datei an:
| Sendung | Größen | Minuten |
|---|---|---|
| Der KI-Podcast (ARD) | 42,2 MB · 38,8 MB | 46 · 42 |
| KI verstehen (DLF) | 45,1 MB · 34,7 MB | 49 · 38 |
| KI-Update (heise) | 19,7 · 21,5 · 21,8 · 23,1 · 26,8 · 38,7 MB | 16 bis 33 |
Sechs von zehn Folgen liegen über der Grenze, und zwar ausgerechnet die langen Formate, also genau die, die argumentieren. Ohne diese Messung hätte die Kette bei vier von zehn Folgen scheinbar funktioniert und bei sechs stillschweigend nichts geliefert.
Bernd: „Man baut erst und guckt dann, was klemmt. Learning by doing.“
Tanja: „Hier hättest du damit vier von zehn Folgen bekommen und sechs nicht, und zwar genau die langen. Der Aufbau hätte funktioniert ausgesehen. Zehn Minuten messen vorher ersparen dir eine Woche Rätselraten nachher.“
Ulf: „Und warum ausgerechnet die langen?“
Tanja: „Weil die Grenze bei der Dateigröße liegt und lange Folgen große Dateien sind. Die Formate, die am meisten erklären, fallen als Erstes raus. Das ist das Ärgerliche daran.“
Die Lösung steckt schon in deiner app.py: der Endpunkt /audio rechnet die Datei mit ffmpeg (dem Standardwerkzeug für Ton- und Videoumwandlung) auf Mono, 16 kHz und niedrige Bitrate herunter.
| Folge | vorher | nachher bei 32 kbps |
|---|---|---|
| 49 Min (DLF) | 45,1 MB | 11,5 MB |
| 46 Min (ARD) | 42,2 MB | 10,8 MB |
| 33 Min (heise) | 38,7 MB | 7,7 MB |
| 16 Min (heise) | 19,7 MB | 3,8 MB |
Gemessen wurden am Ende 11.824.374 Byte = 11,28 MB für die 49-Minuten-Folge. Die Rechnung stimmte auf zwei Prozent genau. Und es geht dabei nichts verloren: 16 kHz Mono ist das Format, mit dem Spracherkennung ohnehin arbeitet.
Ulf: „Aber die Qualität. Wenn ich eine Datei so klein rechne, versteht die Erkennung dann noch etwas?“
Tanja: „16 kHz Mono ist genau das Format, in dem Spracherkennung arbeitet. Du wirfst nichts weg, was sie benutzt hätte. Du wirfst Stereo und Frequenzen über 8 kHz weg, und beides braucht sie für gesprochene Sprache nicht.“
Schritt 4.1: Groq einrichten
Klickfolge: Auf console.groq.com ein Konto anlegen → API Keys → Create API Key → Name vergeben → Schlüssel kopieren.
Ein praktischer Hinweis: Die Anmeldung per E-Mail scheiterte bei einer t-online.de-Adresse, weil die Telekom Bestätigungsmails bei Spamverdacht schon auf Serverebene abweist, sie landen in keinem Ordner, auch nicht im Spam. Der Weg über „Continue with Google“ braucht keine Bestätigungsmail.

In n8n: Credentials → Create credential → Groq → Schlüssel einfügen → speichern.
Ulf: „Ich sehe den Schlüssel nicht mehr. Da stehen nur noch vier Zeichen.“
Tanja: „Den vollständigen Schlüssel zeigt Groq genau einmal, beim Erzeugen. Danach nie wieder. Wenn du ihn nicht kopiert hast, legst du einen neuen an und löschst den alten. Das ist kein Fehler, das ist Absicht.“
Schritt 4.2: Die Grenzen nachsehen, und zwar im eigenen Konto
Klickfolge: Im Groq-Konto → Limits.
Erwartetes Ergebnis für beide Whisper-Modelle:
| Anfragen je Minute | 20 |
|---|---|
| Anfragen je Tag | 2.000 |
| Tonsekunden je Stunde | 7.200 |
| Tonsekunden je Tag | 28.800 |

Bernd: „Die Grenzen stehen in jedem Blogartikel. Muss man nicht im Konto nachsehen.“
Tanja: „Sieh im eigenen Konto nach. Anbieter ändern Tarife, und Blogartikel altern. Die Seite Limits zeigt dir, was für dein Konto heute gilt. Das ist der einzige Wert, mit dem du rechnen kannst.“
Rechne dagegen deinen Bedarf. Im Beispielaufbau: 302,5 Tonminuten je Woche = 18.150 Sekunden, verteilt auf sieben Tage. Das passt bequem in das kostenlose Kontingent. Die Podcast-Transkription kostet damit 0 €.

Schritt 4.3: Die fünf Knoten
Offene Folgen holen (Postgres, Execute Once):
SELECT id,
substring(description from 'AUDIO: (\S+)') AS audio,
COALESCE(dauer_sekunden, 0) AS dauer_sekunden,
source
FROM ki_artikel
WHERE source_type = 'podcast'
AND status = 'GESAMMELT'
AND (content IS NULL OR content = '')
AND description LIKE '%AUDIO:%'
ORDER BY published_date DESC NULLS LAST
LIMIT 25
Die Audioadresse steht im Feld description hinter der Marke AUDIO:. Dort hat sie der Sammel-Knoten aus dem <enclosure>-Element des Feeds abgelegt.
Kontingent beachten (Code), der Knoten, der verhindert, dass ein Lauf die Stundengrenze sprengt:
const GRENZE_SEKUNDEN = 3600;
const STANDARDDAUER = 1800; // unbekannte Laenge: vorsichtig mit 30 Minuten rechnen
const alle = $input.all().filter(i => i.json && i.json.id && i.json.audio);
const genommen = [];
let summe = 0;
for (const eintrag of alle) {
const dauer = Number(eintrag.json.dauer_sekunden) || STANDARDDAUER;
if (genommen.length && summe + dauer > GRENZE_SEKUNDEN) break;
genommen.push({ json: {
id: eintrag.json.id, audio: eintrag.json.audio,
source: eintrag.json.source, dauer_sekunden: dauer,
}});
summe += dauer;
}
if (genommen.length) {
genommen[0].json._protokoll = {
offen_gesamt: alle.length, genommen: genommen.length,
vertagt: alle.length - genommen.length, tonminuten: Math.round(summe / 60),
};
}
return genommen;
Warum 3.600 statt der erlaubten 7.200 Sekunden? Weil die Grenze ein gleitendes Fenster über das ganze Kontoist, nicht ein Topf je Workflow. Beim Bauen lief ein Lauf genau deshalb auf die Grenze, die Probeläufe der Stunde davor zählten mit. Die Fehlermeldung war unmissverständlich:
Rate limit reached for model `whisper-large-v3-turbo` … on seconds of audio
per hour (ASPH): Limit 7200, Used 5110, Requested 2100. Please try again in 5s.
Ulf: „Warum reizen wir die 7.200 nicht aus? Es sind doch 7.200 erlaubt.“
Tanja: „Weil der Zähler nicht bei deinem Workflow anfängt. Er zählt alles, was dein Konto in der letzten Stunde verbraucht hat, auch deine eigenen Probeläufe von vorhin. Wenn du die Grenze voll ausreizt, scheitert der planmäßige Lauf an dem, was du selbst kurz vorher getestet hast.“
Mit 3.600 Sekunden je Durchgang bleibt Luft: siebenmal 60 Minuten sind 420 Minuten die Woche gegenüber 302 Minuten Bedarf.
Audio klein holen (HTTP Request):
- URL:
=http://ytdlp:8080/audio?kbps=32&url={{ encodeURIComponent($json.audio) }} - Response Format: File, Output Property:
data - Timeout:
900000(15 Minuten) - On Error: Continue (using regular output)
Groq: Folgen abtippen (HTTP Request):
POST https://api.groq.com/openai/v1/audio/transcriptions
Authentifizierung: vordefinierter Typ groqApi
Body-Typ: multipart/form-data
file = Binärfeld "data" aus dem /audio-Knoten
model = whisper-large-v3-turbo
language = de
response_format = json
temperature = 0
Zusätzlich Retry on Fail einschalten, drei Versuche mit fünf Sekunden Abstand. Groq schlägt in seiner Fehlermeldung selbst „try again in 5s“ vor.
Zwei Einstellungen sind kein Zierrat. language = de erspart der Erkennung das Raten. temperature = 0 verhindert, dass sie ins Fabulieren gerät. Das ist bei automatischer Spracherkennung die häufigste Fehlerquelle.
Und eine Bauregel, die man teuer lernen kann: Nimm einen HTTP-Request-Knoten, nicht den fertigen OpenAI-Baustein mit umgebogener Basis-Adresse. Dessen neuere Fassung ignoriert eine abweichende Adresse und läuft zur Laufzeit auf Fehler 404, während die Zugangsprüfung fröhlich durchgeht.
Bernd: „Es gibt doch einen fertigen OpenAI-Knoten. Da biegst du einfach die Basis-Adresse auf Groq um, fertig.“
Tanja: „Das haben wir probiert. Die Zugangsprüfung geht durch, alles grün. Zur Laufzeit kommt Fehler 404, weil die neuere Fassung des Knotens die abweichende Adresse ignoriert. Nimm den HTTP-Request-Knoten. Der macht genau das, was du hinschreibst.“
Folgen ordnen (Code). Hier steckt die wichtigste Lehre des ganzen Projekts.Folgen ordnen, vollständiger Code zum Kopieren (62 Zeilen)
// Ordnet die Groq-Antworten den Folgen zu und entscheidet ueber den Status.
// Die Zuordnung laeuft ueber die Reihenfolge: beide HTTP-Knoten stehen auf
// "weiterlaufen" und geben je Eingang genau einen Ausgang zurueck.
// Stimmen die Anzahlen nicht ueberein, wird nichts geschrieben - lieber eine
// Folge spaeter nachholen als das Transkript in die falsche Zeile schreiben.
const quelle = $('Kontingent beachten').all();
const eingang = $input.all();
if (quelle.length !== eingang.length) {
return [{ json: {
_abbruch: 'Anzahlen passen nicht zusammen',
erwartet: quelle.length,
bekommen: eingang.length,
}}];
}
// Groq verpackt den Grund verschachtelt. Ohne diese Funktion steht im Protokoll
// nur "[object Object]" - das hilft bei der Fehlersuche kein Stueck weiter.
function grundFinden(antwort) {
const f = antwort && antwort.error;
if (!f) return 'kein Text erhalten';
if (typeof f === 'string') return f;
const teile = [f.message, f.description].filter(Boolean);
return teile.length ? teile.join(' | ') : JSON.stringify(f);
}
// Wichtig: Ein voruebergehender Fehler darf die Folge NICHT auf OHNE_TRANSKRIPT
// setzen - sonst holt die Abfrage sie nie wieder, weil die auf GESAMMELT filtert.
// Voruebergehende Fehler werden deshalb uebersprungen: die Zeile bleibt unberuehrt
// auf GESAMMELT und ist beim naechsten Lauf wieder dran.
// Die Bot-Pruefung steht hier ausdruecklich mit drin, obwohl sie an dieser Stelle
// nicht auftreten kann - Groq prueft keine Bots. Sie steht in BEIDEN Zweigen, damit
// die Listen nicht wieder auseinanderlaufen. Genau das war der Fehler vom 26.08.
const VORUEBERGEHEND = /rate limit|too many requests|429|timeout|zeitlimit|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|socket hang up|503|502|504|not a bot|sign in to confirm you.?re not/i;
const zeilen = [];
const uebersprungen = [];
for (let i = 0; i < eingang.length; i++) {
const folge = quelle[i].json || {};
const antwort = eingang[i].json || {};
const text = typeof antwort.text === 'string' ? antwort.text.trim() : '';
if (!folge.id) continue;
if (!text) {
const grund = grundFinden(antwort);
if (VORUEBERGEHEND.test(grund)) {
uebersprungen.push({ id: folge.id, quelle: folge.source || null, grund: grund.slice(0, 200) });
continue;
}
}
zeilen.push({ json: {
id: folge.id,
content: text,
status: text ? 'TRANSKRIBIERT' : 'OHNE_TRANSKRIPT',
woerter: text ? text.split(/\s+/).filter(Boolean).length : 0,
quelle: folge.source || null,
fehler: text ? null : grundFinden(antwort).slice(0, 300),
}});
}
if (zeilen.length && uebersprungen.length) {
zeilen[0].json._spaeter = uebersprungen;
}
return zeilen;
Was hier passiert und warum es zählt: Eine Folge, die an der Stundengrenze scheitert, bekäme sonst den Status OHNE_TRANSKRIPT. Die Abfrage Offene Folgen holen sucht aber nach GESAMMELT, die Folge wäre also nie wieder geholt worden. Ein Zeitfehler von fünf Sekunden hätte eine Deutschlandfunk-Folge dauerhaft aus der Zusammenfassung entfernt, ohne dass irgendwo etwas rot geworden wäre.
Ulf: „Ein Fehler, der fünf Sekunden dauert, und die Folge ist für immer weg?“
Tanja: „Für immer. Weil der Status auf OHNE_TRANSKRIPT steht und die Abfrage nach GESAMMELT sucht. Die Folge liegt in der Datenbank, aber sie wird nie wieder abgeholt. Deshalb unterscheidet dieser Knoten vorübergehende Fehler von dauerhaften und lässt die Zeile bei vorübergehenden unangetastet.“
Die Lehre, die beim Bauen gleich mehrfach fällig wurde: Schreiben und Lesen gehören zusammen gedacht. Wer einen Statuswert setzt, muss wissen, was die Abfrage sucht, die ihn später wieder einsammeln soll.
Folgen-Transkript speichern: Postgres Update über id, wie bei den Videos.
Erwartetes Ergebnis: Eine 49-Minuten-Folge ergab 9.078 Wörter in 7,8 Sekunden. Zum Vergleich: dieselbe Folge über einen kostenpflichtigen Anbieter brauchte 130,7 Sekunden und kostete 29 US-Cent. Die Wortzahlen wichen um 0,0 % voneinander ab.
Phase 5: Der vierte Strang, die Seiten ohne Feed
Jetzt kommen die Webseiten aus der Tabelle ganz oben. Dieser Strang hängt direkt am Zeitplan, nicht am Insert-Knoten. So kann er nichts an der bestehenden Kette verschieben.
Schritt 5.1: Vorher nachsehen, was die Seite sich wünscht
Bevor eine Zeile Code entsteht: robots.txt der Zielseiten lesen. Das ist die Datei, in der eine Webseite hinterlegt, welche automatischen Abrufe sie sich wünscht. Sie liegt immer unter https://<domain>/robots.txt.
Eine Einordnung, damit hier nichts Falsches hängen bleibt: Das Robots-Exclusion-Protokoll ist weder ein Zugriffsschutz noch eine Erlaubnis. Es erteilt keine Rechte und entzieht keine. Es ist die Stelle, an der ein Betreiber automatischen Abrufen mitteilt, was er sich vorstellt. Wir halten uns daran, weil das die Grundlage dafür ist, dass so etwas überhaupt geduldet wird, nicht weil ein Server uns sonst aussperren würde.
Bernd: „robots.txt ist doch nur eine Empfehlung. Kein Zugriffsschutz. Also kann ich sie ignorieren.“
Tanja: „Der erste Teil stimmt sogar. Sie schützt nichts und erlaubt nichts, sie sagt nur, was sich der Betreiber vorstellt. Der zweite Teil ist dein üblicher Kurzschluss.“
Ulf: „Warum halten wir uns dann daran?“
Tanja: „Weil genau das die Grundlage dafür ist, dass so etwas geduldet wird. Wir lesen die Datei, wir halten die drei Sekunden ein, die KI-Buzzer sich wünscht, und wir geben uns eine ehrliche Kennung. Das kostet zehn Minuten und ist der Unterschied zwischen einem Leser und einem Ärgernis.“
Das Ergebnis für die fünf Seiten:
| Seite | Regel für User-agent: * |
|---|---|
| deeplearning.ai (The Batch) | kein Disallow |
| artificialanalysis.ai | kein Disallow |
| epoch.ai | nur /assets/, /inspect-viewer/ |
| appliedai-institute.de | nur /cache/ |
| kibuzzer.com | einzelne Pfade, Crawl-delay: 3 |
Ein Detail lohnt die Erwähnung: The Batch sperrt ClaudeBot, GPTBot und CCBot vollständig. Das sind Trainings-Crawler, die Inhalte zum Modelltraining einsammeln. Für den allgemeinen Zugriff steht dort keine Sperre. Wer als Leser mit einer eigenen, ehrlichen Kennung abruft, ist nicht gemeint. Setze deshalb:
const UA = 'Mozilla/5.0 (compatible; mein-newsroom/1.0)';
Ulf: „Warum tarne ich mich nicht einfach als normaler Browser? Dann fragt keiner.“
Tanja: „Weil du dann eine Aussage machst, die nicht stimmt. Eine eigene Kennung sagt: Hier holt sich jemand Texte für seine eigene Wochenzusammenfassung. Wenn eine Seite das nicht will, kann sie es sagen. Das ist der ganze Sinn der Sache.“
Schritt 5.2: Webseiten einsammeln
Der Knoten liest Sitemaps, also Inhaltsverzeichnisse, die viele Seiten unter /sitemap.xml bereitstellen, und filtert auf das Feld <lastmod> der letzten 14 Tage. Für Epoch AI gibt es einen Sonderweg, weil dessen Sitemap kein lastmod führt und damit zur Änderungserkennung unbrauchbar ist: Übersichtsseite /blog laden, Beitragslinks per Muster finden, zu jedem Link das nächstgelegene Datum im Umkreis von 1.500 Zeichen nehmen.Webseiten einsammeln, vollständiger Code zum Kopieren (131 Zeilen)
// A20 Stufe 1b: Seiten OHNE brauchbaren Feed einsammeln (Sitemap bzw. Uebersichtsseite).
// Schreibt nur Adresse, Datum und einen vorlaeufigen Titel - den Text holt
// der Zweig "Offene Seitentexte holen" nach.
const helfer = this.helpers;
const UA = 'Mozilla/5.0 (compatible; mein-newsroom/1.0)';
const TAGE = 14, grenze = Date.now() - TAGE*24*3600*1000;
const hole = async (url) => String(await helfer.httpRequest({
method:'GET', url, headers:{'User-Agent':UA}, timeout:60000 }));
const normalisiere = (roh) => {
try {
const u = new URL(roh);
u.hash = '';
const weg = new Set(['fbclid','gclid','dclid','msclkid','igshid','mc_cid','mc_eid','mkt_tok','yclid','cmpid']);
for (const k of Array.from(u.searchParams.keys())) {
const s = k.toLowerCase();
if (s.startsWith('utm_') || weg.has(s)) u.searchParams.delete(k);
}
u.hostname = u.hostname.replace(/^www\./i,'').toLowerCase();
u.protocol = 'https:';
if (u.pathname === '/') u.pathname = '';
u.search = u.searchParams.toString() ? '?' + u.searchParams.toString() : '';
return u.toString();
} catch (e) { return String(roh); }
};
// Vorlaeufiger Titel aus dem letzten Adressteil - der echte kommt spaeter
// aus og:title oder <h1>, wenn "Seiten abrufen" die Seite geladen hat.
const titelAusAdresse = (adr) => {
try {
const p = new URL(adr).pathname.replace(/\.html?$/i,'').split('/').filter(Boolean).pop() || '';
return p.replace(/[-_]+/g,' ').replace(/\b\w/g, c => c.toUpperCase()).slice(0,200) || adr.slice(0,200);
} catch (e) { return String(adr).slice(0,200); }
};
// Aus einer Sitemap alle <url>-Bloecke ziehen, die zum Muster passen und
// deren <lastmod> innerhalb des Zeitfensters liegt.
const ausSitemap = (xml, passt) => {
const out = []; const re = /<url>([\s\S]*?)<\/url>/g; let m;
while ((m = re.exec(xml)) !== null) {
const blk = m[1];
const loc = (blk.match(/<loc>([\s\S]*?)<\/loc>/)||[])[1];
const lm = (blk.match(/<lastmod>([\s\S]*?)<\/lastmod>/)||[])[1];
if (!loc || !passt(loc.trim())) continue;
const t = lm ? Date.parse(lm) : NaN;
if (isNaN(t) || t < grenze) continue;
out.push({ adresse: loc.trim(), datum: new Date(t).toISOString() });
}
return out;
};
// Je Quelle: Name, Sitemap-Adresse und ein Filter, der echte Beitraege erkennt.
const QUELLEN = [
['The Batch','https://www.deeplearning.ai/sitemap.xml',
a => a.includes('/the-batch/') && !a.includes('/tag/')],
['Artificial Analysis','https://artificialanalysis.ai/sitemap.xml',
a => a.includes('/articles/')],
['AppliedAI Institute','https://www.appliedai-institute.de/sitemap.xml',
a => !a.includes('/en/') && (a.includes('/publikationen/') || a.includes('/ressourcen/'))],
['KI-Buzzer','https://kibuzzer.com/sitemap.xml',
a => a.includes('/de/blog/')],
];
const protokoll = [];
const gefunden = [];
for (const [name, sitemap, passt] of QUELLEN) {
try {
const xml = await hole(sitemap);
const treffer = ausSitemap(xml, passt);
treffer.forEach(t => gefunden.push({ quelle: name, adresse: t.adresse, datum: t.datum }));
protokoll.push(name + ': ok, ' + treffer.length + ' in ' + TAGE + ' Tagen');
} catch (e) {
protokoll.push(name + ': FEHLER ' + String(e && e.message || e).slice(0,110));
}
}
// Epoch AI: Sitemap ohne lastmod, also unbrauchbar zur Aenderungserkennung.
// Stattdessen die Uebersichtsseite lesen und das Datum neben dem Link nehmen.
try {
const html = await hole('https://epoch.ai/blog');
const re = /\/(?:gradient-updates|publications|data-insights)\/[a-z0-9-]{4,}/g;
let m, roh = [];
while ((m = re.exec(html)) !== null) roh.push({ pfad: m[0], pos: m.index });
const gesehen = new Set();
let n = 0;
for (const r of roh) {
if (gesehen.has(r.pfad)) continue;
const slug = r.pfad.split('/').pop();
// Bruchstuecke wie "/gradient-updates/2026" sind keine Beitraege:
// ein echter Slug enthaelt Buchstaben und mindestens einen Bindestrich.
if (!/[a-z]{3}/.test(slug) || !slug.includes('-')) continue;
gesehen.add(r.pfad);
// naechstes Datum im Umkreis von 1500 Zeichen nach dem Link
const fenster = html.slice(r.pos, r.pos + 1500);
const dm = fenster.match(/(20\d\d-\d\d-\d\d)/);
if (!dm) continue;
const t = Date.parse(dm[1] + 'T00:00:00Z');
if (isNaN(t) || t < grenze) continue;
gefunden.push({ quelle: 'Epoch AI', adresse: 'https://epoch.ai' + r.pfad, datum: new Date(t).toISOString() });
n++;
}
protokoll.push('Epoch AI: ok, ' + gesehen.size + ' Links, ' + n + ' in ' + TAGE + ' Tagen');
} catch (e) {
protokoll.push('Epoch AI: FEHLER ' + String(e && e.message || e).slice(0,110));
}
// Innerhalb des Laufs entdoppeln
const raus = [];
const bekannt = new Set();
for (const g of gefunden) {
const norm = normalisiere(g.adresse);
if (bekannt.has(norm)) continue;
bekannt.add(norm);
raus.push({
title: titelAusAdresse(g.adresse),
url: g.adresse,
url_normalized: norm,
source: g.quelle,
source_type: 'blog',
status: 'GESAMMELT',
description: '',
published_date: g.datum,
dauer_sekunden: null,
});
}
if (!raus.length) return [{ json: { _protokoll: protokoll, _gefunden: 0 } }];
raus[0]._protokoll = protokoll;
raus[0]._gefunden = raus.length;
return raus.map(json => ({ json }));
Anzupassen ist die Liste QUELLEN. Je Eintrag drei Dinge: der Name, unter dem die Quelle in der Datenbank steht, die Adresse der Sitemap, und ein Filter, der echte Beiträge von Übersichts- und Schlagwortseiten trennt.
Eine Zeile darin ist teuer erkauft:
// Bruchstuecke wie "/gradient-updates/2026" sind keine Beitraege:
// ein echter Slug enthaelt Buchstaben und mindestens einen Bindestrich.
if (!/[a-z]{3}/.test(slug) || !slug.includes('-')) continue;
Ohne sie fand das Adressmuster ein Stück Adresse aus dem Seitenquelltext, hielt es für einen Beitrag, rief ihn ab und bekam einen 404er.
Ulf: „Was ist ein Slug?“
Tanja: „Das lesbare Endstück einer Adresse. Bei epoch.ai/blog/gradient-updates-2026 ist gradient-updates-2026 der Slug. Wir verlangen, dass er Buchstaben und einen Bindestrich enthält. Bruchstücke wie /2026 fallen damit raus, und genau die hatten uns die 404er beschert.“
Geschrieben wird mit source_type = 'blog' und Status GESAMMELT. Die Beiträge landen also im selben Topf wie die Blogs mit Feed.
Schritt 5.3: Offene Seitentexte holen und Seiten abrufen
Die Abfrage holt, was noch keinen brauchbaren Text hat:
SELECT id, url, source, title
FROM ki_artikel
WHERE source IN ('The Batch','Artificial Analysis','AppliedAI Institute',
'KI-Buzzer','Epoch AI','KI-Beratung')
AND url IS NOT NULL AND url <> ''
AND published_date > now() - interval '30 days'
AND (content IS NULL OR (length(content) < 1200 AND content NOT LIKE '[ohne Text]%'))
ORDER BY published_date DESC NULLS LAST
LIMIT 12
Die 30-Tage-Grenze ist eine Sparmaßnahme mit Geschichte: Der Altbestand einer Quelle reichte Monate zurück, und weil A21 ohnehin nur sieben Tage betrachtet, hätten die alten Volltexte 81 Abrufe gekostet und nie eine Rolle gespielt. Das LIMIT 12 ist bewusst zurückhaltend, die Seiten sollen nicht mit Anfragen überzogen werden.
Bernd: „LIMIT 12? Nimm alles auf einmal, dann bist du in einem Durchgang durch.“
Tanja: „Und schickst einer fremden Seite achtzig Anfragen in einer Minute. Wir laufen jede Nacht. Zwölf am Tag sind in einer Woche vierundachtzig, und niemand merkt etwas davon.“
Seiten abrufen holt je Zeile die Seite, schneidet <article> oder <main> heraus, wirft Skripte und Stilangaben weg, macht Text daraus und liest den Titel aus og:title, <h1> oder <title>.Seiten abrufen, vollständiger Code zum Kopieren (93 Zeilen)
// A20 Stufe 2c: Volltext von Webseiten nachladen, die keinen brauchbaren Feed haben.
// Laeuft weiter, wenn eine Seite nicht antwortet. Unterscheidet voruebergehend
// von dauerhaft - dieselbe Regel wie bei den Podcastfolgen.
const helfer = this.helpers;
const UA = 'Mozilla/5.0 (compatible; mein-newsroom/1.0)';
const VORUEBERGEHEND = /rate limit|too many requests|429|timeout|zeitlimit|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|503|502|504/i;
const alle = $input.all().filter(i => i.json && i.json.id);
// WICHTIG: leere Liste zurueckgeben, KEIN Platzhalter ohne id.
// Der Postgres-Update dahinter gleicht ueber id ab und wuerde sonst
// mit "The row you are trying to update doesn't exist" den ganzen Lauf kippen.
if (!alle.length) return [];
// Beide Formen kennen - dezimal (') und hexadezimal (').
// & kommt ZULETZT, sonst werden gerade entschluesselte Sequenzen wieder zerlegt.
const entschluessle = (s) => String(s)
.replace(/&#x([0-9a-fA-F]+);/g, (m, hx) => String.fromCharCode(parseInt(hx, 16)))
.replace(/&#(\d+);/g, (m, d) => String.fromCharCode(Number(d)))
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
const textAus = (html) => {
let s = String(html);
s = s.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ');
// Wenn es <article> oder <main> gibt, nur das nehmen - sonst die ganze Seite.
const haupt = s.match(/<article[\s\S]*?<\/article>/i) || s.match(/<main[\s\S]*?<\/main>/i);
const teil = haupt ? haupt[0] : s;
return entschluessle(teil.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|section)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
).replace(/[ \t]+/g, ' ').replace(/\n\s*\n\s*\n+/g, '\n\n').trim();
};
const titelAus = (html) => {
const og = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']{3,300})["']/i);
if (og) return entschluessle(og[1]).trim();
const h1 = html.match(/<h1[^>]*>([\s\S]{3,300}?)<\/h1>/i);
if (h1) return entschluessle(h1[1].replace(/<[^>]+>/g,'')).trim();
const t = html.match(/<title[^>]*>([\s\S]{3,300}?)<\/title>/i);
return t ? t[1].replace(/\s*[|\-–]\s*[^|\-–]{2,40}$/,'').trim() : '';
};
// robots.txt einzelner Quellen setzt "Crawl-delay: 3". Wir halten drei Sekunden
// Abstand je Domain ein. Bei LIMIT 12 kostet das nur wenige Sekunden pro Lauf.
const PAUSE_MS = 3000;
const warte = (ms) => new Promise(r => setTimeout(r, ms));
const zuletzt = {};
const bremse = async (adresse) => {
let host = String(adresse);
try { host = new URL(adresse).hostname; } catch (e) { /* Adresse unlesbar, egal */ }
const rest = PAUSE_MS - (Date.now() - (zuletzt[host] || 0));
if (rest > 0) await warte(rest);
zuletzt[host] = Date.now();
};
const raus = [], protokoll = [], spaeter = [];
for (const item of alle) {
const z = item.json;
await bremse(z.url);
try {
const html = String(await helfer.httpRequest({ method:'GET', url:z.url,
headers:{'User-Agent':UA}, timeout:30000 }));
const text = textAus(html);
const titel = titelAus(html);
// Die Schwelle muss zur Auswahl passen: "Offene Seitentexte holen" holt alles
// unter 1.200 Zeichen erneut. Waere hier 400 markiert, wuerde alles dazwischen
// bei jedem Lauf neu geholt - eine stille Endlosschleife.
if (text.length < 1200) {
raus.push({ json: { id: z.id, content: '[ohne Text] ' + (text||'').slice(0,2000), title: z.title } });
protokoll.push(z.source + ' ' + z.id + ': nur ' + text.length + ' Zeichen - als ohne Text vermerkt');
} else {
raus.push({ json: { id: z.id, content: text.slice(0, 200000), title: titel || z.title } });
protokoll.push(z.source + ' ' + z.id + ': ' + text.length + ' Zeichen');
}
} catch (e) {
const m = String(e && e.message || e);
if (VORUEBERGEHEND.test(m)) {
spaeter.push(z.id);
protokoll.push(z.source + ' ' + z.id + ': voruebergehend (' + m.slice(0,60) + ') - unangetastet');
} else {
raus.push({ json: { id: z.id, content: '[ohne Text] ' + m.slice(0,150), title: z.title } });
protokoll.push(z.source + ' ' + z.id + ': dauerhaft (' + m.slice(0,60) + ')');
}
}
}
if (!raus.length) return [];
raus[0].json._protokoll = protokoll;
raus[0].json._spaeter = spaeter;
raus[0].json._geholt = raus.length;
return raus;
Vier Stellen darin sind Erfahrung, nicht Geschmack:
Der Leerfall gibt [] zurück, kein Platzhalterobjekt. Dahinter hängt ein Postgres-Update, der über id abgleicht. Ein Objekt ohne id lässt ihn mit „The row you are trying to update doesn’t exist“ scheitern, und damit den ganzen Lauf. Der Fehler tritt ausgerechnet im Normalfall auf: an einem Tag, an dem es nichts zu tun gibt. Genau das ist beim allerersten planmäßigen Lauf passiert.
Bernd: „Bei leerem Eingang gebe ich immer ein leeres Objekt zurück. Damit hinten etwas ankommt und die Kette nicht abreißt.“
Tanja: „Und genau daran ist der erste echte Lauf gescheitert. Ein Objekt ohne id geht in einen Update-Knoten. Der sucht die Zeile mit dieser id, findet keine und reißt den ganzen Lauf ab. Bei leerem Eingang gibst du [] zurück. Dann macht der Zweig nichts, und nichts ist hier genau richtig.“
Ulf: „Und das passiert an einem Tag, an dem alles normal ist.“
Tanja: „An einem Tag, an dem es schlicht nichts zu tun gibt. Deshalb testet man diesen Fall ausdrücklich. Einmal mit Material, einmal ohne.“
Der Entschlüsseler kennt beide Formen von HTML-Sonderzeichen, dezimal (') und hexadezimal ('). Vorher stand im Ergebnis Minimax H3's Weights Are Free, weil nur die Dezimalform bekannt war. Und & kommt zuletzt, sonst zerlegst du gerade entschlüsselte Sequenzen wieder.
Die Schwelle von 1.200 Zeichen muss zur Auswahl passen. Die Abfrage davor holt alles unter 1.200 Zeichen erneut. Wäre hier 400 markiert, würde alles dazwischen bei jedem Lauf neu geholt, eine stille Endlosschleife.
Drei Sekunden Abstand je Domain. kibuzzer.com setzt in seiner robots.txt ein Crawl-delay: 3. Wer die Datei zitiert, sollte sie auch einhalten. Die kleine Bremse merkt sich je Hostname den Zeitpunkt des letzten Abrufs und wartet, falls seitdem weniger als drei Sekunden vergangen sind. Bei LIMIT 12 über fünf Domains kostet das wenige Sekunden je Lauf. Rechne damit, dass dein Lauf dadurch etwas länger dauert als der in Bild 16 gezeigte.
Schritt 5.4: Seitentext speichern
Klickfolge: Postgres-Knoten anhängen → Operation Update → Tabelle ki_artikel → Map Each Column Manually → Abgleich über id.
| Spalte | Wert |
|---|---|
id | ={{ $json.id }} |
content | ={{ $json.content }} |
title | ={{ $json.title }} |
Unter Settings: On Error → Continue (using regular output).
Prüfe deinen Aufbau zweimal: einmal mit Material, einmal, wenn nichts offen ist. Beide Läufe müssen grün bleiben. Der zweite Fall ist der, an dem die Anlage beim ersten echten Lauf gescheitert ist.

Erwartetes Ergebnis: Im Beispielaufbau lieferte der erste vollständige Lauf 17 Beiträge (The Batch 6, Artificial Analysis 5, KI-Buzzer 1, Epoch AI 5, AppliedAI 0, dort war 14 Tage nichts erschienen), davon 12 Texte zwischen 5.013 und 40.676 Zeichen.
Phase 6: Verdichten, eine Million Zeichen auf 37.000
Jetzt liegt viel Text in der Datenbank. Nach ein paar Tagen waren es 1.176.577 Zeichen Rohmaterial, grob 300.000 bis 400.000 Token.
Das in einen einzigen Aufruf zu stecken, ist technisch nicht mehr ausgeschlossen, die Kontextfenster sind inzwischen groß genug. Sinnvoll ist es trotzdem nicht. Du bezahlst dann jede Woche erneut für dasselbe Rohmaterial, und je mehr gleichzeitig hineingeht, desto schlechter wird die Auswahl: Das Wenige, auf das es ankommt, geht im Vielen unter.
Also wird jeder Fund einzeln auf drei bis fünf Sätze verdichtet. Aus 1,18 Millionen Zeichen wurden 37.012, ein Verhältnis von 1 zu 32, und das passt in einen einzigen Aufruf.
Ulf: „Warum nicht alles auf einmal reingeben? Die neuen Modelle können doch riesige Texte.“
Tanja: „Sie können viel aufnehmen, ja. Nur wird die Auswahl schlechter, je mehr gleichzeitig hineingeht. Wir verdichten jeden Fund einzeln, wenn er ankommt. Am Sonntag liegen dann 37.000 Zeichen bereit statt 1,18 Millionen, und die Arbeit ist längst getan.“
Schritt 6.1: Die Modellwahl, und warum sie nicht Groq ist
Naheliegend wäre, alles über Groq zu machen, denn man hat den Zugang ja schon. Es geht nicht: Groqs kostenloser Tarif erlaubt bei den einschlägigen Chat-Modellen 8.000 Token je Minute (ein Token ist eine Texteinheit, grob ein halbes Wort). Ein durchschnittliches YouTube-Transkript hat rund 6.000 Token und passt knapp. Die 56.000 Zeichen einer langen Podcastfolge passen nicht.
Bernd: „Groq hast du doch schon. Alles über einen Anbieter, das ist sauber.“
Tanja: „Rechne es nach. Kostenloser Tarif, 8.000 Token je Minute bei den Chat-Modellen. Ein YouTube-Transkript hat rund 6.000, das geht knapp. Eine lange Podcastfolge hat 56.000 Zeichen und geht nicht.“
Ulf: „Dann fehlen genau die Folgen, die am meisten hergeben.“
Tanja: „Genau. Eine Kette, die bei den längsten und gehaltvollsten Quellen versagt, ist keine Kette.“
Eine Kette, die bei den längsten und gehaltvollsten Quellen versagt, ist keine Kette. Deshalb hier gpt-4o-mini über einen OpenAI-Zugang, wo diese Minutengrenze nicht besteht.
Die Kosten: Der komplette Rückstand von 295.000 Token kostete etwa 4 Cent, einmalig. Im Dauerbetrieb liegt es bei wenigen Cent im Monat.
Schritt 6.2: Offene Verdichtungen holen
SELECT id, title, source, source_type,
left(coalesce(content, ''), 60000) AS text,
length(content) AS zeichen
FROM ki_artikel
WHERE source_type IN ('youtube','podcast','blog')
AND content IS NOT NULL AND content <> ''
AND content NOT LIKE '[ohne Text]%'
AND (zusammenfassung IS NULL OR zusammenfassung = '')
ORDER BY published_date DESC NULLS LAST
LIMIT 12
Die Zeile content NOT LIKE '[ohne Text]%' ist wichtig: Sonst fasst das Modell brav eine Fehlermeldung zusammen.
Schritt 6.3: Der Prompt
Ein HTTP-Request-Knoten gegen https://api.openai.com/v1/chat/completions, Methode POST, Modell gpt-4o-mini, temperature: 0.2, und vor allem response_format: { type: "json_object" }.
Der Zugang wird hier gesetzt, genauso wie beim Groq-Knoten in Phase 4:
Klickfolge: im Knoten unter Authentication → Predefined Credential Type → Credential Type: OpenAI API → bei Credential for OpenAI API deinen OpenAI-Zugang auswählen.
Zusätzlich unter Settings: On Error → Continue (using regular output) und Retry on Fail an.
Der Systemprompt, hier zuerst in lesbarer Form. Der exakte Ausdruck steht darunter:
Du verdichtest Rohmaterial für eine wöchentliche Zusammenfassung. Du bekommst Titel, Quelle und den vollständigen Text eines Beitrags. Bei Videos und Podcasts ist das ein maschinell erzeugtes Transkript.
Liefere ein JSON-Objekt mit genau zwei Feldern:
1.
zusammenfassung: deutscher Fließtext, keine Aufzählung, keine Überschrift. Drei bis fünf Sätze. Bei Podcastfolgen mit mehreren Themen bis zu acht Sätze. Nenne dann jedes Thema in einem eigenen Satz.
Schreibe, WAS gesagt wurde und WORUM es geht, nicht dass es ein Video oder ein Podcast ist. Beginne nicht mit „In diesem Video“ oder „Die Folge behandelt“. Nenne konkrete Namen, Produkte und Zahlen, wenn sie im Text vorkommen.
Erfinde nichts. Steht etwas nicht im Text, kommt es nicht in die Zusammenfassung.
Zitiere NICHT wörtlich. Die Transkripte sind maschinell erzeugt und verhaspeln sich bei Eigennamen und Fachbegriffen.
Ist der Text unbrauchbar (leer, abgebrochen, kein erkennbarer Inhalt), schreibe genau:UNBRAUCHBAR2.
subkategorie: genau EINER dieser zwölf Werte, zeichengenau abgeschrieben:Anwendungsfälle & Best Practices·Prompting·Lernen & Skill-Aufbau·Tools & Produkt-Updates·Automatisierung & Agenten·KI-Kreativität·News & Entwicklungen·Modelle & Open Source·Hardware & Lokale KI·Sicherheit & Privacy·Ethik, Recht & Politik·Kein KI-Fokus
Denk Dir keinen neuen Wert aus und ändere die Schreibweise nicht. Passt nichts, nimmKein KI-Fokus.
Das Zitierverbot ist keine Zimperlichkeit. Automatische Spracherkennung verhaspelt sich bei Eigennamen. Ein wörtliches Zitat aus einem Transkript ist ein Zitat, das der Sprecher so möglicherweise nie gesagt hat.
Bernd: „Ein wörtliches Zitat wirkt doch viel glaubwürdiger. Das würde ich zulassen.“
Tanja: „Ein wörtliches Zitat aus einem maschinellen Transkript ist ein Satz, den der Sprecher so vielleicht nie gesagt hat. Die Erkennung verhaspelt sich bei Eigennamen und Fachbegriffen. Du zitierst dann jemanden falsch, unter seinem Namen. Deshalb: sinngemäß, nie wörtlich.“Verdichten, der exakte Ausdruck für das Feld Body
Eine erprobte Liste von zwölf Subkategorien für den KI-Bereich steht im Newsroom-Artikel in Abschnitt 3.3.4, sie lässt sich direkt übernehmen oder auf dein Thema anpassen.
Schritt 6.4: Verdichtung ordnen zieht die Kategorie hart
Sprachmodelle schreiben gelegentlich frei Text, auch wenn man ihnen eine Liste gibt. In der Datenbank standen am Ende 32 verschiedene Subkategorien statt zwölf: „Automation & Agenten“ statt „Automatisierung & Agenten“, fünf Schreibweisen von „Kein KI-Fokus“, dazu Ausreißer wie „Schnäppchen“.
Ulf: „Im Prompt steht doch klar, dass es genau zwölf Werte gibt.“
Tanja: „Steht da. Trotzdem kamen zweiunddreißig heraus. Ein Prompt ist eine Bitte, keine Garantie. Was zeichengenau stimmen muss, prüfst du hinterher im Code nach.“
Der Befund stammt aus einer gewachsenen Bestandstabelle mit gut 227.000 Zeilen, in die neben dieser Anlage noch ein zweites System schreibt. Betroffen waren dort 45 Zeilen, also sehr selten, und bei einem frischen Aufbau nach dieser Anleitung entsprechend weniger.
Selten heißt aber nicht harmlos. Jede dieser Zeilen lässt spätere Zuordnungen ins Leere laufen, weil zeichengenau verglichen wird.
Deshalb wird die Antwort deterministisch nachgeschlagen, umlautbereinigt, ohne Sonderzeichen, mit einer Tabelle bekannter Schreibvarianten. Derselbe Knoten liest auch die OpenAI-Antwort aus (sie steckt als JSON-Zeichenkette in choices[0].message.content) und unterscheidet vorübergehende von dauerhaften Fehlern.Verdichtung ordnen, vollständiger Code zum Kopieren (102 Zeilen)
// A20 Stufe 3: Antwort einsortieren und die Subkategorie hart auf die zwoelf
// erlaubten Werte ziehen. Grund: Sprachmodelle schreiben gelegentlich frei Text
// ("Automation & Agenten" statt "Automatisierung & Agenten"). Ein Tippfehler
// laesst spaetere Zuordnungen ins Leere laufen, weil zeichengenau verglichen wird.
const ERLAUBT = [
'Anwendungsfälle & Best Practices', 'Prompting', 'Lernen & Skill-Aufbau',
'Tools & Produkt-Updates', 'Automatisierung & Agenten', 'KI-Kreativität',
'News & Entwicklungen', 'Modelle & Open Source', 'Hardware & Lokale KI',
'Sicherheit & Privacy', 'Ethik, Recht & Politik', 'Kein KI-Fokus',
];
// Schluessel: kleingeschrieben, umlautbereinigt, ohne Sonderzeichen.
function schluessel(w) {
return String(w || '').toLowerCase()
.replace(/ä/g,'a').replace(/ö/g,'o').replace(/ü/g,'u').replace(/ß/g,'ss')
.replace(/[^a-z0-9]/g,'');
}
const NACHSCHLAG = {};
for (const e of ERLAUBT) NACHSCHLAG[schluessel(e)] = e;
// Bekannte Schreibvarianten, die im Betrieb tatsaechlich aufgetreten sind.
const VARIANTEN = {
'automationagenten': 'Automatisierung & Agenten',
'modelsopensource': 'Modelle & Open Source',
'toolproduktupdates': 'Tools & Produkt-Updates',
'ktoolsproduktupdates': 'Tools & Produkt-Updates',
'nachrichtenentwicklungen': 'News & Entwicklungen',
'kenntnisskillaufbau': 'Lernen & Skill-Aufbau',
'kreativitat': 'KI-Kreativität',
'kikreativitat': 'KI-Kreativität',
'anwendungsfalleebestpractices': 'Anwendungsfälle & Best Practices',
'ethikrechtpolitik': 'Ethik, Recht & Politik',
'keinekifokus': 'Kein KI-Fokus',
'keinkifocus': 'Kein KI-Fokus',
};
// Voruebergehend gegen dauerhaft unterscheiden - dieselbe Regel wie in
// "Folgen ordnen". Ohne sie belegt eine dauerhaft scheiternde Zeile in JEDEM
// Lauf einen der zwoelf Plaetze, fuer immer.
const VORUEBERGEHEND = /rate limit|too many requests|429|timeout|zeitlimit|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|503|502|504/i;
const quelle = $('Offene Verdichtungen holen').all();
const eingang = $input.all();
if (quelle.length !== eingang.length) {
return [{ json: { _abbruch: 'Anzahlen passen nicht zusammen',
erwartet: quelle.length, bekommen: eingang.length } }];
}
const zeilen = [];
const uebersprungen = [];
for (let i = 0; i < eingang.length; i++) {
const b = quelle[i].json || {};
const a = eingang[i].json || {};
if (!b.id) continue;
// Die Antwort von OpenAI steckt als JSON-Zeichenkette in choices[0].message.content.
let inhalt = null;
try {
const roh = a.choices && a.choices[0] && a.choices[0].message && a.choices[0].message.content;
inhalt = roh ? JSON.parse(roh) : null;
} catch (f) { inhalt = null; }
if (!inhalt || !inhalt.zusammenfassung) {
const grund = String((a.error && (a.error.message || a.error)) || 'keine verwertbare Antwort').slice(0, 200);
if (VORUEBERGEHEND.test(grund)) {
// Netzproblem oder Kontingentgrenze: Zeile unangetastet lassen, morgen wieder.
uebersprungen.push({ id: b.id, titel: String(b.title || '').slice(0, 60), grund });
continue;
}
// Dauerhaft: markieren, damit die Zeile aus der Auswahl faellt.
zeilen.push({ json: { id: b.id, zusammenfassung: '[unbrauchbar]',
subkategorie: 'Kein KI-Fokus', status: 'OHNE_INHALT', _grund: grund } });
continue;
}
const text = String(inhalt.zusammenfassung).trim();
if (text === 'UNBRAUCHBAR') {
// Marke statt Leerstring: sonst waehlt die Abfrage die Zeile bei jedem Lauf
// erneut aus (sie filtert auf leere zusammenfassung) und verdichtet sie ewig.
zeilen.push({ json: { id: b.id, zusammenfassung: '[unbrauchbar]',
subkategorie: 'Kein KI-Fokus', status: 'OHNE_INHALT' } });
continue;
}
const k = schluessel(inhalt.subkategorie);
const kat = NACHSCHLAG[k] || VARIANTEN[k] || 'Kein KI-Fokus';
zeilen.push({ json: {
id: b.id,
zusammenfassung: text,
subkategorie: kat,
status: 'VERDICHTET',
_titel: String(b.title || '').slice(0, 70),
_quelle: b.source || null,
_zeichen_vorher: b.zeichen,
_zeichen_nachher: text.length,
_kategorie_roh: inhalt.subkategorie === kat ? null : inhalt.subkategorie,
}});
}
if (zeilen.length && uebersprungen.length) zeilen[0].json._spaeter = uebersprungen;
return zeilen;
Zwei Details mit Folgen. Antwortet das Modell UNBRAUCHBAR, schreibt der Knoten die Marke [unbrauchbar] in die Spalte, aber keinen Leerstring. Die Abfrage filtert auf leere zusammenfassung; ein Leerstring würde die Zeile bei jedem Lauf erneut auswählen, für immer. Und ein Rate-Limit lässt die Zeile unangetastet, damit sie morgen wieder drankommt.
Ulf: „Warum ist ein Leerstring schlimmer als ein Wort?“
Tanja: „Weil die Abfrage nach leeren Zusammenfassungen sucht. Schreibst du einen Leerstring hinein, ist die Zeile weiterhin leer, wird morgen wieder ausgewählt, wieder verdichtet, wieder als unbrauchbar erkannt. Jeden Tag, für immer. Die Marke [unbrauchbar] ist die Notiz: Hier war jemand dran, das Ergebnis war nichts.“
Schritt 6.5: Verdichtung speichern
Klickfolge: Postgres-Knoten → Operation Update → Tabelle ki_artikel → Map Each Column Manually → Abgleich über id. Unter Settings: On Error → Continue (using regular output).
| Spalte | Wert |
|---|---|
id | ={{ $json.id }} |
zusammenfassung | ={{ $json.zusammenfassung }} |
subkategorie | ={{ $json.subkategorie }} |
status | ={{ $json.status }} |
Erwartetes Ergebnis: 49 Beiträge verdichtet, 755 Zeichen je Zusammenfassung im Schnitt, 12 Beiträge in 12 Sekunden.

Eine Stichprobe zur Qualität, damit klar ist, was da herauskommt:
„Die Zukunft der menschlichen Synchronstimmen steht auf der Kippe, da Streaming-Dienste wie Netflix Klauseln in Verträgen einführen, die es ihnen erlauben, die Stimmen von Synchronsprechern ohne deren Zustimmung für KI-Trainings zu nutzen. …“
Konkrete Namen, keine Floskel, kein „In dieser Folge“.
Damit ist A20 fertig. Vergiss den letzten Schritt nicht. Dazu kommt Phase 8.
Phase 7: A21, die Zusammenfassung selbst
Neuer Workflow, A21 Wochenzusammenfassung schreiben, Schedule Trigger auf Weeks, Sunday, 06:00.
Schritt 7.1: Wochenmaterial holen
SELECT source_type, source, title, url,
zusammenfassung AS text, subkategorie, published_date
FROM ki_artikel
WHERE published_date > now() - interval '7 days'
AND zusammenfassung IS NOT NULL
AND zusammenfassung <> ''
AND zusammenfassung <> '[unbrauchbar]'
ORDER BY published_date DESC
Ausgewählt wird über die Spalte zusammenfassung, nicht über den Status. Das ist robuster: Es holt genau das, was tatsächlich verdichtet vorliegt, egal welchen Weg die Zeile genommen hat.
Schritt 7.2: Material aufbereiten
Ein Code-Knoten baut aus den Datenbankzeilen einen lesbaren Textblock und gibt jeder Quelle eine Kennung K1…Kn. Über diese Kennungen weist der Planungsschritt gleich Quellen den Blöcken zu, und Bloecke aufteilen findet damit später den Text wieder. Derselbe Knoten rechnet Kalenderwoche und Zeitraum aus, ohne Zusatzbibliothek.
Ulf: „Wozu die Kennungen? Die Titel stehen doch da.“
Tanja: „Titel sind lang, sie wiederholen sich, und ein Modell schreibt sie beim Abschreiben leicht anders. K17 ist kurz und eindeutig. Der Planungsschritt sagt: Block drei besteht aus K3, K17 und K24. Und der nächste Knoten findet damit den zugehörigen Text wieder, ohne raten zu müssen.“Material aufbereiten, vollständiger Code zum Kopieren (49 Zeilen)
Beachte, dass source_type je Quelle mitgegeben wird. Das Modell soll wissen, ob eine Aussage aus einem Podcast, einem Video oder von einer Webseite stammt. Genau daran hängt, ob es unterschiedliche Bewertungen gegeneinanderstellen kann.
Schritt 7.3: Der Umbau, der den Unterschied macht
Hier lohnt eine kurze Geschichte, weil sie einem einen Nachmittag erspart.
Der erste Aufbau war naheliegend: ein Knoten, ein Aufruf, „schreib mir eine Zusammenfassung von rund 3.500 Wörtern“. Das Ergebnis war ordentlich: sechs Themenblöcke, 23 Absätze, 20 Quellenlinks, keine Aufzählungen. Nur eben 1.507 Wörter statt 3.500. Rund 244 Wörter je Block.
Nicht zu wenig Themen. Zu wenig Ausführung je Thema.

Bernd: „Dann schreib eben ‚mindestens 3.500 Wörter‘ in den Prompt. In Großbuchstaben. Und ein paar Ausrufezeichen.“
Tanja: „Haben wir. Es kamen 1.507 Wörter. Modelle schätzen ihre eigene Länge schlecht, das ist keine Frage der Betonung.“
Ulf: „Und was hilft dann?“
Tanja: „Die Aufgabe kleiner machen. Erst planen, wie viele Blöcke es gibt. Dann jeden Block einzeln schreiben lassen, mit einer Vorgabe von 550 bis 700 Wörtern für diesen einen Block. Sechs kleine Aufgaben statt einer großen.“
Eine Längenvorgabe im Prompt reicht nachweislich nicht. Modelle schätzen ihre eigene Länge schlecht.
Die Lösung: blockweise schreiben. Erst ein Aufruf, der die Blöcke plant. Dann ein Aufruf je Block. Dann Vorspann und Schluss. Dann zusammensetzen.
… → Bloecke planen → Bloecke aufteilen → Block schreiben (je Block ein Aufruf)
→ Bloecke sammeln → Rahmen schreiben → Zusammenfassung bauen → Zusammenfassung mailen
Das Ergebnis, gemessen an derselben Woche:
| ein Aufruf | blockweise | |
|---|---|---|
| Themenblöcke | 6 | 6 |
| Wörter | 1.507 | 2.898 |
| Wörter je Block | 244 | rund 450 |
| Absätze | 23 | 32 |
| Laufzeit | 3 Min 9 s | 3 Min 33 s |

Die Bildschirmaufnahmen stammen aus dem ursprünglichen Aufbau, in dem der Workflow noch anders hieß und einen Knoten mehr trug. An der gezeigten Kette ändert das nichts.
Auch die Tiefe stieg sichtbar. Vorher hieß eine Zwischenüberschrift „Kennzeichnungspflicht und unsichtbare Wasserzeichen“; nachher „AI Act in Kraft: Was jetzt wirklich kennzeichnungspflichtig ist“.
Kosten und Laufzeit verdoppeln sich ungefähr. Bei einem Lauf je Woche ist das ohne Bedeutung.
Schritt 7.4: Bloecke planen
HTTP-Request gegen https://api.anthropic.com/v1/messages, Methode POST, Header anthropic-version: 2023-06-01, max_tokens: 4000, temperature: 0.4.
Klickfolge für den Zugang: im Knoten unter Authentication → Predefined Credential Type → Credential Type: Anthropic API → deinen Anthropic-Zugang auswählen. Dazu Retry on Fail an und Timeout 600000.
Der Systemprompt:
Du planst die wöchentliche Zusammenfassung. Du schreibst sie NICHT. Du legst nur fest, aus welchen Themenblöcken sie besteht und welche Quelle wohin gehört.
Das ist kein Pressespiegel. Die Zusammenfassung ordnet ein: sie erkennt, dass mehrere Quellen derselben Woche zum selben Thema gehören, und dass eine Stimme aus einem Podcast eine Sache anders bewertet als eine Fachseite.
Bilde FÜNF ODER SECHS Themenblöcke. Jeder Block soll möglichst Quellen verschiedener Artenthalten: ein Video, ein Podcast, eine Analyse. Ein Block, für den es nur eine einzige dünne Quelle gibt, ist kein Block.
Antworte mit reinem JSON, ohne Codeblockzeichen, ohne Erklärung:
{ "titel": "Überschrift des Ganzen — benennt das Thema der Woche, keine Floskel wie 'Die KI-Woche'", "leitgedanke": "ein Satz: was diese Woche zusammenhält", "bloecke": [ { "ueberschrift": "...", "kernfrage": "...", "quellen": ["K3", "K17", "K24"] } ] }Jede Quelle darf nur in einem Block vorkommen. Quellen ohne Erkenntniswert lässt Du weg.
Ulf: „Warum darf der Planer nicht gleich mitschreiben? Dann wäre man in einem Schritt fertig.“
Tanja: „Weil das zwei verschiedene Aufgaben sind. Planen heißt: Überblick behalten, sortieren, entscheiden, was zusammengehört. Schreiben heißt: in die Tiefe gehen. Wer beides gleichzeitig macht, macht beides halb. Deshalb steht im Prompt in Großbuchstaben, dass er NICHT schreibt.“Bloecke planen, der exakte Ausdruck für das Feld Body
Schritt 7.4b: Bloecke aufteilen
Ein Code-Knoten zerlegt die Planung in einzelne Arbeitsaufträge und hängt jedem sein Material an, plus die Überschriften der anderen Blöcke zur Abgrenzung.Bloecke aufteilen, vollständiger Code zum Kopieren (35 Zeilen)
Schritt 7.5: Block schreiben
Derselbe Aufbau wie Bloecke planen: HTTP-Request gegen https://api.anthropic.com/v1/messages, Zugang über Predefined Credential Type → Anthropic API, Header anthropic-version: 2023-06-01, Retry on Fail an. Nur max_tokens steht hier auf 8000 und temperature auf 0.7.
Der Prompt ist der wichtigste der ganzen Anlage:
Du schreibst EINEN Themenblock der wöchentlichen Zusammenfassung. Nicht das Ganze, sondern nur diesen einen Block.
LÄNGE: 550 bis 700 Wörter. Das ist die wichtigste Vorgabe. Ein Block von 250 Wörtern ist zu kurz und wird zurückgewiesen. Zähle mit.
TIEFE. Darauf kommt es an:
- Sag nicht nur, DASS etwas passiert ist, sondern was genau, mit welcher Begründung und mit welcher Folge.
- Wenn zwei Quellen dieselbe Sache unterschiedlich bewerten, arbeite den Unterschied heraus. Das ist der Kern.
- Nenne konkrete Namen, Produkte, Zahlen und Fristen, wenn sie im Material stehen.
- Erkläre Fachbegriffe beim ersten Vorkommen in einem Nebensatz.
- Schließe mit ein bis zwei Sätzen, warum das über die Woche hinaus zählt.
TON: Erwachsen, konkret, ohne Werbesprache. Keine Ausrufezeichen, kein „revolutionär“, kein „bahnbrechend“. Ganze Absätze, keine Stichpunkte. Vier bis sechs Absätze.
QUELLEN: Nenne die Quelle im Fließtext, wenn eine Aussage von ihr stammt: „Im KI-Update von heise heißt es…“. Verlinke den Quellentitel als HTML-Link. Jede Quelle höchstens einmal verlinken.
VERBOTEN:
- Nichts erfinden. Was nicht im Material steht, kommt nicht in den Text.
- Nicht wörtlich zitieren: die Transkripte sind maschinell erzeugt.
- Keine Zahl, Studie oder Forschungsangabe ohne Urheber im selben Satz.
- Keine Sammelbezeichnungen wie „Studien zeigen“ oder „Forscher haben herausgefunden“.
- Keine Wiederholung dessen, was in anderen Blöcken steht.
FORMAT: Reines HTML, beginnend mit
<h2>, danach nur<p>und<a href>.
Jeder Aufruf bekommt zusätzlich die Überschriften der anderen Blöcke mit, und zwar zur Abgrenzung, damit sich nichts doppelt.
Zwei Dinge zu diesem Prompt, bevor man seine Zahlen für bare Münze nimmt.
Die Vorgabe liegt bewusst über dem Ziel. Sechs Blöcke zu 550 Wörtern wären rechnerisch 3.300, der gemessene Lauf ergab 2.898 bei rund 450 Wörtern je Block. Das ist kein Widerspruch, sondern der Grund für die hohe Vorgabe: Modelle landen verlässlich unterhalb dessen, was man ihnen nennt. Trägst du hier 450 ein, bekommst du 350. Die Zahl im Prompt ist eine Zielmarke, keine Prognose.
Das Zurückweisen ist Rhetorik, nicht Technik. Im Workflow prüft nichts die Länge, und nichts weist etwas zurück. Bloecke sammeln zählt die Wörter zwar mit und legt sie in woerter_bloecke ab, es bricht aber nicht ab. Das ist Absicht: Ein zu kurzer Block ist ärgerlich, ein abgebrochener Sonntagslauf ist schlimmer. Die Wortzahl steht in der Kopftabelle der E-Mail, dort siehst du sie jede Woche und kannst nachsteuern.
Bernd: „Sechs einzelne Aufrufe. Die wissen doch gar nicht voneinander. Da steht am Ende sechsmal dasselbe drin.“
Tanja: „Deshalb bekommt jeder Aufruf die Überschriften der anderen fünf Blöcke mit und die Anweisung, sich davon abzugrenzen. Er sieht nicht ihren Text, aber er weiß, worüber die anderen schreiben. Das reicht.“Block schreiben, der exakte Ausdruck für das Feld Body
Einfügen als Body Content Type: JSON, Modus Expression. Eine einzige Zeile:
={{ JSON.stringify({ model: "claude-sonnet-4-5-20250929", max_tokens: 8000, temperature: 0.7, system: "Du schreibst EINEN Themenblock der woechentlichen Zusammenfassung.\nNicht das Ganze - nur diesen einen Block.\n\nLAENGE: 550 bis 700 Woerter. Das ist die wichtigste Vorgabe. Ein Block von 250 Woertern\nist zu kurz und wird zurueckgewiesen. Zaehle mit.\n\nTIEFE - darauf kommt es an:\n- Sag nicht nur, DASS etwas passiert ist, sondern was genau, mit welcher Begruendung\n und mit welcher Folge.\n- Wenn zwei Quellen dieselbe Sache unterschiedlich bewerten, arbeite den Unterschied\n heraus. Das ist der Kern.\n- Nenne konkrete Namen, Produkte, Zahlen und Fristen, wenn sie im Material stehen.\n- Erklaere Fachbegriffe beim ersten Vorkommen in einem Nebensatz.\n- Schliesse mit ein bis zwei Saetzen, warum das ueber die Woche hinaus zaehlt.\n\nTON: Erwachsen, konkret, ohne Werbesprache. Keine Ausrufezeichen, kein\n'revolutionaer', kein 'bahnbrechend'. Ganze Absaetze, keine Stichpunkte.\nVier bis sechs Absaetze.\n\nQUELLEN: Nenne die Quelle im Fliesstext, wenn eine Aussage von ihr stammt - 'Im\nKI-Update von heise heisst es...'. Verlinke den Quellentitel als HTML-Link auf die\nangegebene Adresse. Jede Quelle hoechstens einmal verlinken.\n\nVERBOTEN:\n- Nichts erfinden. Was nicht im Material steht, kommt nicht in den Text.\n- Nicht woertlich zitieren: die Transkripte sind maschinell erzeugt und verhaspeln\n sich bei Eigennamen. Sinngemaess wiedergeben.\n- Keine Zahl, Studie oder Forschungsangabe ohne Urheber im selben Satz.\n- Keine Sammelbezeichnungen wie 'Studien zeigen' oder 'Forscher haben herausgefunden'.\n- Keine Wiederholung dessen, was in anderen Bloecken steht (Du bekommst deren\n Ueberschriften zur Abgrenzung).\n\nFORMAT: Reines HTML, beginnend mit <h2>Deine Ueberschrift</h2>, danach nur <p> und\n<a href>. Keine Codeblockzeichen, kein Markdown, keine Erklaerung davor oder danach.", messages: [ { role: "user", content: "Titel der Zusammenfassung: " + $json.titel + "\nLeitgedanke der Woche: " + $json.leitgedanke + "\n\nDein Block " + $json.nummer + " von " + $json.von + ": " + $json.ueberschrift + "\nKernfrage: " + $json.kernfrage + "\n\nDie anderen Bloecke (nicht behandeln, nur abgrenzen): " + $json.alle_ueberschriften + "\n\nDein Material (" + $json.quellen_anzahl + " Quellen):\n\n" + $json.material } ] }) }}
Schritt 7.5b: Bloecke sammeln
Ein Code-Knoten führt die einzeln geschriebenen Blöcke wieder zu einem Stück zusammen. Er läuft einmal für alle Eingänge, und genau das braucht man hier.Bloecke sammeln, vollständiger Code zum Kopieren (23 Zeilen)
Schritt 7.5c: Rahmen schreiben
Ein letzter Aufruf schreibt Vorspann und Schluss, und zwar nachdem die Blöcke stehen, damit er sich auf sie beziehen kann. HTTP-Request gegen https://api.anthropic.com/v1/messages, Zugang über Predefined Credential Type → Anthropic API, Header anthropic-version: 2023-06-01, max_tokens: 3000, temperature: 0.7, Retry on Fail an.
Der Systemprompt verlangt drei bis fünf Sätze Vorspann („Was hält diese Woche zusammen? Nicht die Blöcke aufzählen, sondern die Verbindung benennen, die ein Leser sonst übersieht.“) und drei bis vier Sätze Schluss („Was davon zählt nächste Woche noch?“). Geliefert wird reines JSON mit den zwei Feldern vorspann und schluss.
Ulf: „Der Vorspann kommt am Ende? Das klingt verkehrt herum.“
Tanja: „Beim Schreiben ist es genau richtig. Ein Vorspann, der geschrieben wird, bevor die Blöcke stehen, kann nur ankündigen, was gleich kommen soll. Einer, der danach geschrieben wird, kann sagen, was tatsächlich drinsteht. Zeitungen machen das seit hundert Jahren so.“Rahmen schreiben, der exakte Ausdruck für das Feld Body
Schritt 7.6: Der KI-Hinweis und wo er hingehört
Seit dem 2. August 2026 gilt Artikel 50 der KI-Verordnung. Für Texte greift die Kennzeichnungspflicht, wenn drei Dinge zusammenkommen: Der KI-erzeugte oder KI-veränderte Text wird veröffentlicht, er soll die Öffentlichkeit informieren, und er betrifft Angelegenheiten von öffentlichem Interesse. Darunter fallen etwa politische Vorgänge, Verwaltung, Justiz, Grundrechte, öffentliche Sicherheit, Gesundheit sowie wirtschaftliche und wissenschaftliche Entwicklungen von gesellschaftlicher Bedeutung.
Es gibt eine Ausnahme, und sie ist wichtig: Wird der Inhalt von einem Menschen inhaltlich geprüft und übernimmt eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung, entfällt die Kennzeichnungspflicht. Eine reine Rechtschreib-, Grammatik- oder Formatprüfung genügt dafür ausdrücklich nicht, geprüft werden muss der Inhalt.
Für diese Anlage heißt das: Wer die sonntägliche Zusammenfassung nur in die eigene Mailbox legt, veröffentlicht nichts und ist von Artikel 50 nicht betroffen. Wer sie ungeprüft ins Netz stellt, sehr wohl. Dieser Workflow kennzeichnet in beiden Fällen, unabhängig von der Rechtslage, weil eine feste Kennzeichnung nichts kostet und die Frage ein für alle Mal erledigt.
Für den Nachbau zählt vor allem die Bauentscheidung, und die ist unabhängig von der Rechtslage richtig:
Der Hinweis gehört in den Code, nicht in den Prompt. Ein Pflichthinweis darf nicht davon abhängen, ob ein Modell ihn diesmal mitschreibt oder umformuliert. Er steht als feste Zeichenkette im Knoten Zusammenfassung bauen, wird eingefügt und anschließend geprüft, und das Prüfergebnis steht in der Kopftabelle der E-Mail.
Bernd: „Schreib in den Prompt: ‚Setz oben einen KI-Hinweis hin.‘ Erledigt.“
Tanja: „Und in Woche sieben formuliert das Modell ihn um, oder lässt ihn weg, weil der Text gerade knapp wird. Ein Pflichthinweis ist eine feste Zeichenkette im Code. Sie wird eingesetzt und danach geprüft, und das Prüfergebnis steht oben in der E-Mail.“
Ulf: „Also nicht bitten, sondern einbauen.“
Tanja: „Alles, was verbindlich sein muss, gehört in den Code. Der Prompt ist für das zuständig, was Urteilsvermögen braucht.“
Zur Platzierung verlangt die Vorschrift, dass die Information klar und unterscheidbar spätestens beim ersten Kontakt mit dem Inhalt gegeben wird. Eine bestimmte Zeile schreibt sie nicht vor. Dieser Aufbau setzt den Hinweis direkt hinter die Überschrift, vor den Vorspann, weil das die Stelle ist, an der ihn niemand übersehen kann. Am Textende oder im Impressum wäre er eine Wette darauf, dass jemand bis dorthin liest.
(Stand August 2026. Das ist keine Rechtsberatung, sondern die Auswertung öffentlicher Quellen. Zu Artikel 50 gibt es bislang weder Rechtsprechung noch behördliche Entscheidungspraxis. Ob dein Fall unter die Ausnahme fällt, entscheidest nicht du und erst recht nicht dieser Text.)
Schritt 7.6b: Zusammenfassung bauen
Der Knoten setzt Titel, Hinweis, Vorspann, Blöcke und Schluss zusammen, misst die Länge und baut die E-Mail samt Kopftabelle.Zusammenfassung bauen, vollständiger Code zum Kopieren (55 Zeilen)
// A21 Schluss: Text zusammensetzen, messen, E-Mail bauen.
// ---------------------------------------------------------------------------
// KI-Hinweis nach Artikel 50 der KI-Verordnung.
// Bewusst HIER fest verdrahtet und nicht im Prompt: Ein Pflichthinweis darf nicht
// davon abhaengen, ob ein Modell ihn diesmal mitschreibt oder umformuliert.
// Platzierung direkt hinter der Ueberschrift, weil die Leitlinien der Kommission
// verlangen, dass der Hinweis beim ersten Kontakt sichtbar ist - ein Vermerk am
// Textende oder im Impressum genuegt ausdruecklich nicht.
const HINWEIS_HTML = '<p style="font-size:0.9em;opacity:0.75"><strong>Hinweis:</strong> Dieser Beitrag wurde mit künstlicher Intelligenz erstellt.</p>';
// ---------------------------------------------------------------------------
const vor = $('Material aufbereiten').first().json;
const teile = $('Bloecke sammeln').first().json;
// Rahmen einlesen. Faellt das Parsen aus, laeuft der Knoten OHNE Vorspann
// weiter statt abzubrechen - lieber ein Text ohne Rahmen als gar keiner.
let rahmen = { vorspann: '', schluss: '' };
try {
const roh = String((($('Rahmen schreiben').first().json.content || [])[0] || {}).text || '');
const t = roh.replace(/^```(?:json)?/i,'').replace(/```$/,'').trim();
rahmen = JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1));
} catch (f) { /* ohne Rahmen weiter */ }
const artikel = '<h1>' + teile.titel + '</h1>\n' + HINWEIS_HTML + '\n'
+ (rahmen.vorspann || '') + '\n'
+ teile.bloecke_html + '\n'
+ (rahmen.schluss || '');
const woerter = t => String(t).replace(/<[^>]+>/g,' ').split(/\s+/).filter(Boolean).length;
const wArtikel = woerter(artikel);
// Sicherung: der Hinweis muss im Text stehen, sonst faellt es in der Mail auf.
const hinweis_ok = artikel.indexOf('künstlicher Intelligenz erstellt') >= 0;
const kopf = [
'<h2 style="font:600 15px sans-serif">Wochenzusammenfassung KW ' + vor.kw + ' — ' + vor.zeitraum + '</h2>',
'<table style="font:13px sans-serif;border-collapse:collapse">',
'<tr><td style="padding:2px 12px 2px 0">Quellen</td><td>' + vor.anzahl_quellen + '</td></tr>',
'<tr><td style="padding:2px 12px 2px 0">Themenbloecke</td><td>' + teile.bloecke_anzahl + '</td></tr>',
'<tr><td style="padding:2px 12px 2px 0">Laenge</td><td>' + wArtikel + ' Woerter</td></tr>',
'<tr><td style="padding:2px 12px 2px 0">KI-Hinweis enthalten</td><td>' + (hinweis_ok ? 'ja' : 'FEHLT') + '</td></tr>',
'</table><hr>',
].join('');
return [{ json: {
betreff: 'Wochenzusammenfassung KW ' + vor.kw + ' (' + vor.zeitraum + ')',
koerper: kopf + artikel,
wp_content: artikel,
kw: vor.kw,
woerter_artikel: wArtikel,
bloecke: teile.bloecke_anzahl,
woerter_bloecke: teile.woerter_bloecke,
hinweis_ok,
}}];
Eine Kleinigkeit, die den Lauf rettet: Lässt sich die Antwort von Rahmen schreiben nicht als JSON lesen, fängt der try-Block das ab und der Knoten läuft ohne Vorspann weiter. Lieber ein Text ohne Rahmen als gar keiner.
Schritt 7.7: Die E-Mail
Der Zusammenbau-Knoten setzt dem Text eine kleine Kopftabelle voran, die den Lauf selbst berichtet:
| Quellen | 83 |
| Themenblöcke | 6 |
| Länge | 2.796 Wörter |
| KI-Hinweis enthalten | ja |
Das ist eine der nützlichsten Kleinigkeiten des ganzen Aufbaus. Man sieht beim Öffnen der Mail, ob der Lauf gesund war, ohne n8n aufzumachen.
Ulf: „Vier Zeilen Tabelle. Bringt das wirklich etwas?“
Tanja: „Sonntagmorgen, du machst die Mail auf und siehst: 83 Quellen, sechs Blöcke, 2.796 Wörter, Hinweis enthalten. Alles in Ordnung. Stehen da plötzlich 11 Quellen, weißt du sofort, dass etwas klemmt, ohne n8n zu öffnen. Ein System sollte über sich selbst berichten.“
Dann ein Send Email-Knoten mit deinem SMTP-Zugang, Betreff ={{ $json.betreff }}, HTML ={{ $json.koerper }}.
Phase 8: Die drei Dinge, die man erst hinterher merkt
Diese Phase hat keine Knoten und entscheidet trotzdem über Erfolg oder Misserfolg.
8.1: Die Zeitzone gehört an den Workflow
n8n rechnet im Auslieferungszustand in America/New_York. Ein Zeitplan-Knoten ohne eigene Zeitzone erbt diesen Wert. Die Oberfläche zeigt brav „4am“ an. Sie meint nur eine andere Zeitzone als du.
Konkret bedeutete das: A20 lief nicht um 04:00, sondern um 10:00 deutscher Zeit. A21 nicht sonntags um 06:00, sondern sonntags um 12:00. Aufgefallen ist es nur, weil jemand nachfragte, ob alles funktioniert habe, es war nichts rot geworden.
Ulf: „Aber auf dem Bildschirm stand doch vier Uhr.“
Tanja: „Es stand ‚4am‘ da. Nur nicht deine vier Uhr. n8n rechnet ab Werk in America/New_York, und ein Zeitplan-Knoten ohne eigene Zeitzone erbt das. A20 lief um zehn, A21 sonntags um zwölf. Nichts wurde rot. Aufgefallen ist es, weil jemand nachgefragt hat.“
Bernd: „Bei mir stimmt die Uhrzeit. Ich hab die NAS auf deutsche Zeit gestellt.“
Tanja: „Die NAS schon. n8n hat seine eigene Einstellung, und die interessiert sich nicht für die Systemzeit des Rechners darunter.“
Klickfolge je Workflow: Workflow öffnen → … (drei Punkte oben rechts) → Settings → Feld Timezone → Europe/Berlin → speichern.
Dauerhaft löst man es an der Instanz: GENERIC_TIMEZONE=Europe/Berlin in die Docker-Umgebung von n8n eintragen und den Container neu starten. Dann gilt es auch für jeden künftigen Workflow.
Damit die laufende Zeitplanung die neue Zeitzone übernimmt, muss jeder betroffene Workflow einmal deaktiviert und wieder aktiviert werden.
8.2: Veröffentlichen ist ein eigener Schritt
Ein manueller Lauf führt den Entwurf aus. Ein planmäßiger Lauf führt die veröffentlichte Fassung aus.
Wer nur von Hand testet, hat nie geprüft, was nachts wirklich läuft. Erst der Klick auf Publish macht die Änderung zur laufenden Fassung.
Bernd: „Ich hab es doch getestet. Grün durchgelaufen, alles gut.“
Tanja: „Du hast den Entwurf getestet. Nachts läuft die veröffentlichte Fassung. Das sind zwei verschiedene Dinge, solange du nicht auf Publish geklickt hast.“
Ulf: „Woran sehe ich, welche gerade gilt?“
Tanja: „Am Publish-Knopf oben rechts. Grau heißt: Entwurf und laufende Fassung sind gleich. Farbig heißt: In deinem Entwurf liegt etwas, das nachts nicht mitläuft.“
Woran man es erkennt: Der Publish-Knopf ist grau, wenn Entwurf und veröffentlichte Fassung übereinstimmen. Ist er farbig, liegt etwas Unveröffentlichtes im Entwurf.

Ein Nebeneffekt, der einen kalt erwischen kann: Wer an einem Workflow arbeitet, in dem seit Wochen unveröffentlichte Änderungen liegen, veröffentlicht sie mit. Vor dem ersten Publish-Klick also nachsehen, was im Entwurf steht.
8.3: Der Beweis ist der planmäßige Lauf
Die Regel, die sich beim Bauen nach mehreren Fehlschlägen aufgedrängt hat:
Eine Sache gilt erst als belegt, wenn zweierlei vorliegt: die veröffentlichte Fassung und ein geplanter Lauf, der es zeigt.
Bis dahin ist „läuft nachts von selbst“ eine begründete Erwartung, kein Befund.
Beim ersten planmäßigen Sonntag war die Bilanz geteilt: A21 lief um 06:00:51 sauber durch, 83 Quellen, sechs Blöcke, Mail verschickt. A20 feuerte um 04:00:07, also zur korrekten Uhrzeit. Die Zeitzone stimmte, und scheiterte an dem Leerfall aus Phase 5.
Ulf: „Ärgerlich. Alles getestet, und dann geht es beim ersten echten Lauf schief.“
Tanja: „Nicht ärgerlich. Genau dafür ist der erste echte Lauf da. Ein Handlauf startest du, wenn du etwas zu verarbeiten hast. Der planmäßige Lauf startet auch dann, wenn nichts da ist. Diesen Zustand hattest du vorher nie.“
Rechne damit, dass der erste echte Lauf etwas findet, das kein Handlauf gefunden hat. Das ist kein Versagen, das ist der Zweck des ersten Laufs.

Anhang: Der Bauplan auf einen Blick
Wenn alles steht, sollte deine Anlage genau so aussehen. Diese Liste ist zum Gegenprüfen gedacht, Knoten für Knoten, Verbindung für Verbindung.
A20 Wochenquellen sammeln, 22 Knoten
Zeitzone Europe/Berlin, aktiv, täglich 04:00.
| # | Knoten | Typ in n8n | Besonderheit |
|---|---|---|---|
| 1 | Täglich 04:00 | scheduleTrigger | Days, Stunde 4, Minute 0 |
| 2 | Quellen einsammeln | code | die drei Quellenlisten |
| 3 | In ki_artikel schreiben | postgres insert | Skip on Conflict, Map Each Column Manually, On Error: continue |
| 4 | Offene Videos holen | postgres query | Execute Once, 21-Tage-Fenster |
| 5 | Transkripte holen | code | drei gleichzeitig, abschreiben nur bei positiv erkanntem Grund |
| 6 | Transkript speichern | postgres update | Abgleich über id, On Error: continue |
| 7 | Offene Folgen holen | postgres query | Execute Once |
| 8 | Kontingent beachten | code | 3.600 Sekunden je Durchgang |
| 9 | Audio klein holen | httpRequest | Response Format File, Output data, Timeout 900000 |
| 10 | Groq: Folgen abtippen | httpRequest | Zugang groqApi, multipart, Retry on Fail |
| 11 | Folgen ordnen | code | vorübergehend ≠ dauerhaft |
| 12 | Folgen-Transkript speichern | postgres update | Abgleich über id, On Error: continue |
| 13 | Von aussen angestossen | executeWorkflowTrigger | zweiter Einstieg, Eingabe passthrough |
| 14 | Offene Verdichtungen holen | postgres query | LIMIT 12 |
| 15 | Verdichten | httpRequest | Zugang openAiApi, Retry on Fail |
| 16 | Verdichtung ordnen | code | Kategorie hart ziehen |
| 17 | Verdichtung speichern | postgres update | Abgleich über id, On Error: continue |
| 18 | Webseiten einsammeln | code | Sitemaps und Übersichtsseite |
| 19 | Webseiten schreiben | postgres insert | Skip on Conflict, On Error: continue |
| 20 | Offene Seitentexte holen | postgres query | LIMIT 12, 30-Tage-Fenster |
| 21 | Seiten abrufen | code | Leerfall gibt [] zurück, 3 s Abstand je Domain |
| 22 | Seitentext speichern | postgres update | Abgleich über id, On Error: continue |
Die Verbindungen. Vier Stränge, drei davon hängen am Insert-Knoten, der vierte direkt am Zeitplan:
Täglich 04:00 → Quellen einsammeln
Täglich 04:00 → Webseiten einsammeln
Quellen einsammeln → In ki_artikel schreiben
In ki_artikel schreiben → Offene Videos holen
In ki_artikel schreiben → Offene Folgen holen
In ki_artikel schreiben → Offene Verdichtungen holen
Offene Videos holen → Transkripte holen
Transkripte holen → Transkript speichern
Offene Folgen holen → Kontingent beachten
Kontingent beachten → Audio klein holen
Audio klein holen → Groq: Folgen abtippen
Groq: Folgen abtippen → Folgen ordnen
Folgen ordnen → Folgen-Transkript speichern
Von aussen angestossen → Offene Folgen holen
Offene Verdichtungen holen → Verdichten
Verdichten → Verdichtung ordnen
Verdichtung ordnen → Verdichtung speichern
Webseiten einsammeln → Webseiten schreiben
Webseiten schreiben → Offene Seitentexte holen
Offene Seitentexte holen → Seiten abrufen
Seiten abrufen → Seitentext speichern
Knoten 13 (Von aussen angestossen) ist optional. Er erlaubt, den Podcast-Zweig einzeln aufzurufen, ohne die Feeds neu einzusammeln. Das ist praktisch, um einen Rückstand abzuarbeiten. Ein Workflow lässt sich nur als Unter-Workflow aufrufen, wenn er veröffentlicht ist.
A21 Wochenzusammenfassung schreiben, 10 Knoten
Zeitzone Europe/Berlin, aktiv, sonntags 06:00. Eine gerade Kette ohne Verzweigung.
| # | Knoten | Typ in n8n | Besonderheit |
|---|---|---|---|
| 1 | Sonntag 06:00 | scheduleTrigger | Weeks, Sunday, Stunde 6 |
| 2 | Wochenmaterial holen | postgres query | sieben Tage, nur verdichtete Zeilen |
| 3 | Material aufbereiten | code | Kennungen K1…Kn |
| 4 | Bloecke planen | httpRequest | Zugang anthropicApi, Retry on Fail |
| 5 | Bloecke aufteilen | code | ein Item je Block |
| 6 | Block schreiben | httpRequest | läuft je Item einmal |
| 7 | Bloecke sammeln | code | läuft einmal für alle |
| 8 | Rahmen schreiben | httpRequest | Vorspann und Schluss |
| 9 | Zusammenfassung bauen | code | KI-Hinweis fest verdrahtet |
| 10 | Zusammenfassung mailen | emailSend | Zugang smtp |
Sonntag 06:00 → Wochenmaterial holen
Wochenmaterial holen → Material aufbereiten
Material aufbereiten → Bloecke planen
Bloecke planen → Bloecke aufteilen
Bloecke aufteilen → Block schreiben
Block schreiben → Bloecke sammeln
Bloecke sammeln → Rahmen schreiben
Rahmen schreiben → Zusammenfassung bauen
Zusammenfassung bauen → Zusammenfassung mailen
Bei allen drei Anthropic-Knoten: Header anthropic-version: 2023-06-01, Timeout 600000.
Die Prüfpunkte je Stufe
Nach jedem Schritt gibt es eine Zahl, an der man sieht, ob er sitzt. Die Werte stammen aus echten Läufen, nicht aus einer Schätzung.
| Schritt | Was gebaut wird | Woran man erkennt, dass es sitzt |
|---|---|---|
| 0 | Tabelle und Postgres-Zugang | SELECT count(*) FROM ki_artikel antwortet, ohne zu meckern |
| 1 | yt-dlp-Dienst auf der NAS | /gesund meldet beide Versionen; vier Kanäle liefern 29.323 Wörter in 27 Sekunden |
| 2 | Zeitplan → Quellen einsammeln → Insert | 46 Funde gesammelt, 46 geschrieben in 1,775 Sekunden |
| 3 | die drei YouTube-Knoten | 25 Videos in 37 Sekunden, 88.448 Wörter, Status wechselt auf TRANSKRIBIERT |
| 4 | die fünf Podcast-Knoten | eine 49-Minuten-Folge: 9.078 Wörter in 7,8 Sekunden, 0 € |
| 5 | der vierte Strang | 17 Beiträge eingesammelt, 12 Texte zwischen 5.013 und 40.676 Zeichen. Zweiter Test: einmal laufen lassen, wenn nichts offen ist, muss grün bleiben |
| 6 | die drei Verdichtungsknoten | 12 Beiträge in 12 Sekunden, Zeilen haben eine zusammenfassung, Status VERDICHTET |
| 7 | A21 vollständig | erster Lauf: E-Mail kommt an. Nach dem Umbau auf blockweises Schreiben: 2.898 Wörter, hinweis_ok = true |
Tanja: „Diese Tabelle ist der eigentliche Bauplan. Nach jeder Stufe gibt es eine Zahl. Stimmt sie, gehst du weiter. Stimmt sie nicht, suchst du hier und nicht drei Phasen später.“
Bernd: „Ich bau immer erst alles fertig und teste dann am Ende.“
Tanja: „Und suchst den Fehler dann in zweiunddreißig Knoten statt in dreien.“
Die fünf Zugänge
| Zugang | Typ in n8n | Gebraucht von |
|---|---|---|
| Postgres | postgres | allen Datenbankknoten in A20 und A21 |
| Groq | groqApi | Groq: Folgen abtippen |
| OpenAI | openAiApi | Verdichten |
| Anthropic | anthropicApi | Bloecke planen, Block schreiben, Rahmen schreiben |
| SMTP | smtp | Zusammenfassung mailen, der einzige, den nur A21 braucht |
Passwörter und Schlüssel trägst du selbst ein. Diese Regel gilt hier wie überall.
Wartung: Was danach noch anfällt
Erstaunlich wenig.
yt-dlp aktualisiert sich selbst bei jedem Containerstart. Bleiben Transkripte aus, weil YouTube etwas geändert hat, genügt in der Regel ein Neustart des Containers.
Neue Quellen sind eine Zeile in Quellen einsammeln (Feed) beziehungsweise in Webseiten einsammeln (Sitemap). Kein neuer Knoten, kein neuer Workflow.
Einmal im Monat in die Datenbank sehen:
SELECT source_type, status, count(*)
FROM ki_artikel
GROUP BY source_type, status
ORDER BY 1, 2;
Häufen sich Zeilen auf OHNE_TRANSKRIPT oder OHNE_INHALT, ist eine Quelle kaputt.
Das Protokoll lesen. Jeder Lauf legt am ersten Ergebnis ein Feld _protokoll ab. Dort steht je Quelle, ob sie geantwortet hat. Eine Quelle, die dauerhaft „0 Treffer“ meldet, ist entweder still oder tot, und diese beiden Fälle sehen ohne Protokoll gleich aus.
Vor jeder Änderung ein Bildschirmfoto, danach eines. Klingt pedantisch, ist es auch, und es ist die einzige Möglichkeit, drei Wochen später nachzuvollziehen, warum etwas so eingestellt ist. Mit einer Auflage: Sieh dir jede Aufnahme nach dem Speichern einmal an. Eine Aufnahme, die man selbst nie angesehen hat, belegt nichts, sie ist nur eine Datei.
Bernd: „Ich mach massenhaft Screenshots. Hab ich alles im Ordner.“
Tanja: „Angesehen?“
Bernd: „Sind ja da.“
Tanja: „Wir hatten eine Aufnahme im Ordner, die den falschen Bildschirm zeigte. Aufgefallen ist das erst, als jemand sie zum ersten Mal geöffnet hat, Wochen später. Eine Aufnahme, die niemand angesehen hat, belegt nichts.“
Troubleshooting: Zwanzig Fehler, damit du sie nicht selbst machen musst
Ulf: „Zwanzig Fehler. Das ist eine Menge.“
Tanja: „Das ist eine gute Nachricht. Jede Zeile hier hat jemand anderes schon bezahlt. Du liest die Tabelle einmal durch und sparst dir zwei Nachmittage.“
| Symptom | Ursache | Abhilfe |
|---|---|---|
network xy declared as external, but could not be found | Netzname geraten statt nachgesehen | Container Manager → Netzwerk → echten Namen ablesen, in compose.yaml eintragen |
| Container startet und beendet sich sofort | Ordnername in compose.yaml stimmt nicht zeichengenau | Pfad in der volumes:-Zeile gegen File Station abgleichen |
PIDs limit discarded beim Start | Kern der NAS unterstützt die Prozessgrenze nicht | Harmlos. Container läuft; pids_limit wirkt auf diesem System nur nicht |
| Untertitelabruf liefert HTTP 200 und null Bytes | Selbstgebauter Abruf ohne PO-Token | Nicht selbst bauen, yt-dlp benutzen |
| „Sign in to confirm you’re not a bot“ bei jedem Video | Anlage geht über ein Rechenzentrum ins Netz | Mit vertretbarem Aufwand nicht lösbar. Über Privatanschluss betreiben |
| Feed meldet „ok, 0 Treffer“, obwohl neue Beiträge da sind | Server antwortet auf jeden Pfad mit HTTP 200 und Startseite | Auf <item> bzw. <entry> im Inhalt prüfen, nicht auf den Statuscode |
| Podcast-Transkription liefert bei langen Folgen nichts | Datei über 25 MB | /audio-Endpunkt mit ffmpeg vorschalten |
Rate limit … seconds of audio per hour (ASPH) | Gleitendes Stundenfenster über das ganze Konto; Probeläufe zählen mit | Grenze je Durchgang auf 3.600 s, drei Wiederholversuche mit 5 s Abstand |
| Eine Folge oder ein Video verschwindet dauerhaft aus der Zusammenfassung | Vorübergehender Fehler hat OHNE_TRANSKRIPT geschrieben; die Abfrage sucht GESAMMELT | Abschreiben nur bei positiv erkanntem dauerhaften Grund. Unbekannte Fehler bleiben GESAMMELT |
Videos stehen auf OHNE_TRANSKRIPT mit „Sign in to confirm you’re not a bot“ | Die Bot-Prüfung stand in keiner Liste vorübergehender Fehler | Die Bot-Prüfung ausdrücklich als vorübergehend führen, in beiden Zweigen |
| Jedes Video wird mehrfach abgetippt | Abfrageknoten steht auf „je Eingangszeile ausführen“ | Auf Execute Once stellen |
| Insert-Knoten zerlegt es | Automatische Spaltenzuordnung nimmt _protokoll als Spalte | Map Each Column Manually |
| Tabelle wächst mit jedem Lauf um dieselben Zeilen | Skip on Conflict an, aber kein UNIQUE auf url_normalized | Eindeutigen Zwang in der Datenbank anlegen |
| Eine Zeile wird bei jedem Lauf neu verdichtet | Bei UNBRAUCHBAR wurde ein Leerstring geschrieben | Marke [unbrauchbar] schreiben |
| Zusammenfassung ist eine Fehlermeldung | Zeilen mit [ohne Text] wurden mitverdichtet | AND content NOT LIKE '[ohne Text]%' ergänzen |
| Zuordnungen laufen ins Leere | Modell hat die Kategorie frei formuliert | Antwort deterministisch in einer Liste nachschlagen |
Minimax H3's Weights… im Titel | Entschlüsseler kennt nur Dezimalform | Beide Formen entschlüsseln, & zuletzt |
| Ein 404er unter den Funden | Adressmuster hat ein Bruchstück erwischt | Slug muss Buchstaben und einen Bindestrich enthalten |
| Ganzer Lauf schlägt fehl: „The row you are trying to update doesn’t exist“ | Code-Knoten gibt bei leerem Eingang einen Platzhalter ohne id an einen Update-Knoten | Bei leerem Eingang [] zurückgeben. Einmal ohne Material testen! |
| Workflow läuft sechs Stunden zu spät | n8n rechnet in America/New_York | Zeitzone je Workflow auf Europe/Berlin, dazu GENERIC_TIMEZONE an der Instanz |
| Änderung wirkt nachts nicht, obwohl der Handlauf klappt | Änderung liegt im Entwurf, nicht veröffentlicht | Publish drücken. Grauer Knopf = beides gleich |
| Text bleibt bei 1.500 statt 3.500 Wörtern | Ein Aufruf für alle Blöcke | Blockweise schreiben, ein Aufruf je Block, mit Wortvorgabe |
Was am Ende dasteht
Eine Anlage, die täglich um vier Uhr morgens ein Dutzend Quellen abklappert, Videos und Podcasts abtippt, Webseiten ohne Feed ausliest, jeden Fund auf ein paar Sätze eindampft, und sonntags um sechs einen Text von rund 2.800 Wörtern in die Mailbox legt, der sechs Themen benennt und in jedem davon verschiedene Stimmen gegeneinanderstellt.
Die Betriebskosten: null Euro für die Spracherkennung, wenige Cent im Monat fürs Verdichten, und ein Schreiblauf je Woche mit typischerweise acht Sprachmodell-Aufrufen: einmal die Planung, sechsmal je ein Block, einmal der Rahmen.
Was mich beim Nachlesen des Projekttagebuchs am meisten beschäftigt hat, ist nicht die Technik. Es ist eine Beobachtung, die sich durch fast jeden Fehler zieht: Die teuren Fehler waren nie die komplizierten. Der falsche Ordnername. Der geratene Netzname. Die Zeitzone, die auf dem Bildschirm richtig aussah und eine andere meinte. Jedes Mal war es dasselbe, ein Wert aus der Umgebung wurde angenommen statt abgefragt, obwohl das Nachsehen zwei Sekunden gedauert hätte.
Bernd: „Also war am Ende alles halb so wild.“
Tanja: „Am Ende schon. Sieh dir nur an, woran es lag: ein Ordnername, ein Netzname, eine Zeitzone. Keiner davon war schwierig. Sie waren geraten statt nachgesehen.“
Ulf: „Und das nächste Mal?“
Tanja: „Nachsehen. Immer den Wert aus der Umgebung holen, nie den, von dem du glaubst, dass er dort steht.“
Und die zweitteuersten waren die, bei denen etwas funktionierte, aber nicht das, was man dachte. Eine leere Antwort mit Statuscode 200. Ein Workflow, der als „erfolgreich“ gilt, weil er festgestellt hat, dass nichts zu tun ist. Ein Screenshot, der brav abgelegt, aber nie angesehen wurde und deshalb den falschen Bildschirm zeigte.
Das ist keine Eigenart dieses Projekts. Das ist die Grundbedingung von Automatisierung überhaupt: Ein System, das schweigt, sagt dir nicht, dass es funktioniert. Es sagt dir nur, dass es schweigt.
Womit sich am Ende eine Frage stellt, die über den Basteltisch hinausreicht. Wir bauen uns gerade alle Systeme, die für uns lesen, hören, sortieren und zusammenfassen. Sie entscheiden dabei zwangsläufig, was wir nicht zu sehen bekommen. In dieser Anlage macht ein Sprachmodell die Auswahl: welche fünf Themen die Woche waren, welche Quellen „ohne Erkenntniswert“ sind und wegfallen.
Das ist eine redaktionelle Entscheidung. Sie steht in einem Prompt, den man ändern kann, und das ist der eigentliche Gewinn daran, so etwas selbst zu bauen statt es zu abonnieren. Bei einem fertigen Dienst wäre dieselbe Entscheidung auch getroffen worden, nur hätte sie niemand aufgeschrieben, und ändern könnte man sie schon gar nicht.
Ulf: „Eine KI entscheidet also, was ich am Sonntag zu lesen bekomme.“
Tanja: „Ja. Und der Unterschied zu einem fertigen Dienst ist nicht, dass dort niemand entscheidet. Dort entscheidet auch jemand, du kannst es nur nicht nachlesen. Bei dir steht die Entscheidung in einem Prompt, den du öffnen und ändern kannst.“
Die Frage ist also nicht, ob solche Filter existieren. Sie ist, ob du deinen lesen kannst.
Und ob du merkst, wenn er anfängt, dir etwas vorzuenthalten.
FOUNDIC.org ist werbefrei und ohne Bezahlschranke. Wenn dir diese Anleitung geholfen hat:
Lade uns auf einen Kaffee ein
