import { parseHTML } from "linkedom"; import type { DefuddleResponse } from "defuddle/node"; const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/; /** Removes schema.org scripts that Defuddle would report directly to the process console. */ function removeMalformedSchemaOrgData(document: Document): void { for (const script of document.querySelectorAll( 'script[type="application/ld+json"]', )) { const jsonContent = (script.textContent || "") .replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "") .replace(/^\s*\s*$/, "$1") .replace(/^\s*(\*\/|\/\*)\s*|\s*(\*\/|\/\*)\s*$/g, "") .trim(); try { if (JSON.parse(jsonContent) === null) script.remove(); } catch { script.remove(); } } } /** * Replaces element IDs that are unsafe for CSS selectors and updates matching fragment links. * * @param document - The document whose element IDs and same-document links are normalized */ function normalizeSelectorUnsafeIds(document: Document): void { const replacements = new Map(); const occupiedIds = new Set( [...document.querySelectorAll("[id]")].map((element) => element.id), ); let replacementIndex = 0; for (const element of document.querySelectorAll("[id]")) { const id = element.id; if (!id || RAW_ID_SELECTOR_SAFE.test(id)) continue; let replacement: string; do { replacement = `defuddle-safe-id-${replacementIndex++}`; } while (occupiedIds.has(replacement)); occupiedIds.add(replacement); if (!replacements.has(id)) replacements.set(id, replacement); element.id = replacement; } if (replacements.size === 0) return; for (const anchor of document.querySelectorAll('a[href^="#"]')) { const href = anchor.getAttribute("href"); if (!href) continue; const replacement = replacements.get(href.slice(1)); if (replacement) anchor.setAttribute("href", `#${replacement}`); } } /** * Extracts normalized plain text from HTML when structured Markdown extraction is unavailable. * * @param html - The HTML content to convert * @returns The trimmed text content with excluded elements and excessive whitespace removed */ export function htmlToMarkdownFallback(html: string): string { const { document } = parseHTML(html); for (const element of document.querySelectorAll( "script, style, svg, noscript, template, iframe, nav, header, footer, aside, form", )) { element.remove(); } return document.body.textContent .replace(/[ \t]+\n/g, "\n") .replace(/\n[ \t]+/g, "\n") .replace(/[ \t]{2,}/g, " ") .replace(/\n{3,}/g, "\n\n") .trim(); } /** * Runs Defuddle over an already-normalized document with Markdown extraction enabled. * * Defuddle can fail in two distinct ways. The common case rejects the promise * we `await` below, which the caller's `try/catch` catches and turns into a * fallback. The dangerous case is when Defuddle schedules a throw on a * *detached* microtask or timer — for example, when it resolves a * document-relative link such as `/owner/repo/releases` into * `new URL(relative, undefined)` *after* its own promise has already resolved. * That rejection never reaches the `await` and instead escapes as an unhandled * rejection that bypasses the surrounding `try/catch` and crashes the calling * harness UI. Passing the absolute `pageUrl` prevents the URL-resolution form * of this failure, but the guard below still covers any residual detached * rejection. * * To keep `extractHtmlToMarkdown` from ever propagating such a failure, this * helper arms a scoped `unhandledRejection` listener for the lifetime of the * call. The listener is scoped, not process-wide in effect: it only treats a * rejection as a Defuddle failure when its message or stack mentions Defuddle, * so unrelated rejections from other concurrent work are ignored and do not * force a spurious fallback to the basic extractor. After Defuddle resolves we * flush a microtask and a macrotask so any rejection Defuddle scheduled settles * inside the armed window; a rejection observed there is re-thrown so the * caller falls back. Deeply-nested timers in Defuddle are out of scope and would * still surface as a logged (non-crashing) unhandled rejection. * * @param document - The normalized document to parse * @param pageUrl - The absolute URL of the page, used to resolve relative links * @returns The Defuddle result, or `undefined` when extraction must fall back */ async function runDefuddle( document: Document, pageUrl: string, ): Promise { let escapedRejection: unknown = undefined; let armed = true; // Only attribute a rejection to Defuddle when it mentions Defuddle. This keeps // the guard scoped so unrelated concurrent rejections are ignored. const captureUnhandled = (cause: unknown): void => { if (!armed || escapedRejection !== undefined) return; const detail = cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause); if (/defuddle/i.test(detail)) escapedRejection = cause; }; process.on("unhandledRejection", captureUnhandled); try { const { Defuddle } = await import("defuddle/node"); const result = await Defuddle(document, pageUrl, { markdown: true, useAsync: false }); // Let Defuddle's scheduled microtask/macrotask work settle so a detached // rejection is observed by the guard instead of reaching the harness. await Promise.resolve(); await new Promise((resolve) => setImmediate(resolve)); if (escapedRejection !== undefined) throw escapedRejection; return result; } catch { return undefined; } finally { armed = false; process.off("unhandledRejection", captureUnhandled); } } /** Discovery links advertised by a page via `` relations. */ interface AdvertisedLinks { describedByLink?: string; markdownAlternateLink?: string; } /** * Reads agent-discovery `` relations from an HTML document (llmstxt.org v2): * `rel="describedby"` points at the covering `llms.txt`, and * `rel="alternate" type="text/markdown"` points at the Markdown version of the page. * HREFs are resolved against the page URL; absent or malformed links are omitted. */ function readAdvertisedLinks( document: ReturnType["document"], baseUrl: URL, ): AdvertisedLinks { const resolve = (href: string | null): string | undefined => { if (!href) return undefined; try { return new URL(href, baseUrl.href).href; } catch { return undefined; } }; let describedByLink: string | undefined; let markdownAlternateLink: string | undefined; for (const link of document.querySelectorAll("link")) { const href = resolve(link.getAttribute("href")); if (!href) continue; const tokens = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/); if (!describedByLink && tokens.includes("describedby")) describedByLink = href; if ( !markdownAlternateLink && tokens.includes("alternate") && (link.getAttribute("type") ?? "").toLowerCase() === "text/markdown" ) { markdownAlternateLink = href; } } return { describedByLink, markdownAlternateLink }; } /** * Extracts readable Markdown and an optional title from HTML. * * @param html - The HTML document to convert * @param baseUrl - The absolute base URL used to resolve document-relative links * @returns The extracted Markdown, optional title, extractor used, and any advertised * discovery links resolved against the base URL */ export async function extractHtmlToMarkdown( html: string, baseUrl: URL, ): Promise<{ markdown: string; title?: string; extractor: "defuddle" | "basic"; describedByLink?: string; markdownAlternateLink?: string; }> { let advertised: AdvertisedLinks = {}; try { const { document } = parseHTML(html); removeMalformedSchemaOrgData(document); normalizeSelectorUnsafeIds(document); advertised = readAdvertisedLinks(document, baseUrl); // Pass the full absolute URL so Defuddle resolves relative links (e.g. // `/owner/repo/releases`) and metadata against the real origin instead of // dropping the scheme and host and constructing `new URL(pathname)`. const result = await runDefuddle(document, baseUrl.href); const markdown = result?.content?.trim() ?? ""; const trimmedTitle = result?.title?.trim(); if (markdown) { return { markdown, title: trimmedTitle || undefined, extractor: "defuddle", ...advertised, }; } } catch { // Fall through to the basic converter for malformed or unsupported pages. } return { markdown: htmlToMarkdownFallback(html), extractor: "basic", ...advertised }; }