| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- import { Readability } from '@mozilla/readability';
- import TurndownService from 'turndown';
- import { escapeHtml } from '../../utils/escape-html';
- import { parseFrontmatter } from '../../utils/frontmatter';
- import type { CachedFetch, ExtractedContent } from './types';
- export { escapeHtml, parseFrontmatter };
- const CSS_TREE_WARN_PREFIX = '[csstree-match]';
- /**
- * Suppresses css-tree lexer warnings ([csstree-match] prefix) emitted
- * synchronously during JSDOM construction (jsdom uses css-tree to parse
- * stylesheets; css-tree calls the global console.warn directly, bypassing
- * jsdom's virtualConsole). Other warnings pass through untouched.
- */
- export function withCssTreeWarningsSuppressed<T>(fn: () => T): T {
- const originalWarn = console.warn;
- console.warn = ((...args: unknown[]) => {
- const first = typeof args[0] === 'string' ? args[0] : '';
- if (!first.startsWith(CSS_TREE_WARN_PREFIX)) originalWarn(...args);
- }) as typeof console.warn;
- try {
- return fn();
- } finally {
- console.warn = originalWarn;
- }
- }
- let jsdomPromise: Promise<typeof import('jsdom')> | undefined;
- async function getJSDOM() {
- jsdomPromise ??= import('jsdom');
- const { JSDOM } = await jsdomPromise;
- return JSDOM;
- }
- export function wordCount(text: string): number {
- 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<string, unknown>): string {
- 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): 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): 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): 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): string {
- return trimBlankRuns(input);
- }
- export function withTruncationMarker(
- content: string,
- format: 'text' | 'markdown' | 'html',
- truncated: boolean,
- ): string {
- if (!truncated) return content;
- if (format === 'html') return `${content}\n<!-- [..content truncated..] -->`;
- return `${content}\n\n[..content truncated..]`;
- }
- export function joinRenderedContent(
- metadata: string,
- content: string,
- format: 'text' | 'markdown' | 'html',
- ): string {
- if (!metadata) return content;
- if (!content) {
- return format === 'html' ? `<!--\n${metadata.trim()}\n-->` : metadata;
- }
- if (format === 'html') {
- const comment = `<!--\n${metadata.trim()}\n-->\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',
- ): string {
- if (format === 'html') return `<pre>${escapeHtml(content)}</pre>`;
- 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<ExtractedContent> {
- const JSDOM = await getJSDOM();
- const dom = withCssTreeWarningsSuppressed(
- () => 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<HTMLElement>('h1, h2, h3'),
- )
- .map((node) => cleanHeadingText(node.textContent || ''))
- .filter(Boolean)
- .slice(0, 12);
- if (extractMain) {
- const readerDom = withCssTreeWarningsSuppressed(
- () => 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,
- };
- }
- export function inferCanonicalUrlFromText(content: string, finalUrl: string) {
- const frontmatterData = parseFrontmatter(content);
- const raw = frontmatterData?.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<string>();
- 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,
- );
- }
|