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
293 lines
9.8 KiB
JavaScript
293 lines
9.8 KiB
JavaScript
/**
|
||
* `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,
|
||
};
|
||
}
|