import { Readability } from "@mozilla/readability"; import { parseHTML } from "linkedom"; import TurndownService from "turndown"; export interface MarkdownExtraction { title: string; markdown: string; } const REMOVE_SELECTORS = [ "script", "style", "iframe", "frame", "object", "embed", "form", "input", "button", "select", "textarea", "template", "noscript", ].join(","); function safeAbsoluteUrl(value: string, baseUrl: string, allowFragment: boolean): string | undefined { const trimmed = value.trim(); if (!trimmed) return undefined; if (allowFragment && trimmed.startsWith("#")) return trimmed; try { const resolved = new URL(trimmed, baseUrl); if (resolved.protocol === "http:" || resolved.protocol === "https:") return resolved.toString(); if (allowFragment && resolved.protocol === "mailto:") return resolved.toString(); } catch { // Invalid links are removed below. } return undefined; } function sanitizeDocument(document: Document, baseUrl: string): void { for (const node of Array.from(document.querySelectorAll(REMOVE_SELECTORS))) node.remove(); for (const element of Array.from(document.querySelectorAll("*"))) { for (const attribute of Array.from(element.attributes)) { const name = attribute.name.toLowerCase(); if (name.startsWith("on") || name === "srcdoc") element.removeAttribute(attribute.name); } } for (const element of Array.from(document.querySelectorAll("a[href], area[href]"))) { const value = element.getAttribute("href"); const safe = value ? safeAbsoluteUrl(value, baseUrl, true) : undefined; if (safe) element.setAttribute("href", safe); else element.removeAttribute("href"); } for (const element of Array.from(document.querySelectorAll("img[src], source[src], video[src], audio[src]"))) { const value = element.getAttribute("src"); const safe = value ? safeAbsoluteUrl(value, baseUrl, false) : undefined; if (safe) element.setAttribute("src", safe); else element.removeAttribute("src"); } for (const element of Array.from(document.querySelectorAll("img[srcset], source[srcset]"))) { const srcset = element.getAttribute("srcset") ?? ""; const rewritten = srcset .split(",") .map((candidate: string) => candidate.trim()) .filter(Boolean) .flatMap((candidate: string) => { const [rawUrl, ...descriptor] = candidate.split(/\s+/); const safe = rawUrl ? safeAbsoluteUrl(rawUrl, baseUrl, false) : undefined; return safe ? [`${safe}${descriptor.length > 0 ? ` ${descriptor.join(" ")}` : ""}`] : []; }); if (rewritten.length > 0) element.setAttribute("srcset", rewritten.join(", ")); else element.removeAttribute("srcset"); } } function cleanTitle(value: string | null | undefined, fallback: string): string { const title = value?.replace(/\s+/g, " ").trim(); return title || fallback; } function titleFromUrl(url: string): string { const parsed = new URL(url); return parsed.pathname.split("/").filter(Boolean).at(-1) || parsed.hostname; } function normalizeMarkdown(markdown: string): string { return markdown .replace(/\u00a0/g, " ") .replace(/[ \t]+$/gm, "") .replace(/\n{4,}/g, "\n\n\n") .trim(); } export function extractMarkdownFromHtml(html: string, finalUrl: string): MarkdownExtraction { const parsed = parseHTML(html); const document = parsed.document as unknown as Document; sanitizeDocument(document, finalUrl); const documentTitle = cleanTitle(document.querySelector("title")?.textContent, titleFromUrl(finalUrl)); const article = new Readability(document).parse(); const title = cleanTitle(article?.title, documentTitle); const contentHtml = article?.content || document.body?.innerHTML || document.documentElement?.innerHTML || ""; const articleDocument = parseHTML(`
${contentHtml}
`).document as unknown as Document; sanitizeDocument(articleDocument, finalUrl); const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", emDelimiter: "*", strongDelimiter: "**", }); turndown.remove(["script", "style", "iframe", "object", "embed", "form"]); const body = articleDocument.querySelector("article")?.innerHTML ?? contentHtml; let markdown = normalizeMarkdown(turndown.turndown(body)); if (!markdown) markdown = title; if (!/^#\s+/m.test(markdown)) markdown = `# ${title}\n\n${markdown}`; return { title, markdown }; } export function markdownFromPlainText(text: string, finalUrl: string): MarkdownExtraction { const title = titleFromUrl(finalUrl); const markdown = normalizeMarkdown(text); return { title, markdown: /^#\s+/m.test(markdown) ? markdown : `# ${title}\n\n${markdown}` }; }