/** * HTML → Markdown extraction for crawled pages — matches crawl4AI's markdown * output capability. Two-tier, mirroring crawl4AI's "markdown" vs "fit markdown": * * - "fit" markdown: Readability's best guess at the primary content (title + * article body), stripped of nav/ads/chrome. Ideal for content/marketing * pages and RAG chunking. null when Readability finds no article (most * app UI screens — dashboards, forms — legitimately have none). * - "full" markdown: the whole visible page converted straight to Markdown, * always present. The fallback for app screens where there's no "article". * * Runs entirely on an already-captured `page.content()` string — no extra * network fetch, no extra page navigation. */ import { parseHTML } from 'linkedom'; import { Readability } from '@mozilla/readability'; import TurndownService from 'turndown'; const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); turndown.remove(['script', 'style', 'noscript', 'svg', 'iframe'] as unknown as (keyof HTMLElementTagNameMap)[]); export interface ExtractedMarkdown { markdown: string; method: 'readability' | 'full-page'; title: string | null; wordCount: number; } function stripNoiseElements(document: Document): void { const noisy = document.querySelectorAll('script, style, noscript, svg, iframe, [aria-hidden="true"]'); noisy.forEach((el: any) => el.remove()); } /** Best-effort HTML→Markdown for one crawled page. Never throws. */ export function extractMarkdown(html: string, url: string): ExtractedMarkdown | null { try { const { document } = parseHTML(html); stripNoiseElements(document); let readabilityResult: { title: string; content: string; textContent: string } | null = null; try { // Readability mutates the DOM it's given — clone via re-parsing so the // full-page fallback below still has an intact document. const { document: readerDoc } = parseHTML(html); stripNoiseElements(readerDoc); readabilityResult = new Readability(readerDoc as any, { charThreshold: 50 }).parse() as any; } catch { readabilityResult = null; } if (readabilityResult?.content && readabilityResult.textContent.trim().length >= 50) { const markdown = turndown.turndown(readabilityResult.content); return { markdown: `# ${readabilityResult.title || document.title || url}\n\n${markdown}`, method: 'readability', title: readabilityResult.title || document.title || null, wordCount: readabilityResult.textContent.trim().split(/\s+/).length, }; } const body = document.body; if (!body) return null; const markdown = turndown.turndown(body.innerHTML || ''); const text = markdown.trim(); if (!text) return null; const title = document.title || null; return { markdown: title ? `# ${title}\n\n${markdown}` : markdown, method: 'full-page', title, wordCount: text.split(/\s+/).length, }; } catch { return null; } }