/** * Article extraction: HTML → cleaned markdown. * * Stack: linkedom (lightweight DOM, no jsdom weight) → @mozilla/readability * (Mozilla's reader-view boilerplate stripper, battle-tested across templates) * → turndown (HTML→markdown). All three are pure JS, no native deps. * * Pure (no network). Failure modes are EXPLICIT and surfaced to the caller: * * - Readability returns null (no main content identified), or * - the extracted article is below {@link MIN_ARTICLE_CHARS} characters. * This floor catches false-positives where Readability latches onto a * small sidebar/widget and would otherwise silently discard the real * content elsewhere on the page. * * → falls back to a stripped-tag plain-text dump of the whole page, prefixed * with {@link ARTICLE_FALLBACK_MARKER} so the model knows the output is * whole-page text rather than the distilled article. Silently discarding * real content is the failure mode this guard exists to prevent. * * Returns `{ markdown, fallback }`. The integration passes `markdown` straight * through; the `fallback` flag is informational (e.g. for telemetry/details). */ import { Readability } from "@mozilla/readability"; import { parseHTML } from "linkedom"; import TurndownService from "turndown"; /** * Minimum extracted-article size (chars) below which extraction is treated as * failed and we fall back to whole-page text. Tuned to sit safely above * typical nav/sidebar chrome noise (~50–150 chars) and safely below a real * doc body, so a real article always passes and a stray widget never does. */ const MIN_ARTICLE_CHARS = 400; /** * Marker prepended to fallback output. Worded so a reader (model or human) * immediately knows this is whole-page text, not the distilled article, and * that distillation failed rather than producing low-quality output. * Mirrors the phrasing the integration contract documents. */ export const ARTICLE_FALLBACK_MARKER = "…[article extraction failed; returning full page]\n\n"; export interface ExtractArticleResult { /** Cleaned markdown (success) or stripped plain text (fallback). */ markdown: string; /** True when Readability could not identify a viable article and the helper * fell back to whole-page text. Callers may surface this to the user. */ fallback: boolean; } // Structural type for the Readability.parse() return — defined locally so we // don't depend on @mozilla/readability's exported `Article` type (which has // moved between versions). We only consume three fields. type ParsedArticle = { length?: number; content?: string | null; textContent?: string | null; }; // Single shared Turndown instance. Construction is cheap and the instance is // stateless across calls, so reusing avoids per-call allocation. ATX headings // + fenced code produce the cleanest markdown for doc pages. const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", emDelimiter: "_", }); /** * Normalize whitespace in a plain-text dump: NBSP → space, collapse runs of * spaces/tabs, trim trailing space per line, collapse 3+ blank lines to one, * and trim the ends. Keeps the fallback readable rather than a wall of jagged * text inherited from the source HTML. */ function normalizePlainText(text: string): string { return text .replace(/\u00a0/g, " ") .replace(/[ \t\f\v]+/g, " ") .split("\n") .map((line) => line.trimEnd()) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } /** * Reduce HTML to plain text. Prefers the parsed DOM's `textContent` (correct * entity/whitespace handling); falls back to a regex tag-strip if the DOM has * no usable body/documentElement (deeply malformed input where linkedom * itself produced an empty tree). */ function htmlToPlainText(document: Document, rawHtml: string): string { const root = document.body ?? document.documentElement ?? null; if (root) { // Remove non-content descendants before taking textContent; otherwise // inline scripts, styles, noscripts, and iframes leak into the fallback. for (const selector of ["script", "style", "noscript", "iframe", "svg"]) { for (const el of Array.from(root.querySelectorAll(selector))) { el.remove(); } } } const text = root?.textContent ?? rawHtml.replace(/<[^>]+>/g, " "); return normalizePlainText(text || ""); } /** * Extract the main article from a raw HTML string and return it as markdown. * * @param html Raw HTML page source. * @param _source Optional source URL/label. Reserved for future use (e.g. a * citation footer); accepted to keep the integration's calling shape stable. */ export function extractArticle(html: string, _source?: string): ExtractArticleResult { let document: Document; try { document = parseHTML(html).document; } catch { // linkedom choked on the HTML — last-resort regex strip so we still return // something useful rather than throwing. return { markdown: `${ARTICLE_FALLBACK_MARKER}${normalizePlainText( html.replace(/<[^>]+>/g, " "), )}`, fallback: true, }; } let article: ParsedArticle | null = null; try { // cloneNode(true): Readability mutates the doc during scoring; cloning // keeps our parsed DOM intact for the fallback path's textContent read. article = new Readability(document.cloneNode(true), { // Hand Readability the same floor we re-check below, so its internal // threshold and our external one agree on what "too short" means. charThreshold: MIN_ARTICLE_CHARS, }).parse() as ParsedArticle | null; } catch { article = null; } if (article && (article.length ?? 0) >= MIN_ARTICLE_CHARS && article.content) { let markdown = ""; try { markdown = turndown.turndown(article.content).trim(); } catch { // Turndown very rarely throws on exotic markup; fall back to the // article's own textContent (already cleaned by Readability). markdown = normalizePlainText(article.textContent ?? ""); } // Re-check the markdown length: a long `article.length` could still // produce near-empty markdown if Turndown discards everything (e.g. an // article composed entirely of unsupported nodes). Belt-and-suspenders // against silently returning an empty article. if (markdown.length >= MIN_ARTICLE_CHARS) { return { markdown, fallback: false }; } } // Fallback: whole-page stripped text, marker-first so it can't be mistaken // for a clean article extraction. const text = htmlToPlainText(document, html); return { markdown: `${ARTICLE_FALLBACK_MARKER}${text}`.trimEnd(), fallback: true, }; }