winfuture.de/news,161132.html per Bookmarklet gespeichert: ArticleSaved trug 39 Ersatzzeichen im Text. Der Server-Fix vom 29.07. galt nur dem Server-Abruf. Das Bookmarklet baut sein <form> im fremden Dokument, und ein Formular ohne accept-charset kodiert im Zeichensatz dieses Dokuments (winfuture: ISO-8859-1, `ö` als %F6) — request.formData() liest UTF-8. - Bookmarklet setzt acceptCharset='UTF-8'. - Hook liest den Body über $lib/form-body: Escapes selbst zu Bytes, striktes UTF-8, sonst Windows-1252. Repariert auch alte Lesezeichen, die nicht mitdeployt werden. - collectPageta holte Folgeseiten per res.text() — dieselbe Falle, jetzt BOM > Header > <meta>. Kopie in der Erweiterung zeichengleich. 106 Tests grün (10 neu, Latin-1-Test per Mutation rot gesehen), svelte-check 0 Fehler, Erweiterungs-Check grün. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgK5h7sUgh9cdqyv85RBCq
20 KiB
Extraktion — warum Artikel manchmal leer ankommen
Stand 2026-07-29. Messungen aus dem Produktions-Container (pageta-api),
nicht vom Laptop — der Unterschied ist relevant, weil colima-Egress eigene
Aussetzer hat.
Verwandt: docs/ui-review-2026-07-26.html (P-05),
mana/docs/AGGREGATOR_POLICY.md.
Wie der Weg heute läuft
saveArticle(url)
└─ POST pageta-api /api/v1/articles/extract/preview { url, html? }
├─ html mitgeschickt? → Readability auf DIESES HTML (Bookmarklet v2)
└─ sonst → anonymer Server-Fetch, UA „ManaPageta/0.1"
→ Readability
└─ emit ArticleSaved { title, excerpt, content, siteName, … }
└─ (non-blocking) archiveAndEmit → ArticleMediaArchived
Der Server-Fetch trägt keine Cookies und keine Session. Das ist die Wurzel von allem, was unten steht.
Drei verschiedene Fehlerbilder — nicht ein Problem
Sie brauchen verschiedene Antworten, deshalb müssen sie im UI und in den Daten auseinandergehalten werden.
1. Harte Consent-Wand (nicht serverseitig lösbar)
golem.de antwortet auf jeden Cookie-losen Request mit 302 auf
/sonstiges/zustimmung/auswahl.html — unabhängig vom User-Agent
(mit ManaPageta und mit einem Chrome-UA identisch geprüft).
GET /news/support-panne-instagram-…-2606-209284.html
→ 302 Location: /sonstiges/zustimmung/auswahl.html?from=…
→ 200, 18 KB
Readability daraus: title „Golem" · siteName „golem.de" · wordCount 254
excerpt „Besuchen Sie Golem.de wie gewohnt mit Werbung…"
Kein User-Agent, kein Header, kein Retry kommt daran vorbei. Nur eine zustimmende Browser-Session — also Bookmarklet, Erweiterung oder Share-Extension.
Die Erkennung greift hier korrekt: 254 < CONSENT_WORDCOUNT_THRESHOLD
(300) und das Keyword „wie gewohnt mit werbung und tracking" steht in
CONSENT_KEYWORDS. detectConsentWall liefert true, der Client emittiert
ArticleWarningFlagged.
⚠️ Die Wortzahl-Schwelle ist knapp. Golem liegt bei 254, eine ausführlichere Consent-Seite (Sourcepoint-Vollseite) rutscht über 300 und wird dann nicht erkannt. Robuster wäre ein deterministisches Signal: finale URL nach Redirects ≠ angeforderte URL → sicher kein Artikel, unabhängig von der Wortzahl.
2. Gar nicht extrahiert (reparierbar, keine Wand)
the-decoder.de liefert dem anonymen Server-Fetch den vollständigen
Artikel:
GET /ki-modell-trennt-zwei-arten-wie-koeche-ueber-zutaten-denken/
→ 200, 159 KB
<title> KI-Modell trennt zwei Arten, wie Köche über Zutaten denken
og:title dito · 944 Wörter Fließtext
Trotzdem stand dieser Artikel in der Lese-Liste als „the-decoder.de" ohne Volltext. Die gespeicherten Einträge dieser Sorte haben:
- aufeinanderfolgende ULIDs (im Millisekunden-Abstand erzeugt)
title== Hostnamecontentleer- kein
warning-Flag
Das ist die Signatur eines Batch-Schreibvorgangs, der die Extraktion übersprungen hat — nicht die einer Consent-Wand. Diese Artikel wären sofort reparierbar.
Herkunft geklärt (2026-07-27): nicht der Bulk-Import, wie zuerst
vermutet — alle aus der iOS-App. Die Events tragen
actor.displayName: "ios". Aufschlüsselung derselben Datenbank, 54
ArticleSaved-Events:
| Quelle | gespeichert | davon Stub | Textlänge der Stubs |
|---|---|---|---|
| web · golem.de | 9 | 0 | — |
| web · the-decoder.de | 4 | 0 | — |
| ios · golem.de | 12 | 12 | 2664 Zeichen (= die Consent-Seite) bzw. 0 |
| ios · winfuture.de | 22 | 16 | 0 |
| ios · the-decoder.de | 6 | 1 | 0 |
| ios · engadget.com | 1 | 1 | 0 |
Zwei verschiedene Dinge stecken darin:
- golem über iOS — die 2664 Zeichen sind exakt der Consent-Text (254 Wörter, deckungsgleich mit der Messung oben). Die Wand, wie erwartet.
- winfuture über iOS — 16 Einträge mit leerem Text, von einer Quelle, die serverseitig einwandfrei ausliefert. Das ist keine Wand, sondern ein Extraktions-Fehlschlag im nativen Speicherpfad. Warum, lässt sich rückwirkend nicht mehr feststellen (die Speicherungen sind von Mai 2026).
Der iOS-Code selbst ist an dieser Stelle richtig: er emittiert
ArticleWarningFlagged, sobald der Server preview.warning liefert
(EventSyncCoordinator.swift:227). Nur lieferte der Server die Warnung
damals nicht — die Erkennung hing an der Wortzahl-Heuristik, die seit
heute um das deterministische Redirect-Signal ergänzt ist. Neue
iOS-Speicherungen sollten damit korrekt als blocked markiert werden.
Bleibt offen: warum die native Extraktion bei winfuture leeren Text lieferte. Solange das nicht geklärt ist, ist „leere Artikel gar nicht erst speichern" im nativen Pfad die falsche Reihenfolge — erst messen, dann verhindern. Die Web-Reparatur holt sie inzwischen alle nach.
2b. Nur Seite 1 gespeichert (mehrseitiger Artikel)
Kein Extraktions-Fehler, sondern ein Umfang-Fehler: der Artikel ist auf mehrere Seiten verteilt, gespeichert wurde eine.
golem.de/news/wirtschaftsblase-…-2607-211258.html
Seite 1 …-211258.html 911 Wörter ← das war alles, was ankam
Seite 2 …-211258-2.html
Seite 3 …-211258-3.html
Golem verlinkt die Seiten in .go-pagination__list; es gibt kein
<link rel="next">, keine Druck- und keine „auf einer Seite"-Ansicht.
Serverseitig ist da nichts zu holen: Seite 2 liegt hinter derselben Wand
wie Seite 1. Ein fetch aus dem Tab bekommt sie dagegen anstandslos
(gemessen 2026-07-27: …-211258-2.html → 200, kein Redirect auf
/sonstiges/zustimmung/). Also sammelt der Tab sie — siehe
Mehrseitige Artikel unten.
2c. Zeichensalat — Text da, Umlaute zerstört (behoben 2026-07-29)
Anders als 1 und 2: die Extraktion gelingt, Wortzahl und Struktur stimmen, nur jedes Nicht-ASCII-Zeichen ist ein Ersatzzeichen.
Fehlender Unterstrich f?hrte zu 18 Monaten Haft f?r Unschuldigen
Die Ursache liegt vor Readability. Response.text() dekodiert laut
fetch-Spezifikation immer als UTF-8, egal was Server oder Dokument
deklarieren. winfuture.de liefert ISO-8859-1 und sagt das nur im
<meta http-equiv>, nicht im HTTP-Header — gemessen an
news,160269.html: 150 Ersatzzeichen im Roh-HTML, bevor irgendetwas
extrahiert wurde. Der Schaden entsteht beim Lesen der Bytes und ist danach
nicht mehr reparierbar.
Fix: @mana/shared-rss@0.5.1 bringt decodeResponseText() — Body als
Bytes holen, Zeichensatz in Browser-Reihenfolge bestimmen (BOM >
Content-Type > Dokument-Deklaration > UTF-8), deklariertes iso-8859-1 als
windows-1252 behandeln. Genutzt von extractFromUrl,
discoverFeedsFromSite sowie hier von fetchHtml (Artikel) und
fetchText (Feed-/Sitemap-Archiv). Regel: bei fremdem HTML nie .text().
Nachgemessen im Prod-Container: 0 Ersatzzeichen, 501 Wörter, Titel mit Umlauten.
Rest-Lücke:
parseFeedUrlgeht überrss-parser.parseURL, das den Zeichensatz nur aus dem Content-Type liest, nicht aus dem XML-Prolog. Ein Feed, der ISO-8859-1 nur im Prolog deklariert, garbelt weiterhin — betrifftmana-news-poolund den Feed-Archiv-Import. Fix wäre: selbst fetchen +decodeResponseText+parseFeedXmlstattparseFeedUrl.
Zweiter Weg, gefunden 2026-09-14: das Bookmarklet. Der Server-Fix galt nur dem Server-Abruf.
winfuture.de/news,161132.htmlper Bookmarklet gespeichert:ArticleSavedmit 39 Ersatzzeichen im Text, Titel sauber (er hatte keine Umlaute). Ursache: das Bookmarklet baut sein<form>im fremden Dokument, und ein Formular ohneaccept-charsetkodiert im Zeichensatz DIESES Dokuments —ögeht als%F6raus,request.formData()im Hook liest UTF-8. Die Erweiterung war nie betroffen (fetch+FormDataist immer UTF-8). Fix: Bookmarklet setztacceptCharset='UTF-8'; weil alte Lesezeichen nicht mitdeployt werden, liest der Hook den Body über$lib/form-body(Bytes selbst entschlüsseln, striktes UTF-8, sonst Windows-1252). Dazu holtecollectPagetadie Folgeseiten mehrseitiger Artikel perres.text()— dieselbe Falle, jetzt mit BOM > Header ><meta>.
Bestehende Artikel heilt der Fix nicht — sie tragen den Salat in den
Daten. articleQuality kennt dafür garbled (ein Ersatzzeichen im Titel
oder drei im Text; die Schwelle im Text lässt Artikel durch, die das
Zeichen zitieren). Der Zustand ist isRepairable, läuft also über
denselben „Neu extrahieren"-Weg wie Fall 2, und trägt in der Liste das
Schild „Zeichensalat".
2d. Fragment ohne ArticleSaved (kein Artikel, unlöschbar)
Gefunden 2026-07-29: ein Aggregat aus vier Events — zwei
ArticleProgressUpdated, ein ArticleStatusChanged, ein weiteres
Progress-Event — und keinem ArticleSaved. Wie es dazu kam, lässt
sich nachträglich nicht mehr klären.
Folge: state.id setzt allein ArticleSaved, war hier also undefined.
Die Karte stand ohne Titel und ohne Quelle in der Liste, und löschen ging
nicht — batchDelete([undefined]) schrieb ArticleDeleted brav auf
article:undefined und liess das echte Aggregat unangetastet.
listArticles nimmt die Identität jetzt aus dem Aggregat-Schlüssel
statt aus dem Zustand und blendet Einträge ohne originalUrl aus. Beides
zusammen: so ein Fragment taucht nicht mehr auf, und falls doch je eines
sichtbar wird, lässt es sich löschen.
3. Funktioniert (die Mehrheit)
winfuture.de und die meisten Blogs kommen sauber durch: 200, keine
CMP-Signatur, echter Titel. Von 40 Artikeln der Testliste waren 32 in
Ordnung, 8 kaputt — und die 8 verteilen sich auf Fall 1 und Fall 2.
Nachtrag 2026-07-29: „sauber durch" stimmte für Struktur und Wortzahl, nicht für die Zeichen — winfuture kam die ganze Zeit mit zerstörten Umlauten an (Fall 2c). Das fiel nicht auf, weil die Zählmaße heil aussahen.
Der Reparatur-Weg liegt schon im Schema
evt_article_enriched_v1 existiert in @mana/shared-schemas mit genau
dem passenden Payload und einem nicht-destruktiven Reducer:
payload: { articleId, title, excerpt, content, author, siteName,
imageUrl, wordCount, readingTimeMinutes, publishedAt, warning }
reducer: imageUrl: e.payload.imageUrl ?? state.imageUrl // clobbert nichts
warning: e.payload.warning ?? state.warning
Bisher emittiert das nur Native. Der Web-Client kennt es nicht. Ein „Neu extrahieren" ist damit keine Schema-Änderung, sondern ein Client-Aufruf.
Ebenfalls schon da: evt_article_warning_cleared_v1.
Lösungsraum
Serverseitig — billig, aber prinzipiell begrenzt
| Maßnahme | Wirkung |
|---|---|
| Redirect-Ziel prüfen statt Wortzahl raten | macht Fall 1 deterministisch erkennbar |
Metadaten-Fallback (og:title, og:image) wenn kein Volltext |
Karte heißt richtig, auch ohne Text |
| Schwellwerte/Keywords pflegen | Symptombehandlung, altert schlecht |
| AMP-/Print-Varianten probieren | quellenspezifisch, wackelig |
Keine dieser Maßnahmen löst Fall 1. Golem bleibt zu.
Client-Erfassung — der einzige Weg an harte Wände
| Weg | Status | Hürde |
|---|---|---|
| HTML-Bookmarklet (v2) | funktioniert | Einrichtung, Lesezeichenleiste |
| Browser-Erweiterung | offen | Manifest V3, Store-Review — einmalig |
| iOS/Android Share mit HTML | teilweise | Android-Share liefert oft nur die URL |
Der Transport ist schon gebaut und erweiterungs-tauglich:
POST app.pageta.com/lese-liste/add/receive { url, title, pages[], missing[] }
→ hooks.server.ts stasht (single-use Token, 2 min TTL)
→ 303 /lese-liste/add?pickup=<token>
→ die (same-origin, angemeldete) Seite speichert via event-sync
Eine Erweiterung braucht damit keinen API-Schlüssel — sie nutzt dieselbe Session wie der Browser.
Mehrseitige Artikel
Seit 2026-07-27. Dieselbe Einsicht wie bei der Wand, eine Ebene weiter: was nur im Tab existiert, muss im Tab eingesammelt werden.
apps/web/src/lib/page-collector.js (collectPageta) läuft im fremden
Tab — als Quelltext in der javascript:-Adresse des Bookmarklets und via
scripting.executeScript in der Erweiterung. Es findet Folgeseiten
ohne quellenspezifische Selektoren:
- Zähler-Adressen. Ein Link zählt, wenn er sich von der aktuellen
Adresse nur in einem Seitenzähler unterscheidet —
-2.html,_2.html,?page=2,/seite/2/. Auf Golem findet das genau Seite 1–3, ohne.go-pagination__linkzu kennen. rel="next"-Kette, wenn (1) nichts liefert — für Quellen, die nur „weiter" verlinken.
Deckel: 20 Seiten, 6 MB gesamt, 300 ms Pause zwischen den Abrufen. Eine
Seite, die nicht durchkommt, landet in missing statt den ganzen Artikel
scheitern zu lassen; die add-Seite springt dann nicht weiter, sondern sagt
„x von y Seiten".
Gestrippt, aber weich
Vor dem Senden fallen <style>, <noscript> und <template> weg und
Skript-Inhalte werden geleert — Tags und Attribute bleiben stehen.
Absichtlich weich, weil zwei Dinge auf dem Server daran hängen:
siteNameFromJsonLd (@mana/shared-rss) liest application/ld+json, und
die CMP-Signaturen in consent-wall.ts erkennen Wände an Attributen wie
sourcepoint oder optanon.
| Quelle | roh | gestrippt | Extraktion |
|---|---|---|---|
| golem.de (Seite 1) | 401 KB | 145 KB | — |
| the-decoder.de | 155 KB | 107 KB | identisch (844 Wörter, Titel/siteName/Bild/Datum gleich) |
| winfuture.de | 92 KB | 63 KB | identisch (650 Wörter) |
Der Golem-Artikel komplett: 3 Seiten, 298 KB statt ~1,2 MB roh.
Serverseitig
POST /api/v1/articles/extract/preview nimmt zusätzlich pages
([{url, html}], max 20). Jede Seite läuft einzeln durch Readability —
mit IHRER Adresse, damit relative Verweise pro Seite auflösen — und
lib/merge-pages.ts fügt zusammen: Texte in Lese-Reihenfolge, Wortzahl
summiert, Lesezeit neu, Titel/Anriss von Seite 1, fehlende Kopfdaten von
der ersten Seite, die welche hat. Die Antwort trägt pageCount.
html (eine Seite) bleibt gültig: Bookmarklets liegen im Lesezeichen des
Nutzers und werden nicht mitdeployt. Ein altes Bookmarklet speichert
weiterhin nur Seite 1 — der Hinweis dazu steht in den Einstellungen.
Bewusst nicht
- Headless-Browser mit Nutzer-Cookies serverseitig. Löst es technisch, aber wir würden Consent umgehen und fremde Session-Cookies auf unserem Server halten. Steht quer zu MISSION.md und AGGREGATOR_POLICY.md.
- Consent-Dialoge automatisch wegklicken. Dasselbe Problem, offener.
- Archiv-Fallback (archive.org) wäre die mildere Variante — braucht aber eine Policy-Entscheidung, keine technische.
Reihenfolge
- Qualitätssignal am Artikel — Wortzahl, hat Titel, hat Absätze. Dann ist „kaputt" abfragbar statt geraten, und jede Verbesserung messbar.
- Neu extrahieren über
ArticleEnriched, einzeln und als Stapel. - Deterministische Wand-Erkennung (Redirect-Ziel) + Metadaten-Fallback.
- Browser-Erweiterung — der eine große Hebel für Fall 1.
Periodischer Re-Extract— verworfen, mit Grund. AGGREGATOR_POLICY.md erlaubt pageta das Abrufen fremder Seiten nur unter der Bedingung „user-initiiert, pro Artikel — nie Crawl, nie Feed-weit" (Ausnahme „Privatkopie-Archiv", Bedingung 1). Ein Timer im Hintergrund ist nicht user-initiiert. Der Wert bleibt über den Knopf erreichbar; was fehlte, war ein Gedächtnis — das gibt es jetzt ($lib/reextract-attempts.ts, 24-Stunden-Sperre nach einem erfolglosen Versuch, „trotzdem nochmal" bleibt sichtbar).- Mehrseitige Artikel im Tab sammeln — erledigt 2026-07-27, siehe oben. Offen bleibt der Zweitweg: derselbe Detektor serverseitig für Bulk-Import, Feed-Archiv und „Neu extrahieren" — dort gibt es keinen Tab, aber bei Quellen ohne Wand funktioniert das Nachladen.
pageCountspeichern — heute nur in der Extraktions-Antwort, nicht im Event. Ein Feld inevt_article_saved_v1/evt_article_enriched_v1bedeutet@mana/shared-schemasmit 14 Konsumenten; deshalb bewusst noch nicht.
Was nicht versprochen wird
Manche Quellen (Pur-Abo, harte Paywall) sind bewusst zu. Dann speichert Pageta den Link mit Metadaten und sagt das offen, statt einen Schein-Artikel anzulegen. Das ist die werte-konforme Antwort, nicht die Ausrede.
⚠️ Blockiert: „Neu extrahieren" greift nicht durch (Stand 2026-07-27)
Der Reparatur-Weg ist gebaut, deployt — und wirkungslos, aus einem Grund, der tiefer liegt als diese App.
Gemessen nach dem ersten Stapel-Lauf (8 ohne Text neu holen →
„4 geholt · 4 von der Quelle blockiert"): die ArticleEnriched-Events liegen
korrekt im lokalen Log, mit richtigem Titel und 844 Wörtern, die Outbox ist
leer (also gepusht). Die Projektion zeigt trotzdem weiter the-decoder.de
ohne Text.
Ursache — @mana/event-sync@0.14.0, dist/storage/event-log.js:
function toBig(v) {
if (v == null) return 0n;
return BigInt(v);
}
events.sort((a, b) => toBig(a.sequenceNumber) - toBig(b.sequenceNumber));
Lokal emittierte Events haben sequenceNumber: null bis zum nächsten Pull.
toBig(null) macht daraus 0n — sie sortieren also vor allem, was schon
eine Nummer hat. replay() faltet in dieser Reihenfolge:
ArticleEnriched seq null → 0n
ArticleMediaArchived seq null → 0n
ArticleSaved seq "1" → 1n ← zuletzt angewandt, überschreibt alles
Und die Sequenznummer kommt nie nach: append() dedupliziert per eventId,
der Re-Pull überschreibt die lokale Kopie nicht (bekannt, siehe
reference_pageta_sync2_schema_and_servicekey_drift Abschnitt C).
Das trifft nicht nur die Extraktion. In derselben Datenbank betrifft es
9 Aggregate, darunter ArticleStatusChanged und ArticleProgressUpdated auf
einem Bestands-Artikel — Lesestatus und Lesefortschritt gehen auf diesem
Client also ebenfalls still verloren.
Fix gehört ins geteilte Paket (14 Apps konsumieren es): ein Event ohne Sequenznummer ist per Konstruktion neuer als jedes sequenzierte und gehört ans Ende.
const UNSEQUENCED = 2n ** 63n;
function toBig(v) {
if (v == null) return UNSEQUENCED;
return BigInt(v);
}
Dazu ein Golden-Test mit gemischtem Stream. Bis dahin nützt jede Funktion nichts, die ein bestehendes Aggregat nachbessert — neu gespeicherte Artikel sind nicht betroffen, weil dort alle Events unsequenziert sind und die Einfüge-Reihenfolge stimmt.
✅ Blocker behoben (2026-07-27)
@mana/event-sync@0.15.1 sortiert Events ohne Sequenznummer jetzt hinter
die bereits gesyncten. Am selben Artikel nachgemessen, ohne dass ein neues
Event geschrieben wurde — dieselben drei Events, nur richtig sortiert:
| vorher | nachher | |
|---|---|---|
| Titel | the-decoder.de |
„KI-Modell trennt zwei Arten, wie Köche über Zutaten denken" |
| Text | 0 Zeichen | 6410 Zeichen · 844 Wörter |
Die Reparatur lag die ganze Zeit im Log. Sie wurde beim Replay überschrieben.