← Zurück zum Blog
pullmdweb-scrapingclaude-codeextraction19. April 2026 · 8 Min. Lesezeit

PullMD Teil 3: Mehr als Reddit, mehr als Readability

URL → ROUTING GATE → EXTRACTION PATHS
Editorial-Diagramm: URL-Input speist eine zentrale Routing-Gate, die in vier Extraction-Path-Cards verzweigt (Cloudflare MD, Reddit JSON, Readability, Trafilatura); rechts der Quality-Score; unten links ein kleines Log-Panel mit Health-, Signal- und Routed-Status.Editorial-Diagramm: URL-Input speist eine zentrale Routing-Gate, die in vier Extraction-Path-Cards verzweigt (Cloudflare MD, Reddit JSON, Readability, Trafilatura); rechts der Quality-Score; unten links ein kleines Log-Panel mit Health-, Signal- und Routed-Status.

Dieser Artikel erzählt drei Akte. Im ersten Akt, am Vormittag des 20. März, sprengt ein einziger Satz die Grenzen eines Tools, das ich eigentlich für nur einen einzigen Use-Case gebaut hatte. Im zweiten Akt, im April, reift der Service in Schritten — angetrieben von einem internen Briefing, das ich für mich selbst führte. Und im dritten Akt, am 25. April, finde ich den Bug, der wochenlang unter allem lag, ohne dass ich es bemerkt hätte — versteckt in einem Default-Parameter, der stillschweigend die falsche Code-Branch triggerte. Kein Absturz und keine Fehlermeldung — nur eine falsche Antwort, die gut genug aussah, um nicht aufzufallen. Wer Teil 2 gelesen hat, weiß: drei Wochen lief der Service still, nur für Reddit, nur auf dem Handy, nur für mich. Was dann passierte, fing mit einer Tippfehler-Frage an.

Akt 1: Der Pivot (20. März)

Drei Wochen nach dem ersten Deploy lief der Service genau so, wie ich ihn gebaut hatte: Reddit-URL rein, Markdown raus. Die Web Share Target API funktionierte zuverlässig, die PWA war auf dem Homescreen installiert, und wenn ich aus der Reddit-App heraus teilte, landete der Post als sauberes Markdown auf dem Bildschirm. Für diesen einen Workflow war das System fertig.

Dann kam der 20. März — und der Service bekam, aus reinem Versehen, eine URL, die kein Reddit-Link war. Ich hatte, ohne groß darüber nachzudenken, eine andere URL in die PWA eingegeben. Was zurückkam, war ein leeres Ergebnis: der Service konnte damit schlicht nichts anfangen. Die Web-Extraction-Logik existierte noch nicht. Es gab nur den Reddit-Pfad.

An diesem Vormittag, um 09:33, schrieb ich in die Claude-Code-Session:

„die URL soll auch bei normalen seiten im markdown erscheinen… das war doch bisher nicht so oder?“

Die Antwort war klar: Nein, war es nicht. Der Käfig war aus dem Spec entstanden — Reddit first — aber der tatsächliche Bedarf war breiter. Jede URL. Nicht nur Reddit.

Die erste Erweiterung war Mozilla Readability als Extraction-Layer: eine Bibliothek, die Mozilla für den Firefox Reader Mode entwickelt hat und die aus beliebigem HTML den Hauptinhalt herausholt — Artikel-Text, Überschriften, Bilder — ohne Sidebar, Navigation, Cookie-Banner. Dazu kam Turndown als HTML→Markdown-Konverter. Zusammen ergaben sie einen zweiten Pfad: für alles, das kein Reddit ist, gehe durch Readability, wandle das Ergebnis mit Turndown um, liefere Markdown.

Kurz danach kam noch eine dritte Quelle dazu, die ich aus Teil 1 kannte: Cloudflare bietet einen nativen Markdown-Pfad für Sites, die es unterstützen. Wenn eine Site das aktiviert hat, liefert Cloudflare direkt sauberes Markdown — schneller und zuverlässiger als jede eigene Extraction-Logik, weil keine HTML-Bereinigung notwendig ist. Der Service prüft das jetzt als ersten Schritt: Cloudflare-Markdown verfügbar? Dann nimm das. Reddit? Dann den JSON-Pfad. Alles andere? Readability plus Turndown.

Drei Pfade, wo vorher einer war. Der Käfig war offen.

Akt 2: Die Reifephase (11.–25. April)

Irgendwann im April fing ich an, ein internes Dokument zu führen — kein formales Spec, eher eine Sammlung von Fragen, die ich mir beim Benutzen des Tools stellte. Die zentrale Frage war: was fehlt, wenn ein Agent diesen Output konsumiert? Nicht ein Mensch, der den Text liest und Lücken übersieht — sondern ein Agent, der strukturierte Informationen braucht, um damit weiterzuarbeiten.

Aus dieser Sammlung wurden drei Verbesserungen, die ich P1.1, P1.2 und P1.3 nannte — die erste Runde der Extraction Pipeline v0.2.

P1.1: Turndown → node-html-markdown. Turndown ist der klassische HTML→Markdown-Konverter, aber er unterstützt kein GitHub Flavored Markdown. Tabellen wurden nicht als GFM-Tabellen ausgegeben, sondern flachgedrückt oder weggeworfen. Fenced Code Blocks mit Sprach-Hint, also ```typescript oder ```python, gingen verloren — der Code-Block kam ohne Language-Tag raus, und nachgelagerte Syntax-Highlighting funktionierte nicht. node-html-markdown beherrscht GFM nativ: Tabellen bleiben Tabellen, Task-Lists bleiben Task-Lists, Code-Fences behalten ihren Sprach-Hint. Ein Zeilen-Tausch in der Pipeline, aber ein deutlicher Unterschied im Output.

P1.2: Rich Metadata. Bisher lieferte der Service nur Markdown — keinen Kontext darüber, was der Text eigentlich ist. lib/metadata.js mit cheerio extrahiert jetzt Titel aus <title> und <meta>-Tags (og:title, twitter:title), dazu Description, Author, Published Time. Das Ergebnis: jeder Markdown-Output bekommt ein YAML-Frontmatter mit diesen Metadaten. Für API-Clients gibt es außerdem eine ?format=json-Variante, die Metadata und Markdown-Text strukturiert als JSON-Objekt liefert — direkt konsumierbar für Pipelines, die nicht erst Frontmatter parsen wollen.

P1.3: Readability-Fallback. Readability versagt bei manchen Seiten — Paywalls, reine JavaScript-Apps, Sites, die keinen semantischen Artikel-Inhalt im DOM haben. Das Ergebnis sind Outputs mit weniger als 200 Zeichen, offensichtlich unbrauchbar. Statt in diesem Fall nichts zu liefern, greift jetzt ein Fallback: gecleantes Body-HTML, ohne die aggressive Readability-Sanitisierung. Der Source-Marker im Frontmatter lautet dann readability-fallback, damit man in den Logs sehen kann, wie oft dieser Pfad genommen wird und für welche Domains.

Bis zu diesem Punkt war alles inkrementell. Dann kam der 25. April — und mit ihm der Bug, der alles in Frage stellte.

Akt 3: Der Bug, der wochenlang lebte (25. April)

Die Frage war eigentlich eine andere: ist Readability wirklich der beste Extraction-Pfad? Ich wollte das evaluieren, bevor ich weiter darauf aufbaute. Also setzte ich einen Vergleichs-Eval auf: 15 URLs aus dem Production-Cache, durchgejagt durch drei Engines. Readability, @extractus/article-extractor und Trafilatura — ein Python-Bibliothek, die ursprünglich für Web-Archivierungs-Projekte entwickelt wurde und bekannt dafür ist, auch bei schwierigen Seiten noch substantiellen Text zu extrahieren.

Beim Auswerten der Ergebnisse fiel etwas auf. Die Readability-Outputs sahen anders aus als erwartet — kürzer, irgendwie rauer, ohne die sauber strukturierten Absätze, die ich vom Firefox Reader Mode kannte. Ich schaute in lib/web.js und verfolgte den Code-Pfad zurück.

server.js übergab für alle URLs den Parameter wantComments=true an extractWeb. Das war der Default — für Reddit hatte es einen Sinn, weil Reddit-Kommentare mitextrahiert werden sollten. Aber wantComments=true triggerte in der Web-Conversion-Funktion einen separaten Branch: wenn Comments gewünscht sind, überspringe Readability und liefere gecleantes Body-HTML. Dieser Branch war für Reddit gedacht. Für Web-URLs war er nie gedacht. Aber der Default-Wert true galt für alle URLs — und damit für jede Web-Anfrage.

Das bedeutete: Production lief seit Wochen mit „geputzte Body-HTML“-Output, nicht mit Readability. Was ich als „Readability-Output“ kannte, war in Wirklichkeit gecleantes Raw-HTML. Mein Vergleich hatte wochenlang die falsche Engine gegen Trafilatura gemessen.

Der Fix war eine Zeile: für Web-URLs übergibt server.js immer comments: false. Danach machte die Eval plötzlich Sinn.

Trafilatura war komplementär stark genau dort, wo Readability tatsächlich versagte. Auf einer persönlichen Blog-Seite — intern nano-banana2 — lieferte Readability nach dem Fix 107 Zeichen Output; Trafilatura lieferte 14.755 Zeichen, Faktor ~138. Bei einer Tech-News-Site: 471 Zeichen mit Readability, 1.667 Zeichen mit Trafilatura. Bei einem anderen Blog: Trafilatura +80% mehr Inhalt. @extractus war für die meisten URLs byte-identisch zu Readability — plus drei harte Fehler auf 15 URLs. Kein Uplift, kein weiteres Argument.

Readability gewann dort, wo Tabellen erhalten bleiben mussten — Wikipedia, API-Dokumentation, strukturierte Seiten. Trafilatura extrahiert aggressiver und verliert dabei manchmal die Struktur. Das Ergebnis war keine Entweder-Oder-Entscheidung: Trafilatura als Python-Sidecar-Container, intern erreichbar über ein Docker-Network. Beide Engines laufen parallel — ein Quality-Score wählt das bessere Ergebnis pro Request.

Der Quality-Score (5 Punkte)

Die Heuristik in lib/scoring.js ist einfach und absichtlich so gehalten — fünf Faktoren, die grob messen, ob ein Extraction-Output substanziell ist:

  • +0.3 wenn der Markdown-Output länger als 500 Zeichen ist
  • +0.2 wenn das Verhältnis aus extrahiertem Text zu Original-HTML-Größe über 2% liegt
  • +0.2 wenn <article> oder <main> im Original-HTML vorhanden ist (semantisches Signal, dass die Seite Artikel-Struktur hat)
  • +0.15 wenn mindestens eine Überschrift im Output ist
  • +0.15 wenn mindestens drei Absätze mit 40+ Zeichen vorhanden sind

Reddit-JSON und Cloudflare-Markdown bekommen +0.4 statt der beiden web-only Bonuses — beide sind „clean by construction“, also ohne Junk-HTML, das gefiltert werden müsste. Der Score landet im X-Quality-Header jeder Response und in metadata.quality im JSON-Output, damit man als Client sehen kann, wie zuverlässig der Output für eine gegebene URL war. Wer zwei Engines parallel laufen lässt, nimmt das Ergebnis mit dem höheren Score.

Wenn die eigene Production seit Wochen falsch läuft

Was sagt es aus, wenn ein Bug über Wochen in einem selbst-gehosteten Service lebt, ohne dass jemand es bemerkt?

Zum einen, dass der Output „gut genug“ war. Gecleantes Body-HTML für die meisten Seiten enthält den Großteil des relevanten Texts — auch ohne Readability. Das ist die unangenehme Erkenntnis: wenn der Unterschied zwischen richtigem und falschem Verhalten schwer zu sehen ist, sieht man ihn nicht. Nicht weil man nicht aufpasst, sondern weil es kein sichtbares Signal gibt.

Das führte direkt zu P3.1: eine extraction_log SQLite-Tabelle, die für jeden Request Source, Quality-Score und Latenz speichert. Auto-Prune nach 30 Tagen. Dazu ein GET /api/stats?window=-7+days Endpoint, der die Daten aggregiert — wie viele Requests gingen durch Readability, wie viele durch Trafilatura, wie viele durch den Fallback-Pfad, welche Domains haben durchschnittlich niedrige Quality-Scores. Das nächste Mal, wenn ein Parameter-Default einen Code-Branch falsch triggert, verschiebt sich die Source-Verteilung im Stats-Endpoint. Es ist keine Garantie — aber es ist ein Signal.

Für jedes selbst-gehostete Tool gilt: entweder du baust Beobachtbarkeit ein, oder du fliegst blind. Die Beobachtbarkeit muss nicht groß sein. Eine SQLite-Tabelle und ein JSON-Endpoint reichen als erster Schritt.

Was als nächstes kam

Drei Extraction-Pfade, ein Quality-Score, eine MCP-Server-Schnittstelle und ein PWA-Frontend — aber das alles lief noch unter einem Codenamen, den ich aus markenrechtlichen Gründen vor dem Public-Release ablegen musste. → PullMD Teil 4: Vom Tool für mich zum OSS-Projekt


Teil 3 von 9 der PullMD Serie.

PullMD Teil 2: Vom Spec zum laufenden Container | PullMD Teil 4: Vom Tool für mich zum OSS-Projekt