import { Readability } from '@mozilla/readability'; import TurndownService from 'turndown'; import type { CachedFetch, ExtractedContent } from './types'; let jsdomPromise: Promise | undefined; async function getJSDOM() { jsdomPromise ??= import('jsdom'); const { JSDOM } = await jsdomPromise; return JSDOM; } export function wordCount(text: string) { const trimmed = text.trim(); if (!trimmed) return 0; return trimmed.split(/\s+/).length; } function byteLength(text: string) { return Buffer.byteLength(text || '', 'utf8'); } function quote(value: unknown) { return JSON.stringify(value ?? ''); } export function frontmatter(metadata: Record) { const lines = ['---']; for (const [key, value] of Object.entries(metadata)) { if (value === undefined) continue; if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`); continue; } lines.push(`${key}:`); for (const item of value) lines.push(` - ${quote(item)}`); continue; } lines.push(`${key}: ${quote(value)}`); } lines.push('---', '', ''); return lines.join('\n'); } export function trimBlankRuns(input: string) { return input.replace(/\n{3,}/g, '\n\n').trim(); } function cleanExtractedText(input: string) { return trimBlankRuns(input); } function mapOutsideCodeBlocks( input: string, transform: (value: string) => string, ) { const parts = input.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g); return parts .map((part, index) => (index % 2 === 1 ? part : transform(part))) .join(''); } function extractStructuredText(root: Element | null) { if (!root) return ''; const chunks: string[] = []; const ignoredTags = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']); const blockTags = new Set([ 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DIV', 'DL', 'DT', 'DD', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TH', 'THEAD', 'TR', 'UL', ]); const isText = (node: Node) => node.nodeType === node.TEXT_NODE; const isElement = (node: Node) => node.nodeType === node.ELEMENT_NODE; const pushText = (value: string) => { const normalized = value.replace(/\s+/g, ' '); if (!normalized.trim()) return; const previous = chunks[chunks.length - 1]; if (!previous || /\n$| $/.test(previous)) { chunks.push(normalized.trimStart()); } else { chunks.push(normalized); } }; const pushBreak = (count = 1) => { const wanted = '\n'.repeat(count); const last = chunks[chunks.length - 1] || ''; const trailing = last.match(/\n+$/)?.[0].length || 0; if (trailing >= count) return; if (trailing > 0) { chunks[chunks.length - 1] = last.replace(/\n+$/, '') + wanted; return; } chunks.push(wanted); }; const visit = (node: Node) => { if (isText(node)) { pushText(node.textContent || ''); return; } if (!isElement(node)) return; const element = node as Element; const tag = element.tagName; if (ignoredTags.has(tag)) return; if (tag === 'BR') { pushBreak(1); return; } if (tag === 'PRE') { const text = trimBlankRuns(element.textContent || ''); if (!text) return; pushBreak(2); chunks.push(text); pushBreak(2); return; } const isBlock = blockTags.has(tag); if (isBlock) pushBreak(tag === 'LI' ? 1 : 2); if (tag === 'LI') chunks.push('- '); for (const child of element.childNodes) visit(child); if (isBlock) pushBreak(tag === 'LI' ? 1 : 2); }; visit(root); return cleanExtractedText(chunks.join('')); } export function cleanHeadingText(input: string) { const normalized = trimBlankRuns(input).replace(/¶+$/g, '').trim(); if (/^(?:C|F)#$/.test(normalized)) return normalized; if (/\s#+$/.test(normalized)) { return normalized.replace(/\s#+$/g, '').trim(); } return normalized; } export function cleanFetchedMarkdown(input: string) { const output = mapOutsideCodeBlocks(input, (value) => value .replace(/^\s*!\[[^\]]*\]\([^)]+\)\s*$/gm, 'Image omitted') .replace(/(^|\n)Image(?=\n|$)/g, '$1Image omitted') .replace(/^\s*(#{1,6})\s*\\?\['([^'\n]+)'\s*$/gm, '$1 $2') .replace(/^\s*(#{1,6})\s*'([^'\n]+)'\]\s*$/gm, '$1 $2') .replace(/^\s*(#{1,6})\s*'([^'\n]+)'\s*$/gm, '$1 $2') .replace(/(#{1,6}[^\n]*?)\s*\[¶\]\(#.*?"Permanent link"\)\s*$/gm, '$1') .replace(/\s+\(#[A-Za-z0-9_-]+\)\s*$/gm, ''), ); return trimBlankRuns(output); } export function cleanFetchedText(input: string) { return trimBlankRuns(input); } export function escapeHtml(input: string) { return input .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function withTruncationMarker( content: string, format: 'text' | 'markdown' | 'html', truncated: boolean, ) { if (!truncated) return content; if (format === 'html') return `${content}\n`; return `${content}\n\n[..content truncated..]`; } export function joinRenderedContent( metadata: string, content: string, format: 'text' | 'markdown' | 'html', ) { if (!metadata) return content; if (!content) { return format === 'html' ? `` : metadata; } if (format === 'html') { const comment = `\n`; const xmlDecl = content.match(/^\s*(<\?xml[\s\S]*?\?>\s*)/i); if (xmlDecl) { return `${xmlDecl[1]}${comment}${content.slice(xmlDecl[0].length)}`; } return `${comment}${content}`; } const startsWithFrontmatter = /^---(?:\r?\n|$)/.test(content); if (!startsWithFrontmatter) return `${metadata}${content}`; return `${metadata}Source content:\n\n${content}`; } export function renderMessageForFormat( content: string, format: 'text' | 'markdown' | 'html', ) { if (format === 'html') return `
${escapeHtml(content)}
`; return content; } export function buildRedirectResultMessage( originalUrl: string, redirectUrl: string, statusCode: number, ) { return [ 'Redirect was blocked by policy.', `Original URL: ${originalUrl}`, `Redirect URL: ${redirectUrl}`, `Status: ${statusCode}`, '', 'Re-run webfetch with the redirect URL to continue.', ].join('\n'); } export function buildLlmsRequiredMessage(originalUrl: string, reason?: string) { return [ 'Required llms.txt content was unavailable.', `Original URL: ${originalUrl}`, ...(reason ? [`Reason: ${reason}`] : []), ].join('\n'); } const turndown = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced', }); turndown.remove(['script', 'style', 'noscript', 'meta', 'link']); turndown.remove( (node: unknown) => (node as Element).nodeName === 'A' && /permanent link/i.test((node as Element).getAttribute('title') || ''), ); turndown.addRule('fenced-pre-code', { filter(node: unknown) { return ( (node as Element).nodeName === 'PRE' && !!(node as Element).querySelector('code') ); }, replacement(_content: string, node: unknown) { const code = (node as Element).querySelector('code'); const text = trimBlankRuns( code?.textContent || (node as Element).textContent || '', ); if (!text) return ''; return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`; }, }); export async function extractFromHtml( html: string, finalUrl: string, extractMain: boolean, ): Promise { const JSDOM = await getJSDOM(); const dom = new JSDOM(html, { url: finalUrl }); const document = dom.window.document; const title = document.title || undefined; const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href') || undefined; const canonicalUrl = (() => { if (!canonical) return undefined; try { return new URL(canonical, finalUrl).toString(); } catch { return undefined; } })(); const headings = Array.from( document.querySelectorAll('h1, h2, h3'), ) .map((node) => cleanHeadingText(node.textContent || '')) .filter(Boolean) .slice(0, 12); if (extractMain) { const readerDom = new JSDOM(html, { url: finalUrl }); const article = new Readability(readerDom.window.document).parse(); if (article?.content?.trim()) { const articleContainer = readerDom.window.document.createElement('div'); articleContainer.innerHTML = article.content; const articleText = extractStructuredText(articleContainer); const articleMarkdown = trimBlankRuns(turndown.turndown(article.content)); return { title: article.title || title, rawContent: html, html: article.content, text: articleText, markdown: articleMarkdown, extractedMain: true, canonicalUrl, headings, }; } } const bodyHtml = document.body?.innerHTML || html; const bodyText = extractStructuredText(document.body); const markdown = trimBlankRuns(turndown.turndown(bodyHtml)); return { title, rawContent: html, html: bodyHtml, text: bodyText, markdown, extractedMain: false, canonicalUrl, headings, }; } function parseFrontmatterBlock(content: string) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) return undefined; const result: Record = {}; for (const line of match[1].split(/\r?\n/)) { const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.+?)\s*$/); if (!kv) continue; result[kv[1]] = kv[2].replace(/^(['"])(.*)\1$/, '$2'); } return result; } export function inferCanonicalUrlFromText(content: string, finalUrl: string) { const frontmatter = parseFrontmatterBlock(content); const raw = frontmatter?.url; if (!raw) return undefined; try { return new URL(raw, finalUrl).toString(); } catch { return undefined; } } export function extractHeadingsFromMarkdown(content: string) { const headings = content .split(/\r?\n/) .filter((line) => /^#{1,6}\s+/.test(line)) .map((line) => cleanHeadingText(line.replace(/^#{1,6}\s+/, ''))) .filter(Boolean) .slice(0, 12); return headings.length ? headings : undefined; } export function detectQualitySignals( fetchResult: Pick< CachedFetch, | 'text' | 'markdown' | 'rawContent' | 'wordCount' | 'sourceKind' | 'extractedMain' >, ) { const signals = new Set(); const text = `${fetchResult.text}\n${fetchResult.markdown}`.toLowerCase(); if (fetchResult.wordCount > 0 && fetchResult.wordCount < 60) { signals.add('very_short_content'); } if ( /(subscribe to continue|subscription required|sign in to continue|log in to continue|create an account to continue|members only|premium content|paywall)/i.test( text, ) ) { signals.add('possible_paywall'); } if (fetchResult.sourceKind === 'html') { const renderedBytes = Math.max(byteLength(fetchResult.text), 1); const rawBytes = byteLength(fetchResult.rawContent); const ratio = rawBytes / renderedBytes; if ( !fetchResult.extractedMain && ratio >= 10 && fetchResult.wordCount < 1200 ) { signals.add('high_boilerplate_ratio'); } } return [...signals]; } export function pickContent( fetchResult: CachedFetch, format: 'text' | 'markdown' | 'html', ) { if (format === 'html') { if (fetchResult.sourceKind === 'html') { const htmlContent = fetchResult.extractedMain ? fetchResult.html : fetchResult.rawContent; return withTruncationMarker(htmlContent, format, fetchResult.truncated); } return withTruncationMarker( renderMessageForFormat( fetchResult.text || fetchResult.rawContent, format, ), format, fetchResult.truncated, ); } if (format === 'text') { return withTruncationMarker( cleanFetchedText(fetchResult.text), format, fetchResult.truncated, ); } return withTruncationMarker( cleanFetchedMarkdown(fetchResult.markdown), format, fetchResult.truncated, ); }