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
434 lines
20 KiB
Markdown
434 lines
20 KiB
Markdown
# 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`](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` == 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](#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:
|
||
|
||
<!-- als `text` ausgezeichnet: Prettier formatiert einen `js`-Block um und
|
||
macht aus dieser Skizze gültigen, aber unlesbaren Code. -->
|
||
|
||
```text
|
||
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`:
|
||
|
||
```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.
|
||
|
||
```js
|
||
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.
|