pageta/docs/EXTRAKTION.md
Till JS fb057da372 fix(web): Umlaute im Bookmarklet-Weg nicht mehr verlieren
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
2026-09-14 16:28:08 +02:00

20 KiB
Raw Permalink Blame History

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.

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 == Hostname
  • content leer
  • 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: parseFeedUrl geht über rss-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 — betrifft mana-news-pool und den Feed-Archiv-Import. Fix wäre: selbst fetchen + decodeResponseText + parseFeedXml statt parseFeedUrl.

Zweiter Weg, gefunden 2026-09-14: das Bookmarklet. Der Server-Fix galt nur dem Server-Abruf. winfuture.de/news,161132.html per Bookmarklet gespeichert: ArticleSaved mit 39 Ersatzzeichen im Text, Titel sauber (er hatte keine Umlaute). Ursache: das Bookmarklet baut sein <form> im fremden Dokument, und ein Formular ohne accept-charset kodiert im Zeichensatz DIESES Dokuments — ö geht als %F6 raus, request.formData() im Hook liest UTF-8. Die Erweiterung war nie betroffen (fetch + FormData ist immer UTF-8). Fix: Bookmarklet setzt acceptCharset='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 holte collectPageta die Folgeseiten mehrseitiger Artikel per res.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:

  1. 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__link zu kennen.
  2. 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

  1. Qualitätssignal am Artikel — Wortzahl, hat Titel, hat Absätze. Dann ist „kaputt" abfragbar statt geraten, und jede Verbesserung messbar.
  2. Neu extrahieren über ArticleEnriched, einzeln und als Stapel.
  3. Deterministische Wand-Erkennung (Redirect-Ziel) + Metadaten-Fallback.
  4. Browser-Erweiterung — der eine große Hebel für Fall 1.
  5. 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).
  6. 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.
  7. pageCount speichern — heute nur in der Extraktions-Antwort, nicht im Event. Ein Feld in evt_article_saved_v1 / evt_article_enriched_v1 bedeutet @mana/shared-schemas mit 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.