PullMD Teil 9: Microsofts MarkItDown integriert — und drei Teile wieder rausgerissen
PullMD ist mein selbst gehostetes Tool, das Webseiten und URLs in sauberes Markdown verwandelt. Die Serie davor handelte vom Bauen, Härten und Ausliefern. Dieser Teil beginnt nicht mit einem Plan, sondern mit einem Juckreiz.
Ich hatte ein paar HTML-Dateien auf der Platte, die ich Claude geben wollte. PullMD extrahiert doch ohnehin schon Markdown aus jeder URL. Warum also kann ich diese Dateien nicht einfach reinwerfen? Die Antwort war ein kleines Feature: lokale Datei-Konvertierung über dieselbe Pipeline.
Der banale Juckreiz
Die Schutz-Vorkehrungen drumherum stammten überwiegend von Claude: kein cache.put() für hochgeladene Dateien, kein Playwright für Uploads, der Dateiname in einem X-Filename-Header statt im Query-Param, damit er nicht in den Access-Logs des Reverse-Proxys landet. Alles vertretbar. Mein eigener Anteil an dieser Liste war profaner: Ich habe schlicht keinen Sinn darin gesehen, eine Datei zu cachen, die man einmal per Drag&Drop konvertiert. Wozu sollte man die später noch einmal abrufen? PullMD ist in erster Linie ein lokales Tool mit Authentifizierung; die Privacy-Dramatik, mit der Claude die Punkte begründet hat, habe ich nie ganz geteilt.
Ausgelöst wird das Ganze per Drag&Drop. Beim Testen kam mir die naheliegende Frage hinterher:
„aber auf mobile könnte man ‚oder Datei öffnen’ daraus machen, oder? eine heruntergeladene html datei kann man auf mobile auch umwandeln wollen.“
Also wurde die Drop-Box antippbar und öffnet seitdem einen nativen Datei-Dialog: dasselbe convertHtmlFile() dahinter, nur ein <label> mit verstecktem File-Input davor. Desktop und Mobile.
MarkItDown rein
Das Upload-Feature lief zunächst nur auf meiner Testinstanz. Zwei Tage später stolperte ich zufällig über Microsofts frisch veröffentlichtes MarkItDown, MIT-lizenziert, in Python. Und es machte klick. Wenn HTML-Dateien gehen, warum nicht PDF, Word, PowerPoint, Excel, EPUB? Und warum eigentlich keine YouTube-Transcripte? Wer einmal auf einer dieser überladenen Transcript-Seiten gelandet ist, weiß, warum man das lieber im eigenen Tool hätte.
Ich gab Claude das Repo: „Schau dir das folgende Repo an. Können wir es sinnvoll in PullMD integrieren?“ Claude analysierte die Library und kam mit einem Plan zurück, der mir sofort einleuchtete. MarkItDown übernimmt nur die Nicht-HTML-Dateitypen. Für HTML wäre es ein Rückschritt: es macht im Kern dasselbe wie das, was PullMD längst auf dem rohen Body tut, und als „vierter HTML-Extraktor“ bringt es nichts. Also ein weiterer Sidecar. PullMD ist ohnehin ein kleiner Container-Verbund mit eigenen Diensten für Extraktion und Browser-Rendering; neu dazu kommt ein Python-Dienst, der angesprochen wird, wenn ein Dokumentformat reinkommt, das die bestehende Pipeline nicht kann.
So weit, so sauber. Eine fremde Library für die langweilige Fleißarbeit, denn Office-Formate parsen ist niemandes Lieblingsbeschäftigung; MarkItDown deckt einen ganzen Zoo davon ab: PDF, DOCX, PPTX, XLSX, EPUB, CSV, JSON, XML. Genau die Sorte Arbeit, die man sinnvoll abgibt, statt sie selbst zu pflegen. Dann habe ich mir Claudes Plan im Detail angesehen, und aus „rein damit“ wurde ein „rein, aber drei Teile davon nicht“.
Drei Teile wieder raus
In Claudes Integrationsplan fielen mir beim Lesen zwei Dinge auf: Der YouTube-Converter fehlte kommentarlos, ausgerechnet der Teil, der mich am meisten gereizt hatte. Und beim Audio stand „Privacy“ als Begründung, während der Plan trotzdem OpenAI aufrief. Ich hakte nach:
„warum hast du YT-transcript weggelassen? … und was bedeutet ‚Privacy’, wenn du openai dennoch verwendest?“
Claudes Antwort ging die Gründe am Code entlang durch, und daraus wurde die eigentliche Arbeit dieses Releases: nicht das Einbauen, sondern das gezielte Wieder-Rausnehmen.
Audio. MarkItDowns Default schickt Audiodateien still an Googles Web-Speech-API. Bei einem selbst gehosteten Tool gehört diese Entscheidung dem Betreiber, nicht dem Default. Also ein eigener Endpoint mit betreiber-konfiguriertem Backend: faster-whisper oder Ollama lokal, Groq oder OpenAI für die, die das wollen. Eine bewusste Wahl inklusive echter Local-Option statt stillem Google-Default.
YouTube. MarkItDowns YouTubeConverter flog komplett raus. Claudes Blick in dessen Code fand: ytt_api.list() lag außerhalb von try/except. Ein geblocktes oder transkriptloses Video reißt damit die ganze Konvertierung mit hoch: man verliert sogar Titel und Metadaten, obwohl die im HTML längst dastanden. Kein Proxy-Support obendrein. An die Stelle kam ein eigener /youtube-Handler mit graceful fallback, der sich an einer keyless-Implementierung orientiert, die ich anderswo schon hatte.
Bild-Captioning. Kein Wrapper um MarkItDowns llm_client, sondern der Vision-Call direkt im eigenen Provider-Layer. Null MarkItDown-Kopplung für den LLM-Teil: ein MarkItDown-Update kann daran gar nichts brechen.
Das Prinzip ging als Satz ins Projekt-Memory: MarkItDown nur für Dateitypen, fragile und 3rd-Party-Pfade bauen wir selbst.
Am nächsten Vormittag wuchs aus diesem Satz ein größerer Architektur-Brainstorm. Ich stellte die offene Frage:
„bauen wir unseren eigenen markitdown-Ersatz? wäre das schlechtes Design?“
Claudes Antwort war nein, und es war die richtige. Nicht den ganzen Office-, EPUB- und XLSX-Parser-Zoo nachbauen; den will niemand selbst pflegen. Aber die LLM-Schicht gehörte heraus. Vision und Speech-to-Text wanderten komplett aus dem Python-Sidecar nach Node. Meine Begründung dafür: „ich finde es nicht gut, wenn wir unsere LLM-Calls so von MarkItDown abhängig machen. Wenn jemand gar keine Dokumente braucht, muss er trotzdem MarkItDown mitlaufen lassen.“ Der Sidecar schrumpfte damit auf das, was er gut kann: Dokumente und YouTube.
An derselben Stelle wäre fast noch ein viertes Stück hineingewandert: Docling, IBMs Tabellen-Engine, die lokal die besten Ergebnisse liefert. Claude hatte sie als Alternative erwähnt, und ich war kurz begeistert, bis Claude die Zahlen nachreichte: 4,4-GB-Image, mehrere Gigabyte RAM pro Request, 60 bis 140 Sekunden Cold-Start. Den Ausschlag gab dann keine technische Abwägung, sondern die Vorstellung, wie das beim ersten Ausprobieren wirkt:
„wenn dann so ein ewig langer warmup kommt, bin ich frustriert und schiebe es auf ‚was für ein schlechtes projekt, dieses pullmd’.“
Docling ist jetzt nur als steckbarer URL-Endpoint dokumentiert, nicht im Standard-Image. Der Leitsatz dahinter: leichte hosted-Tür für alle, schwere self-host-Tür nur für die, die sie bewusst aufmachen. Dieselbe Logik bei den PDF-Tabellen: Mistral OCR als Referenz-Adapter, aber opt-in per Query-Param, damit es nie still Geld kostet.
Am Ende blieb genau ein echter Breaking Change: Der Body besteht jetzt nur noch aus Titel und Inhalt; Quelle und Abruf-Datum stehen ausschließlich im YAML-Frontmatter, statt zusätzlich als Zeile im Text. Nichts ist mehr doppelt, Agenten lesen weniger Tokens; wer die alte Zeile braucht, holt sie sich per Env-Flag zurück. Das machte aus dem Release v3.0.0.
Das neue Modell als Türsteher
Am Abend, als v3 fertig war und der Release-Tag geplant, erschien Fable 5 — Anthropics neues Spitzenmodell. Ein brandneues Modell, dessen Klasse fürs Schwachstellen-Finden gebaut ist, am Abend vor einem Release, das gerade einen öffentlichen Upload-Pfad dazubekommen hatte: neue Angriffsfläche, die noch niemand mit bösen Augen angesehen hatte. Die naheliegendste erste Aufgabe konnte nur eine sein.
Erster Einsatz, direkt am echten Diff:
„mach bitte ein code review und finde bugs und sicherheitsprobleme.“
Sieben reale Befunde, ein falsch-positiver. Die zwei, die mir am meisten zu denken gaben, waren Security:
Eine YAML-Injection über \r. Die Funktion, die Strings fürs Frontmatter quotet, escapte nur \n, nicht \r. YAML behandelt ein nacktes \r als Zeilenumbruch: ein angreiferkontrollierter Seitentitel hätte damit eine eigene Frontmatter-Zeile einschleusen können. Der Fix war ein einzelnes Zeichen mehr in der Regex: [\r\n]. Ein Ein-Zeichen-Loch. Geschrieben hatte den Code Claude, und durch jedes Review davor war es trotzdem gerutscht.
Eine Zip-Bomb beziehungsweise DoS auf dem öffentlichen Upload-Pfad: beliebige Dokumente jagten ungeschützt durch den Parser, und der Sidecar war intern unauthentifiziert. Der Fix: Konvertierung in einem Wegwerf-Subprozess mit Wall-Clock-Timeout und Memory-Cap. Bewusst spawn statt fork, weil fork aus einem Worker eines multithreaded Servers mit den Lazy-Imports von MarkItDown deadlocken kann. Dieser Punkt landete im Review zunächst auf der Liste „braucht echte Designarbeit“; ich habe ihn dann doch sofort vollständig umsetzen lassen, denn ein öffentlicher Upload-Pfad ohne Ressourcen-Deckel ist keine Sache, die man auf später verschiebt.
Dazu die Bugs:
- try/catch fehlte: ein Vision/STT-Fehler hätte statt einer graceful degradation einen 502 ausgelöst; der beruhigende Kommentar „no behaviour change“ galt nur für den Null-Fall, nicht für den Throw-Fall.
- Cache-Hit verlor Medien-Felder bei gesetztem Frontmatter-Flag; der Fix konsolidierte nebenbei den dreifach duplizierten Merge-Block.
- MCP-Tool von der HTTP-API abgedriftet: der PDF-OCR-Pfad war über MCP gar nicht erreichbar.
- Ein falsch-positiver Befund (ein gemeldeter Exception-String-Leak): ungefährlich, weil der betroffene Body verworfen wird, bevor er den Client je erreicht.
Auch das Review lesen bleibt Pflicht; ein Befund ist eine Hypothese, kein Urteil.
Am nächsten Morgen entdeckte ich selbst ein einzelnes kaputtes Bild auf einer Share-Page. Ich gab es Claude zum Aufspüren und bat darum, bei der Gelegenheit gleich noch einmal über das ganze Release zu gehen. Es kam ein ganzer Sweep mit. Relative <img src>-URLs wurden nie gegen die Quell-Domain absolutifiziert. Ein OAuth-Secret war seit v2.3 in keiner Compose-Datei durchgereicht. Und der Opt-out-Flag für den Breaking Change war über keinen Pfad erreichbar — die wichtigste Lücke, denn ein Breaking Change ohne erreichbaren Opt-out ist einfach nur ein Break.
Nach all dem standen 721 Node-Tests plus 4 Sidecar-Guard-Tests grün, von 644 beim Öffnen des Branches.
Draußen
Squash-Merge, Tag, Release. Minuten später der Reddit-Post. Kein Triumph-Ton: das Ding ist draußen, :latest zeigt jetzt auf v3, fertig. Vor dem Push lief noch ein Privacy- und Secrets-Scan über den ganzen Branch; das war mir wichtiger als saubere Commit-Ästhetik, aus naheliegenden Gründen, die in dieser Serie schon einmal ein eigenes Thema waren.
Die Aufteilung war über den ganzen Bogen dieselbe: ich habe die Entscheidungen getroffen, Claude hat analysiert und gebaut. Was diesen Teil von den vorigen unterscheidet, ist, dass die wichtigsten Entscheidungen nicht das Einbauen waren, sondern das bewusste Wieder-Rausnehmen. Die langweilige Hälfte habe ich abgegeben. Die heikle Hälfte habe ich behalten.
Eine fremde Library spart einem den Parser-Zoo. Aber jeden Pfad, der entweder fragil ist oder still Daten nach außen schickt, übernimmt man besser nicht ungeprüft: den baut man selbst, oder man macht ihn wenigstens zur bewussten Wahl statt zum stillen Default.
Teil 9 von 9 der PullMD Serie.