/** * Dependency-free HTML → Markdown conversion, page metadata and link extraction. * * The converter builds a lightweight DOM from a forgiving tokenizer, optionally isolates the main * content (readability-style density scoring), then serialises to Markdown. It is intended for * LLM/RAG pipelines and crawling: stable, compact output rather than perfect fidelity. */ import { decodeEntities } from "./text"; import type { PageLink, PageMetadata } from "./schema"; // --------------------------------------------------------------------------- // Tokenizer / DOM-lite // --------------------------------------------------------------------------- export interface HNode { type: "element" | "text"; tag: string; attrs: Record; children: HNode[]; text: string; parent: HNode | null; } const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]); const RAW_TEXT = new Set(["script", "style", "noscript", "template", "svg", "math", "iframe", "canvas", "object", "textarea"]); const BLOCK = new Set([ "address", "article", "aside", "blockquote", "body", "center", "dd", "details", "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "html", "li", "main", "nav", "ol", "p", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", ]); /** Elements whose content is closed implicitly by a new block start. */ const AUTO_CLOSE: Record> = { p: BLOCK, li: new Set(["li"]), dt: new Set(["dt", "dd"]), dd: new Set(["dt", "dd"]), tr: new Set(["tr"]), td: new Set(["td", "th", "tr"]), th: new Set(["td", "th", "tr"]), option: new Set(["option"]), }; function makeNode(type: HNode["type"], tag: string, parent: HNode | null): HNode { return { type, tag, attrs: {}, children: [], text: "", parent }; } const ATTR_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; export function parseHtml(html: string): HNode { const root = makeNode("element", "#root", null); let cur = root; let i = 0; const n = html.length; const pushText = (t: string) => { if (!t) return; const node = makeNode("text", "#text", cur); node.text = t; cur.children.push(node); }; while (i < n) { const lt = html.indexOf("<", i); if (lt === -1) { pushText(html.slice(i)); break; } if (lt > i) pushText(html.slice(i, lt)); if (html.startsWith("", lt + 4); i = end === -1 ? n : end + 3; continue; } if (html.startsWith("", lt); i = end === -1 ? n : end + 3; continue; } if (html[lt + 1] === "!" || html[lt + 1] === "?") { const end = html.indexOf(">", lt); i = end === -1 ? n : end + 1; continue; } const gt = findTagEnd(html, lt); if (gt === -1) { pushText(html.slice(lt)); break; } const raw = html.slice(lt + 1, gt); i = gt + 1; if (raw.startsWith("/")) { const tag = raw.slice(1).trim().toLowerCase().split(/\s/)[0]!; // close up to matching open element let p: HNode | null = cur; while (p && p !== root && p.tag !== tag) p = p.parent; if (p && p !== root) cur = p.parent ?? root; continue; } const m = raw.match(/^([a-zA-Z][a-zA-Z0-9:-]*)/); if (!m) { pushText("<" + raw + ">"); continue; } const tag = m[1]!.toLowerCase(); const selfClosing = raw.endsWith("/"); const attrs: Record = {}; const attrStr = raw.slice(m[0].length, selfClosing ? -1 : undefined); for (const am of attrStr.matchAll(ATTR_RE)) { const k = am[1]!.toLowerCase(); attrs[k] = decodeEntities(am[2] ?? am[3] ?? am[4] ?? ""); } // implicit closes let p: HNode | null = cur; while (p && p !== root) { const ac = AUTO_CLOSE[p.tag]; if (ac && ac.has(tag)) { cur = p.parent ?? root; break; } if (p.tag === "p" && BLOCK.has(tag)) { cur = p.parent ?? root; break; } p = p.parent; } const node = makeNode("element", tag, cur); node.attrs = attrs; cur.children.push(node); if (RAW_TEXT.has(tag) && !selfClosing) { const close = html.toLowerCase().indexOf(`", close) + 1 || n; continue; } if (!VOID.has(tag) && !selfClosing) cur = node; } return root; } function findTagEnd(html: string, from: number): number { let q: string | null = null; for (let j = from + 1; j < html.length; j++) { const c = html[j]!; if (q) { if (c === q) q = null; } else if (c === '"' || c === "'") { // only treat as quote when inside attribute area (after a space or =) const prev = html[j - 1]; if (prev === "=" || prev === " " || prev === "\t" || prev === "\n") q = c; } else if (c === ">") return j; else if (c === "<" && j > from + 1) return -1 + 0 * j; // malformed: give up on this tag } return -1; } // --------------------------------------------------------------------------- // Queries // --------------------------------------------------------------------------- export function walk(node: HNode, fn: (n: HNode) => boolean | void): void { if (fn(node) === false) return; for (const c of node.children) walk(c, fn); } export function findAll(root: HNode, pred: (n: HNode) => boolean): HNode[] { const out: HNode[] = []; walk(root, (n) => { if (n.type === "element" && pred(n)) out.push(n); }); return out; } export function textOf(node: HNode): string { let s = ""; walk(node, (n) => { if (n.type === "element" && RAW_TEXT.has(n.tag)) return false; if (n.type === "text") s += n.text; }); return decodeEntities(s).replace(/\s+/g, " ").trim(); } // --------------------------------------------------------------------------- // Main content extraction (readability-lite) // --------------------------------------------------------------------------- const NOISE_TAGS = new Set(["nav", "header", "footer", "aside", "form", "button", "select", "input", "label", "dialog", "menu"]); const NOISE_RE = /(^|[\s_-])(nav|menu|sidebar|side-bar|footer|header|banner|cookie|consent|gdpr|modal|popup|newsletter|subscribe|social|share|sharing|related|recommend|promo|advert|ad-|ads|sponsor|breadcrumb|comment|widget|toolbar|skip|masthead|sitemap|legal|copyright|login|signup|search)([\s_-]|$)/i; const CONTENT_RE = /(^|[\s_-])(article|content|main|post|entry|body|story|text|blog|product|description|prose|markdown|documentation|docs)([\s_-]|$)/i; function classId(n: HNode): string { return `${n.attrs["class"] ?? ""} ${n.attrs["id"] ?? ""}`; } export function isNoise(n: HNode): boolean { if (n.type !== "element") return false; if (RAW_TEXT.has(n.tag)) return true; if (NOISE_TAGS.has(n.tag)) return true; if (n.attrs["hidden"] !== undefined || /display\s*:\s*none/i.test(n.attrs["style"] ?? "")) return true; if (n.attrs["aria-hidden"] === "true") return true; const role = n.attrs["role"]; if (role && /navigation|banner|contentinfo|complementary|dialog|search|menu/i.test(role)) return true; if (n.tag === "div" || n.tag === "section" || n.tag === "ul" || n.tag === "span") { const ci = classId(n); if (NOISE_RE.test(ci) && !CONTENT_RE.test(ci)) return true; } return false; } /** Pick the element that most likely holds the primary content. Falls back to /root. */ export function mainContent(root: HNode): HNode { const explicit = findAll(root, (n) => n.tag === "article" || n.tag === "main" || n.attrs["role"] === "main" || n.attrs["itemprop"] === "articleBody"); const body = findAll(root, (n) => n.tag === "body")[0] ?? root; const scored: Array<{ node: HNode; score: number }> = []; const candidates = explicit.length ? explicit : findAll(body, (n) => ["div", "section", "td", "article", "main"].includes(n.tag)); const bodyLen = Math.max(1, textOf(body).length); for (const c of candidates) { if (isNoise(c)) continue; const text = textOf(c); if (text.length < 200) continue; const paragraphs = findAll(c, (n) => n.tag === "p" || n.tag === "h1" || n.tag === "h2" || n.tag === "h3" || n.tag === "li" || n.tag === "pre").length; const links = findAll(c, (n) => n.tag === "a").reduce((s, a) => s + textOf(a).length, 0); const linkDensity = links / Math.max(1, text.length); let score = text.length / bodyLen + Math.min(paragraphs, 40) / 40; score *= 1 - Math.min(0.9, linkDensity); if (c.tag === "article" || c.tag === "main" || c.attrs["role"] === "main") score *= 1.6; if (CONTENT_RE.test(classId(c))) score *= 1.2; scored.push({ node: c, score }); } if (!scored.length) return body; scored.sort((a, b) => b.score - a.score); const best = scored[0]!; // Guard against choosing a tiny fragment: require the pick to hold ≥ 25% of the body text. if (textOf(best.node).length < bodyLen * 0.25 && !explicit.length) return body; return best.node; } // --------------------------------------------------------------------------- // Markdown serialisation // --------------------------------------------------------------------------- export interface MarkdownOptions { baseUrl?: string; /** Isolate the main content (default true). */ mainContent?: boolean; /** Keep images as ![alt](src) (default true). */ images?: boolean; /** Keep hyperlinks as [text](href) (default true); false renders plain text. */ links?: boolean; /** Drop navigation/footers/asides etc. even inside the main content (default true). */ stripNoise?: boolean; /** Maximum output length in characters (default 2,000,000). */ maxLength?: number; } function absolutize(href: string | undefined, base?: string): string | null { if (!href) return null; const h = href.trim(); if (!h || h.startsWith("javascript:") || h.startsWith("data:") || h.startsWith("mailto:") || h.startsWith("tel:") || h === "#") return h.startsWith("mailto:") || h.startsWith("tel:") ? h : null; try { return base ? new URL(h, base).toString() : new URL(h).toString(); } catch { return null; } } function esc(s: string): string { return s.replace(/([\\`*_{}[\]<>])/g, "\\$1"); } type WriterOpts = Required> & { baseUrl?: string; /** Text length of the rendered scope, used to keep "noise" landmarks that actually hold the content (e.g. a