// Article fetcher — the reader pane's supply line. // // Goal (Daniel, 2026-08-31): reading a story must not require leaving BAM-tribute. We fetch the // publisher page server-side, run it through Readability to get the article body (the same // extraction engine Firefox's reader mode ships), and cache it on the story doc. // // Discipline: // * Server-only (imports fs-free DOM code). Never pulled into a client component. // * Returns PLAIN PARAGRAPHS, never raw HTML — the reader renders text nodes, so a publisher // page can't inject markup, scripts, or trackers into our paper. // * Budgeted at crawl time + on demand, with an in-flight lock: one URL is fetched once, // no matter how many tickers/cards point at it. // * Failure is a state, not an exception: status "blocked"/"error" is stored and the UI // falls back to "read at " honestly. No fake article text, ever. import { Readability } from "@mozilla/readability"; import { parseHTML } from "linkedom"; import { getCollections } from "./db.js"; const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; const MAX_BYTES = 2_500_000; // stop reading a page past 2.5 MB const MAX_PARAS = 80; const MAX_CHARS = 24_000; // stored body cap — a long-read stays readable, Mongo stays sane; // the overflow gets an honest "continue at the source" link const MIN_WORDS = 90; // below this Readability chewed off a shell/teaser, not an article const FETCH_TIMEOUT_MS = 12_000; const RETRY_AFTER_MS = 1000 * 60 * 60 * 12; // re-try a blocked/error page twice a day at most const inflight = new Map(); // url -> promise // Hosts that refuse us, with a cooling-off window. Discovered the hard way on // 2026-08-31: newsinfo.inquirer.net answers EVERY article with 403 (every UA and header // combination we tried), and because its feed sits early in the crawl order it ate 10 of // the 14 prefetch slots in one tick — the reader came out warm for only 4 stories, all // SunStar. Remembering the wall means we pay for it once per window instead of per story, // and it frees the budget for publishers who actually answer. const blockedHosts = new Map(); // host -> { until, reason } const BLOCK_TTL_MS = 1000 * 60 * 60 * 6; function hostOf(url) { try { return new URL(url).hostname.toLowerCase(); } catch { return null; } } function blockedUntil(host) { const e = host && blockedHosts.get(host); if (!e) return 0; if (Date.now() > e.until) { blockedHosts.delete(host); return 0; } return e.until; } function noteBlock(host, reason) { if (!host) return; blockedHosts.set(host, { until: Date.now() + BLOCK_TTL_MS, reason }); } // Google News "articles/CBMi..." links are JS redirect wrappers; the publisher URL is // packed in the base64url article id. Same decoder lib/crawl.js uses for og:image. export function unwrapGoogleNews(link) { const m = /news\.google\.com\/(?:rss\/)?articles\/([A-Za-z0-9_-]+)/.exec(String(link || "")); if (!m) return link; try { const raw = Buffer.from(m[1], "base64url").toString("latin1"); const urls = raw.match(/https?:\/\/[^\x00-\x1f\x7f"]+/g) || []; const real = urls.map((u) => u.replace(/[\x00-\x1f]+.*$/, "")).filter((u) => !/google|gstatic/i.test(u)); if (real.length) return real.sort((a, b) => b.length - a.length)[0]; } catch { /* keep the wrapper */ } return link; } function absolutise(src, base) { try { return new URL(src, base).href; } catch { return null; } } // Readability hands back a fragment of markup. We re-parse it and pull text out of the // block elements — deliberately losing all formatting so nothing hostile survives. function parasFrom(article, baseHref) { // article.textContent is the authority Readability itself computed (and the // same value `words` is counted from). linkedom's document.body can parse to an EMPTY // textContent on a re-parsed fragment — verified on scmp.com 2026-08-31: a 633-word // story where body.textContent was "" while 6

blocks existed — so the plain-text // fallback below must never re-read the DOM for its source. const plainText = String(article?.textContent || "").replace(/\r/g, ""); const frag = String(article?.content || ""); if (!frag) return []; const { document } = parseHTML(`${frag}`); const out = []; let chars = 0; const blocks = [...document.querySelectorAll("h2, h3, h4, p, blockquote, li, pre, figcaption")]; for (const node of blocks) { const tag = node.nodeName ? String(node.nodeName).toLowerCase() : "p"; const text = String(node.textContent || "").replace(/\s+/g, " ").trim(); if (!text || text.length < 3) continue; chars += text.length; if (chars > MAX_CHARS || out.length > MAX_PARAS) { out.push({ tag: "p", text: "", truncated: true }); break; } out.push({ tag, text }); } const images = []; for (const img of document.querySelectorAll("img")) { const u = absolutise(img.getAttribute("src") || img.getAttribute("data-src") || "", baseHref); if (u && /^https?:/i.test(u) && !/1x1|spacer|pixel|blank|logo|sprite|tracking/i.test(u)) images.push(u); if (images.length >= 12) break; } // Safety net: if the block walk produced nothing usable, the copy is sitting in // non-block elements (a bare

, or

wrappers that were all empty). SCMP returned // 1107 words but 0 surviving paragraphs on 2026-08-31 — a blank pane: status ok, words // populated, nothing to render. This net runs whenever NO usable paragraph survived, so // words>0 can never again ship an empty page. if (!out.some((p) => p.text)) { // Split the Readability text into paragraphs: prefer blank-line breaks, then single // newlines, and as a last resort (some publishers return one flat line) break long // runs on sentence boundaries so the pane never shows a wall of unbroken text. const splitInto = (src, re) => src.split(re).map((x) => x.replace(/\s+/g, " ").trim()).filter((t) => t.length > 2); let chunks = splitInto(plainText, /\n{2,}/); if (chunks.length <= 1) chunks = splitInto(plainText, /\n/); if (chunks.length <= 1 && plainText.replace(/\s+/g, " ").trim().length > 300) { chunks = splitInto(plainText.replace(/\s+/g, " "), /(?<=[.!?])\s+(?=["A-Z\u201c])/); } out.length = 0; chars = 0; for (const t of chunks) { chars += t.length; if (chars > MAX_CHARS || out.length > MAX_PARAS) { out.push({ tag: "p", text: "", truncated: true }); break; } out.push({ tag: "p", text: t }); } } return { paras: out, images }; } // Some outlets ship the article as JSON in the page and render it client-side, so // Readability sees a shell (ABS-CBN is the case that forced this: 60 words extracted // out of a real 500-word story). Pull the body out of the structured data instead. // Order: JSON-LD articleBody -> Next.js pageProps body_html -> any big body-ish field. function structuredBody(doc, html) { // 1. schema.org NewsArticle / articleBody for (const s of doc.querySelectorAll('script[type="application/ld+json"]')) { const raw = String(s.textContent || "").trim(); if (!raw) continue; try { const j = JSON.parse(raw); const seen = []; const walk = (n, d) => { if (!n || d > 6 || seen.length > 3) return; if (Array.isArray(n)) { for (const x of n) walk(x, d + 1); return; } if (typeof n === "object") { for (const [k, v] of Object.entries(n)) { if (typeof v === "string" && /^(articlebody|articleBody|text)$/i.test(k) && v.length > 240) seen.push(v); else walk(v, d + 1); } } }; walk(j, 0); if (seen.length) return { html: seen.sort((a, b) => b.length - a.length)[0], from: "jsonld" }; } catch { /* malformed ld+json is normal on news sites */ } } // 2. Next.js / Nuxt payload with a body_html (EV/Superdesk "content" object) const nd = /]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html); if (nd) { try { const j = JSON.parse(nd[1]); const hits = []; const walk = (n, d) => { if (!n || d > 8 || hits.length > 4) return; if (Array.isArray(n)) { for (const x of n) walk(x, d + 1); return; } if (typeof n === "object") { for (const [k, v] of Object.entries(n)) { if (typeof v === "string" && /^(body_html|bodyHtml|articleBody|content_html)$/i.test(k) && v.length > 240) hits.push(v); else walk(v, d + 1); } } }; walk(j?.props?.pageProps ?? j, 0); if (hits.length) return { html: hits.sort((a, b) => b.length - a.length)[0], from: "nextdata" }; } catch { /* ignore */ } } return null; } // Readability leaks photo credits at the top ("DOST-PAGASA FB PAGE", "Abhijeet Mishra / // SamMobile", "… | INQUIRER File Photo") and newsletter/cookie boilerplate at the tail. // Trim both — never reorder or rewrite what is left. const CREDIT = /(?:file photo|courtesy|photo by|image:|getty|facebook page|^by \p{Lu}|\p{Lu}\/(?:inquirer|rappler|cnn|gmanetwork|abs-cbn|philstar|bworld|pna)\b)/iu; const ALLCAPS = /^[\p{Lu}\d\s.,'’&()\/-]+$/u; const JUNK_TAIL = /^(?:cookie|subscribe|sign ?in|log ?in|advertisement|sponsored content|all rights reserved|read more\b|follow us|join the (?:conversation|discussion)|related stories|comments?\b|tags?\b|this article first appeared|want news alerts|email this article|print this article|also read|read next|most read|trending|popular\b|newsletter\b|share this\b)/i; // Structured bodies arrive as HTML; parasFrom's fallback wants a text form of it. function stripTagsOf(html) { return String(html || "").replace(/<\/(p|div|section|article|li|h[1-6]|blockquote)>/gi, "\n\n").replace(/<[^>]*>/g, " "); } function cleanParas(paras) { const list = paras.filter((p) => p && p.text); // Leading credit lines are always short — a genuine lede is not. That length guard is // what keeps "KATHMANDU, Nepal – Three months before…" (mixed case, long) intact. let start = 0; while (start < list.length - 1 && list[start].text.length < 90) { const t = list[start].text.trim(); const isCredit = CREDIT.test(t) || ALLCAPS.test(t) || /^(?:updated|published|manila|quezon city|cebucity|cebuphoto)\b/i.test(t); if (!isCredit) break; start++; } let end = list.length - 1; while (end > start && (JUNK_TAIL.test(list[end].text.trim()) || (list[end].text.length < 24 && list[end].tag === "p"))) end--; return list.slice(start, end + 1); } async function fetchArticle(url) { const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-PH,en;q=0.9", Referer: new URL(url).origin + "/", }, redirect: "follow", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); // 403/451 is a bot wall, not a bug — the reader shows the honest "read at source" // state instead of retrying forever (newsinfo.inquirer.net hard-403s every UA we tried). if (!res.ok) return { status: res.status === 403 || res.status === 451 || res.status === 429 ? "blocked" : "error", reason: `upstream ${res.status}` }; // Read with a byte ceiling so a runaway page cannot eat the box's memory. const chunks = []; let total = 0; if (res.body) { for await (const c of res.body) { total += c.length || 0; chunks.push(c); if (total > MAX_BYTES) break; } } else { const buf = await res.arrayBuffer(); chunks.push(new Uint8Array(buf)); total = buf.byteLength; } const html = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8"); if (/<(script[^>]*>\s*window\.__sveltekit|js-circleci|recaptcha)/i.test(html) && total < 4000) { return { status: "blocked", reason: "bot shell" }; } const { window } = parseHTML(html); const doc = window.document; const canonical = absolutise(doc.querySelector('link[rel="canonical"]')?.getAttribute("href") || "", url) || url; const baseHref = canonical || url; // Structured body must be read BEFORE the script strip — it lives inside