/** HTML → markdown via turndown + GFM. */ import TurndownService from "turndown"; import { gfm } from "turndown-plugin-gfm"; export function normalizeWhitespace(text: string): string { if (!/[\r\t]| \n|\n |\n{3,}| {2}/u.test(text)) { return text.trim(); } return text .replaceAll(/\r\n?/gu, "\n") .replaceAll(/[\t ]+/gu, " ") .replaceAll(/ *\n */gu, "\n") .replaceAll(/\n{3,}/gu, "\n\n") .trim(); } export interface MarkdownOptions { removeImages?: boolean; } function createMarkdownService(removeImages: boolean): TurndownService { const turndown = new TurndownService({ codeBlockStyle: "fenced", headingStyle: "atx", bulletListMarker: "-", emDelimiter: "_", strongDelimiter: "**", }); turndown.use(gfm); turndown.remove(["script", "style", "noscript", "template"]); if (removeImages) { turndown.addRule("removeImages", { filter: "img", replacement: () => "" }); } turndown.addRule("stableLinks", { filter: "a", replacement: (content, node) => { const href = (node as HTMLAnchorElement).getAttribute?.("href"); if (!href) return content; const label = content.trim().replaceAll(/\s+/gu, " "); return label ? `[${label}](${href})` : href; }, }); return turndown; } const keepImagesService = createMarkdownService(false); const removeImagesService = createMarkdownService(true); function stripLargeElements(html: string): string { if (html.length < 40_000) return html; const hasTable = html.includes("]/giu)?.length ?? 0; } if (hasList) { liCount = html.match(/]/giu)?.length ?? 0; } if (trCount < 20 && liCount < 100) return html; let result = html; if (trCount >= 20) { result = result.replaceAll(//giu, "\n\n"); } if (liCount >= 100) { result = result.replaceAll(/<(ul|ol)[\s\S]*?<\/(ul|ol)>/giu, "\n\n[Long list]\n\n"); } return result; } /** Drop inline data:/blob: images from HTML before turndown (Reddit/JS challenge pages). */ export function stripInlineMediaFromHtml(html: string): string { return html .replace(/]*\bsrc=["']data:[^"']*["'][^>]*>/giu, "") .replace(/]*\bsrc=["']blob:[^"']*["'][^>]*>/giu, "") .replace(/\bsrc=["']data:[^"']{200,}["']/giu, 'src=""') .replace(/\bhref=["']data:[^"']{200,}["']/giu, 'href=""'); } /** * Sanitize extracted text/markdown before it enters the model context. * Strips data-URI embeds and long base64 blobs that blow up token counts. */ export function sanitizeForContext(text: string): string { let out = text; // Markdown image embeds with data: or blob: out = out.replace(/!\[[\s\S]*?]\(\s*data:[\s\S]*?\)/gi, "[inline image omitted]"); out = out.replace(/!\[[\s\S]*?]\(\s*blob:[\s\S]*?\)/gi, "[inline image omitted]"); out = out.replace(/]*\bsrc=["']data:[\s\S]*?["'][^>]*>/gi, ""); // Bare data: URLs (including truncated challenge pages) out = out.replace( /\bdata:(?:image|application|font|audio|video)\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]{80,}/gi, "[base64 omitted]", ); // Long base64 payloads without a data: prefix (must include +/ to avoid URLs) out = out.replace(/\b(?=[A-Za-z0-9+/]*[+/])[A-Za-z0-9+/]{200,}={0,2}\b/g, "[base64 omitted]"); return out; } export function htmlToMarkdown(html: string, options: MarkdownOptions = {}): string { const service = options.removeImages ? removeImagesService : keepImagesService; const cleaned = stripInlineMediaFromHtml(stripLargeElements(html)); return sanitizeForContext(normalizeWhitespace(service.turndown(cleaned))); } /** Strip tags for plain text fallback. */ export function htmlToText(html: string): string { return sanitizeForContext( normalizeWhitespace( html .replace(//giu, " ") .replace(//giu, " ") .replace(/<[^>]+>/gu, " "), ), ); }