pageta/apps/extension/page-collector.js
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

293 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `collectPageta` — sammelt die offene Artikel-Seite MITSAMT ihren Folgeseiten,
* im Tab des Nutzers.
*
* Warum im Tab und nicht auf dem Server: dieselbe Begründung wie bei der
* Cookie-Wand (`docs/EXTRAKTION.md`). Quellen wie golem.de beantworten jede
* Anfrage ohne Zustimmungs-Cookie mit ihrer Abfrage statt mit dem Artikel —
* auch für Seite 2. Ein `fetch` aus dem Tab heraus trägt die Sitzung mit und
* bekommt den echten Text (gemessen 2026-07-27 an
* `…-2607-211258-2.html`: 200, kein Redirect auf `/sonstiges/zustimmung/`).
*
* Wie Folgeseiten gefunden werden — bewusst OHNE quellenspezifische
* Selektoren:
*
* 1. **Zähler-URLs.** Ein Link ist eine Folgeseite, wenn seine Adresse sich
* von der aktuellen NUR in einem Seitenzähler unterscheidet
* (`-2.html`, `?page=2`, `/seite/2/`). Auf golem.de findet das genau
* Seite 1–3, ohne `.go-pagination__link` zu kennen.
* 2. **`rel="next"`-Kette**, falls (1) nichts findet — für Quellen, die nur
* „weiter" verlinken statt eine Seitenliste zu zeigen.
*
* Jede geholte Seite wird geleichtert (siehe `strip`) und mit IHRER eigenen
* Adresse zurückgegeben, damit der Server relative Verweise pro Seite richtig
* auflöst.
*
* ── Zwei Aufrufer, eine Quelle ──────────────────────────────────────
* Diese Funktion muss **eigenständig** bleiben: keine Importe, kein Zugriff
* auf Variablen ausserhalb. Sie wird auf zwei Wegen benutzt, die beides nicht
* mitnehmen könnten:
* - Bookmarklet: `collectPageta.toString()` landet als Quelltext in der
* `javascript:`-Adresse (`lese-liste/settings/+page.svelte`).
* - Erweiterung: `scripting.executeScript({ func: collectPageta })`
* serialisiert die Funktion in den fremden Tab (`apps/extension`).
*
* Deshalb ausserdem: **kein `#` und kein `%` im Quelltext.** In einer
* `javascript:`-Adresse schneidet `#` den Rest ab und `%` wird dekodiert.
* Fragmente werden darum über die URL-API entfernt, nicht per `split`.
* `apps/web/src/lib/page-collector.test.ts` prüft beides.
*
* Die Kopie unter `apps/extension/page-collector.js` muss zeichengleich sein
* — `apps/extension/scripts/check.mjs` bricht sonst ab.
*/
/**
* @returns {Promise<{
* url: string,
* title: string,
* pages: { url: string, html: string }[],
* detected: number,
* missing: string[],
* }>}
*/
export async function collectPageta() {
/** Mehr Seiten holt niemand freiwillig — und schützt vor Zähler-Schleifen. */
const MAX_PAGES = 20;
/** Gesamt-Budget. `BODY_SIZE_LIMIT` der Web-App liegt bei 12 MB. */
const MAX_TOTAL_CHARS = 6_000_000;
/** Kurze Pause zwischen den Seiten — kein Stakkato gegen die Quelle. */
const PAUSE_MS = 300;
const FETCH_TIMEOUT_MS = 15_000;
/**
* Seitenzähler in einer Adresse. Die erste Gruppe ist die Nummer; was das
* Muster wegnimmt, ist der Rest-Stamm, über den Seiten einer Serie
* zusammenfinden.
*/
const COUNTERS = [
/-(\d{1,3})\.html?$/i, // golem.de: …-211258-2.html
/_(\d{1,3})\.html?$/i, // ältere CMS: …_2.html
/[?&](?:page|seite|pagina|p)=(\d{1,3})$/i, // …?page=2
/\/(?:page|seite)\/(\d{1,3})\/?$/i, // WordPress: …/page/2/
];
/**
* Adresse ohne Fragment — über die URL-API, damit kein Rautenzeichen im Text steht.
* @param {string} raw
* @param {string} base
* @returns {string}
*/
function bare(raw, base) {
const u = new URL(raw, base);
u.hash = '';
return u.href;
}
/**
* Endung und Schluss-Schrägstrich weg, damit Stämme vergleichbar werden.
* @param {string} u
* @returns {string}
*/
function normalize(u) {
return u.replace(/\.html?$/i, '').replace(/\/+$/, '');
}
/**
* Zerlegt eine Adresse in Seitenzahl + Stamm. Ohne Zähler ist es Seite 1 —
* die meisten Quellen hängen an die erste Seite keinen an.
* @param {string} u
* @returns {{ n: number, stem: string }}
*/
function split(u) {
for (const re of COUNTERS) {
const m = u.match(re);
if (m) return { n: Number(m[1]), stem: normalize(u.replace(re, '')) };
}
return { n: 1, stem: normalize(u) };
}
/**
* Trägt alle Zähler-Geschwister aus `doc` in `into` ein (Nummer → Adresse).
* @param {Document} doc
* @param {string} baseUrl
* @param {string} stem
* @param {Map<number, string>} into
*/
function discover(doc, baseUrl, stem, into) {
const host = new URL(baseUrl).host;
for (const a of doc.querySelectorAll('a[href]')) {
const raw = a.getAttribute('href');
if (!raw) continue;
let u;
try {
u = new URL(raw, baseUrl);
} catch {
continue;
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') continue;
if (u.host !== host) continue;
u.hash = '';
const part = split(u.href);
if (part.stem !== stem) continue;
if (part.n < 1 || part.n > MAX_PAGES) continue;
if (!into.has(part.n)) into.set(part.n, u.href);
}
}
/**
* `rel="next"` als Kopf-Verweis oder Link — der Fallback ohne Seitenliste.
* @param {Document} doc
* @param {string} baseUrl
* @returns {string | null}
*/
function relNext(doc, baseUrl) {
const el = doc.querySelector('link[rel~="next"], a[rel~="next"]');
const raw = el && el.getAttribute('href');
if (!raw) return null;
try {
return bare(raw, baseUrl);
} catch {
return null;
}
}
/**
* Erleichtert eine Seite auf das, was die Extraktion braucht.
*
* Bewusst weich: `<script>`-Tags bleiben MIT ihren Attributen stehen, nur
* ihr Inhalt fällt weg. Zwei Dinge auf dem Server hängen daran —
* `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`. Hart entfernt würde beides stillschweigend
* schlechter.
*
* Gemessen an der Golem-Seite: 401 KB roh, 145 KB danach — knapp zwei
* Drittel weniger.
* @param {Document} doc
* @returns {string}
*/
function strip(doc) {
const root = /** @type {HTMLElement} */ (doc.documentElement.cloneNode(true));
for (const el of root.querySelectorAll('style, noscript, template')) el.remove();
for (const el of root.querySelectorAll('script')) {
const type = (el.getAttribute('type') || '').toLowerCase();
if (type !== 'application/ld+json') el.textContent = '';
}
return '<!DOCTYPE html>' + root.outerHTML;
}
/**
* @param {string} u
* @returns {Promise<Document>}
*/
async function fetchDoc(u) {
const res = await fetch(u, {
credentials: 'include',
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const bytes = new Uint8Array(await res.arrayBuffer());
return new DOMParser().parseFromString(
decode(bytes, res.headers.get('content-type') || ''),
'text/html',
);
}
/**
* Nie `res.text()` bei fremdem HTML: das unterstellt immer UTF-8 und macht
* aus jeder Latin-1-Folgeseite (winfuture & Co.) Ersatzzeichen. Seite 1
* kommt aus dem DOM und hat das Problem nicht. Reihenfolge wie im Browser:
* BOM, dann HTTP-Header, dann Deklaration im Dokument, sonst UTF-8.
* @param {Uint8Array} bytes
* @param {string} contentType
* @returns {string}
*/
function decode(bytes, contentType) {
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
let label = bom ? 'utf-8' : '';
if (!label) {
const fromHeader = /charset=["']?([\w.:-]+)/i.exec(contentType);
if (fromHeader) label = fromHeader[1];
}
if (!label) {
const head = new TextDecoder('windows-1252').decode(bytes.subarray(0, 4096));
const fromMeta = /<meta[^>]+charset=["']?([\w.:-]+)/i.exec(head);
label = fromMeta ? fromMeta[1] : 'utf-8';
}
try {
return new TextDecoder(label).decode(bytes);
} catch {
return new TextDecoder('utf-8').decode(bytes);
}
}
// ── Seite 1 ist der offene Tab ───────────────────────────────────
const here = bare(location.href, location.href);
const stem = split(here).stem;
const pages = [{ url: here, html: strip(document) }];
let total = pages[0].html.length;
const known = new Map([[split(here).n, here]]);
const done = new Set([here]);
discover(document, here, stem, known);
let lastDoc = document;
let lastUrl = here;
const missing = [];
while (pages.length < MAX_PAGES && total < MAX_TOTAL_CHARS) {
// Kleinste noch offene Seitenzahl zuerst — die Lese-Reihenfolge.
let next = null;
for (const n of [...known.keys()].sort((a, b) => a - b)) {
const candidate = known.get(n);
if (candidate && !done.has(candidate)) {
next = candidate;
break;
}
}
// Keine Seitenliste? Dann der „weiter"-Kette folgen.
if (!next) {
const chained = relNext(lastDoc, lastUrl);
if (chained && !done.has(chained) && new URL(chained).host === new URL(here).host) {
next = chained;
}
}
if (!next) break;
done.add(next);
await new Promise((r) => setTimeout(r, PAUSE_MS));
try {
const doc = await fetchDoc(next);
const html = strip(doc);
// Über Budget: lieber ehrlich als Seite fehlend melden, als den
// POST an der Grössenbremse scheitern zu lassen.
if (total + html.length > MAX_TOTAL_CHARS) {
missing.push(next);
break;
}
pages.push({ url: next, html });
total += html.length;
lastDoc = doc;
lastUrl = next;
// Späte Seiten verlinken manchmal noch spätere.
discover(doc, next, stem, known);
} catch {
// Eine Seite verloren ist besser als der ganze Artikel verloren.
missing.push(next);
}
}
// Was noch bekannt, aber nie geholt wurde (Deckel erreicht), zählt als fehlend.
for (const u of known.values()) {
if (!done.has(u) && !missing.includes(u)) missing.push(u);
}
return {
url: here,
title: document.title || '',
pages,
detected: Math.max(pages.length + missing.length, known.size),
missing,
};
}