/** * URL handling and HTML → markdown extraction (pure). */ import type TurndownService from "turndown"; /** * linkedom + turndown + readability cost ~90ms to load (findings §15), so they * are imported on the first fetch rather than at startup. The promise is * cached: concurrent fetches share one load. */ let libsPromise: | Promise<{ Readability: typeof import("@mozilla/readability").Readability; parseHTML: typeof import("linkedom").parseHTML; Turndown: typeof TurndownService; }> | undefined; function loadLibs() { libsPromise ??= Promise.all([import("@mozilla/readability"), import("linkedom"), import("turndown")]).then( ([readability, linkedom, turndown]) => ({ Readability: readability.Readability, parseHTML: linkedom.parseHTML, // turndown is CJS (`export =`): the class arrives as `default` under // Node's ESM interop, or as the module itself under require-style loaders. Turndown: (turndown as { default?: typeof TurndownService }).default ?? (turndown as unknown as typeof TurndownService), }), ); return libsPromise; } export interface NormalizedUrl { url: string; /** Set when the input was rewritten, so the tool can say so. */ note?: string; } /** Upgrades http to https and rejects non-web schemes, as Claude Code's WebFetch does. */ export function normalizeUrl(input: string): NormalizedUrl { const trimmed = input.trim(); let parsed: URL; try { parsed = new URL(trimmed); } catch { throw new Error(`Not a valid URL: ${input}`); } if (parsed.protocol === "http:") { parsed.protocol = "https:"; return { url: parsed.toString(), note: "Upgraded http to https." }; } if (parsed.protocol !== "https:") { throw new Error(`Unsupported URL scheme "${parsed.protocol}" — only http and https are fetched.`); } return { url: parsed.toString() }; } export function isSameHost(a: string, b: string): boolean { try { return new URL(a).host === new URL(b).host; } catch { return false; } } function createTurndown(Turndown: typeof TurndownService): TurndownService { const turndown = new Turndown({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", }); // Script/style survive Readability in some documents; drop them explicitly. turndown.remove(["script", "style", "noscript"]); return turndown; } export interface ExtractResult { title?: string; markdown: string; /** True when Readability found no article and the whole body was converted. */ fallback: boolean; } /** * Extracts readable content and converts it to markdown. Readability is tried * first (it strips navigation and boilerplate); if it finds no article — common * for API references and landing pages — the whole body is converted instead. */ export async function htmlToMarkdown(html: string, _url: string): Promise { const { Readability, parseHTML, Turndown } = await loadLibs(); const { document } = parseHTML(html); const turndown = createTurndown(Turndown); // linkedom's Document is structurally compatible with what Readability needs // but is not the DOM lib's Document type, hence the cast. let article: { title?: string | null; content?: string | null } | null = null; try { article = new Readability(document as never).parse(); } catch { article = null; } if (article?.content && article.content.trim().length > 0) { return { title: article.title ?? document.title ?? undefined, markdown: turndown.turndown(article.content).trim(), fallback: false, }; } const body = document.body?.innerHTML ?? html; return { title: document.title || undefined, markdown: turndown.turndown(body).trim(), fallback: true, }; } export interface Page { text: string; truncated: boolean; nextOffset?: number; totalChars: number; } /** Windows long content so a big page cannot swamp the context. */ export function paginate(text: string, offset: number, maxChars: number): Page { const start = Math.max(0, Math.min(offset, text.length)); const slice = text.slice(start, start + maxChars); const end = start + slice.length; return { text: slice, truncated: end < text.length, nextOffset: end < text.length ? end : undefined, totalChars: text.length, }; }