utils.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import { Readability } from '@mozilla/readability';
  2. import TurndownService from 'turndown';
  3. import { escapeHtml } from '../../utils/escape-html';
  4. import { parseFrontmatter } from '../../utils/frontmatter';
  5. import type { CachedFetch, ExtractedContent } from './types';
  6. export { escapeHtml, parseFrontmatter };
  7. const CSS_TREE_WARN_PREFIX = '[csstree-match]';
  8. /**
  9. * Suppresses css-tree lexer warnings ([csstree-match] prefix) emitted
  10. * synchronously during JSDOM construction (jsdom uses css-tree to parse
  11. * stylesheets; css-tree calls the global console.warn directly, bypassing
  12. * jsdom's virtualConsole). Other warnings pass through untouched.
  13. */
  14. export function withCssTreeWarningsSuppressed<T>(fn: () => T): T {
  15. const originalWarn = console.warn;
  16. console.warn = ((...args: unknown[]) => {
  17. const first = typeof args[0] === 'string' ? args[0] : '';
  18. if (!first.startsWith(CSS_TREE_WARN_PREFIX)) originalWarn(...args);
  19. }) as typeof console.warn;
  20. try {
  21. return fn();
  22. } finally {
  23. console.warn = originalWarn;
  24. }
  25. }
  26. let jsdomPromise: Promise<typeof import('jsdom')> | undefined;
  27. async function getJSDOM() {
  28. jsdomPromise ??= import('jsdom');
  29. const { JSDOM } = await jsdomPromise;
  30. return JSDOM;
  31. }
  32. export function wordCount(text: string): number {
  33. const trimmed = text.trim();
  34. if (!trimmed) return 0;
  35. return trimmed.split(/\s+/).length;
  36. }
  37. function byteLength(text: string) {
  38. return Buffer.byteLength(text || '', 'utf8');
  39. }
  40. function quote(value: unknown) {
  41. return JSON.stringify(value ?? '');
  42. }
  43. export function frontmatter(metadata: Record<string, unknown>): string {
  44. const lines = ['---'];
  45. for (const [key, value] of Object.entries(metadata)) {
  46. if (value === undefined) continue;
  47. if (Array.isArray(value)) {
  48. if (value.length === 0) {
  49. lines.push(`${key}: []`);
  50. continue;
  51. }
  52. lines.push(`${key}:`);
  53. for (const item of value) lines.push(` - ${quote(item)}`);
  54. continue;
  55. }
  56. lines.push(`${key}: ${quote(value)}`);
  57. }
  58. lines.push('---', '', '');
  59. return lines.join('\n');
  60. }
  61. export function trimBlankRuns(input: string): string {
  62. return input.replace(/\n{3,}/g, '\n\n').trim();
  63. }
  64. function cleanExtractedText(input: string) {
  65. return trimBlankRuns(input);
  66. }
  67. function mapOutsideCodeBlocks(
  68. input: string,
  69. transform: (value: string) => string,
  70. ) {
  71. const parts = input.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g);
  72. return parts
  73. .map((part, index) => (index % 2 === 1 ? part : transform(part)))
  74. .join('');
  75. }
  76. function extractStructuredText(root: Element | null) {
  77. if (!root) return '';
  78. const chunks: string[] = [];
  79. const ignoredTags = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
  80. const blockTags = new Set([
  81. 'ARTICLE',
  82. 'ASIDE',
  83. 'BLOCKQUOTE',
  84. 'DIV',
  85. 'DL',
  86. 'DT',
  87. 'DD',
  88. 'FIGCAPTION',
  89. 'FIGURE',
  90. 'FOOTER',
  91. 'FORM',
  92. 'H1',
  93. 'H2',
  94. 'H3',
  95. 'H4',
  96. 'H5',
  97. 'H6',
  98. 'HEADER',
  99. 'HR',
  100. 'LI',
  101. 'MAIN',
  102. 'NAV',
  103. 'OL',
  104. 'P',
  105. 'PRE',
  106. 'SECTION',
  107. 'TABLE',
  108. 'TBODY',
  109. 'TD',
  110. 'TH',
  111. 'THEAD',
  112. 'TR',
  113. 'UL',
  114. ]);
  115. const isText = (node: Node) => node.nodeType === node.TEXT_NODE;
  116. const isElement = (node: Node) => node.nodeType === node.ELEMENT_NODE;
  117. const pushText = (value: string) => {
  118. const normalized = value.replace(/\s+/g, ' ');
  119. if (!normalized.trim()) return;
  120. const previous = chunks[chunks.length - 1];
  121. if (!previous || /\n$| $/.test(previous)) {
  122. chunks.push(normalized.trimStart());
  123. } else {
  124. chunks.push(normalized);
  125. }
  126. };
  127. const pushBreak = (count = 1) => {
  128. const wanted = '\n'.repeat(count);
  129. const last = chunks[chunks.length - 1] || '';
  130. const trailing = last.match(/\n+$/)?.[0].length || 0;
  131. if (trailing >= count) return;
  132. if (trailing > 0) {
  133. chunks[chunks.length - 1] = last.replace(/\n+$/, '') + wanted;
  134. return;
  135. }
  136. chunks.push(wanted);
  137. };
  138. const visit = (node: Node) => {
  139. if (isText(node)) {
  140. pushText(node.textContent || '');
  141. return;
  142. }
  143. if (!isElement(node)) return;
  144. const element = node as Element;
  145. const tag = element.tagName;
  146. if (ignoredTags.has(tag)) return;
  147. if (tag === 'BR') {
  148. pushBreak(1);
  149. return;
  150. }
  151. if (tag === 'PRE') {
  152. const text = trimBlankRuns(element.textContent || '');
  153. if (!text) return;
  154. pushBreak(2);
  155. chunks.push(text);
  156. pushBreak(2);
  157. return;
  158. }
  159. const isBlock = blockTags.has(tag);
  160. if (isBlock) pushBreak(tag === 'LI' ? 1 : 2);
  161. if (tag === 'LI') chunks.push('- ');
  162. for (const child of element.childNodes) visit(child);
  163. if (isBlock) pushBreak(tag === 'LI' ? 1 : 2);
  164. };
  165. visit(root);
  166. return cleanExtractedText(chunks.join(''));
  167. }
  168. export function cleanHeadingText(input: string): string {
  169. const normalized = trimBlankRuns(input).replace(/¶+$/g, '').trim();
  170. if (/^(?:C|F)#$/.test(normalized)) return normalized;
  171. if (/\s#+$/.test(normalized)) {
  172. return normalized.replace(/\s#+$/g, '').trim();
  173. }
  174. return normalized;
  175. }
  176. export function cleanFetchedMarkdown(input: string): string {
  177. const output = mapOutsideCodeBlocks(input, (value) =>
  178. value
  179. .replace(/^\s*!\[[^\]]*\]\([^)]+\)\s*$/gm, 'Image omitted')
  180. .replace(/(^|\n)Image(?=\n|$)/g, '$1Image omitted')
  181. .replace(/^\s*(#{1,6})\s*\\?\['([^'\n]+)'\s*$/gm, '$1 $2')
  182. .replace(/^\s*(#{1,6})\s*'([^'\n]+)'\]\s*$/gm, '$1 $2')
  183. .replace(/^\s*(#{1,6})\s*'([^'\n]+)'\s*$/gm, '$1 $2')
  184. .replace(/(#{1,6}[^\n]*?)\s*\[¶\]\(#.*?"Permanent link"\)\s*$/gm, '$1')
  185. .replace(/\s+\(#[A-Za-z0-9_-]+\)\s*$/gm, ''),
  186. );
  187. return trimBlankRuns(output);
  188. }
  189. export function cleanFetchedText(input: string): string {
  190. return trimBlankRuns(input);
  191. }
  192. export function withTruncationMarker(
  193. content: string,
  194. format: 'text' | 'markdown' | 'html',
  195. truncated: boolean,
  196. ): string {
  197. if (!truncated) return content;
  198. if (format === 'html') return `${content}\n<!-- [..content truncated..] -->`;
  199. return `${content}\n\n[..content truncated..]`;
  200. }
  201. export function joinRenderedContent(
  202. metadata: string,
  203. content: string,
  204. format: 'text' | 'markdown' | 'html',
  205. ): string {
  206. if (!metadata) return content;
  207. if (!content) {
  208. return format === 'html' ? `<!--\n${metadata.trim()}\n-->` : metadata;
  209. }
  210. if (format === 'html') {
  211. const comment = `<!--\n${metadata.trim()}\n-->\n`;
  212. const xmlDecl = content.match(/^\s*(<\?xml[\s\S]*?\?>\s*)/i);
  213. if (xmlDecl) {
  214. return `${xmlDecl[1]}${comment}${content.slice(xmlDecl[0].length)}`;
  215. }
  216. return `${comment}${content}`;
  217. }
  218. const startsWithFrontmatter = /^---(?:\r?\n|$)/.test(content);
  219. if (!startsWithFrontmatter) return `${metadata}${content}`;
  220. return `${metadata}Source content:\n\n${content}`;
  221. }
  222. export function renderMessageForFormat(
  223. content: string,
  224. format: 'text' | 'markdown' | 'html',
  225. ): string {
  226. if (format === 'html') return `<pre>${escapeHtml(content)}</pre>`;
  227. return content;
  228. }
  229. export function buildRedirectResultMessage(
  230. originalUrl: string,
  231. redirectUrl: string,
  232. statusCode: number,
  233. ) {
  234. return [
  235. 'Redirect was blocked by policy.',
  236. `Original URL: ${originalUrl}`,
  237. `Redirect URL: ${redirectUrl}`,
  238. `Status: ${statusCode}`,
  239. '',
  240. 'Re-run webfetch with the redirect URL to continue.',
  241. ].join('\n');
  242. }
  243. export function buildLlmsRequiredMessage(originalUrl: string, reason?: string) {
  244. return [
  245. 'Required llms.txt content was unavailable.',
  246. `Original URL: ${originalUrl}`,
  247. ...(reason ? [`Reason: ${reason}`] : []),
  248. ].join('\n');
  249. }
  250. const turndown = new TurndownService({
  251. headingStyle: 'atx',
  252. bulletListMarker: '-',
  253. codeBlockStyle: 'fenced',
  254. });
  255. turndown.remove(['script', 'style', 'noscript', 'meta', 'link']);
  256. turndown.remove(
  257. (node: unknown) =>
  258. (node as Element).nodeName === 'A' &&
  259. /permanent link/i.test((node as Element).getAttribute('title') || ''),
  260. );
  261. turndown.addRule('fenced-pre-code', {
  262. filter(node: unknown) {
  263. return (
  264. (node as Element).nodeName === 'PRE' &&
  265. !!(node as Element).querySelector('code')
  266. );
  267. },
  268. replacement(_content: string, node: unknown) {
  269. const code = (node as Element).querySelector('code');
  270. const text = trimBlankRuns(
  271. code?.textContent || (node as Element).textContent || '',
  272. );
  273. if (!text) return '';
  274. return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`;
  275. },
  276. });
  277. export async function extractFromHtml(
  278. html: string,
  279. finalUrl: string,
  280. extractMain: boolean,
  281. ): Promise<ExtractedContent> {
  282. const JSDOM = await getJSDOM();
  283. const dom = withCssTreeWarningsSuppressed(
  284. () => new JSDOM(html, { url: finalUrl }),
  285. );
  286. const document = dom.window.document;
  287. const title = document.title || undefined;
  288. const canonical =
  289. document.querySelector('link[rel="canonical"]')?.getAttribute('href') ||
  290. undefined;
  291. const canonicalUrl = (() => {
  292. if (!canonical) return undefined;
  293. try {
  294. return new URL(canonical, finalUrl).toString();
  295. } catch {
  296. return undefined;
  297. }
  298. })();
  299. const headings = Array.from(
  300. document.querySelectorAll<HTMLElement>('h1, h2, h3'),
  301. )
  302. .map((node) => cleanHeadingText(node.textContent || ''))
  303. .filter(Boolean)
  304. .slice(0, 12);
  305. if (extractMain) {
  306. const readerDom = withCssTreeWarningsSuppressed(
  307. () => new JSDOM(html, { url: finalUrl }),
  308. );
  309. const article = new Readability(readerDom.window.document).parse();
  310. if (article?.content?.trim()) {
  311. const articleContainer = readerDom.window.document.createElement('div');
  312. articleContainer.innerHTML = article.content;
  313. const articleText = extractStructuredText(articleContainer);
  314. const articleMarkdown = trimBlankRuns(turndown.turndown(article.content));
  315. return {
  316. title: article.title || title,
  317. rawContent: html,
  318. html: article.content,
  319. text: articleText,
  320. markdown: articleMarkdown,
  321. extractedMain: true,
  322. canonicalUrl,
  323. headings,
  324. };
  325. }
  326. }
  327. const bodyHtml = document.body?.innerHTML || html;
  328. const bodyText = extractStructuredText(document.body);
  329. const markdown = trimBlankRuns(turndown.turndown(bodyHtml));
  330. return {
  331. title,
  332. rawContent: html,
  333. html: bodyHtml,
  334. text: bodyText,
  335. markdown,
  336. extractedMain: false,
  337. canonicalUrl,
  338. headings,
  339. };
  340. }
  341. export function inferCanonicalUrlFromText(content: string, finalUrl: string) {
  342. const frontmatterData = parseFrontmatter(content);
  343. const raw = frontmatterData?.url;
  344. if (!raw) return undefined;
  345. try {
  346. return new URL(raw, finalUrl).toString();
  347. } catch {
  348. return undefined;
  349. }
  350. }
  351. export function extractHeadingsFromMarkdown(content: string) {
  352. const headings = content
  353. .split(/\r?\n/)
  354. .filter((line) => /^#{1,6}\s+/.test(line))
  355. .map((line) => cleanHeadingText(line.replace(/^#{1,6}\s+/, '')))
  356. .filter(Boolean)
  357. .slice(0, 12);
  358. return headings.length ? headings : undefined;
  359. }
  360. export function detectQualitySignals(
  361. fetchResult: Pick<
  362. CachedFetch,
  363. | 'text'
  364. | 'markdown'
  365. | 'rawContent'
  366. | 'wordCount'
  367. | 'sourceKind'
  368. | 'extractedMain'
  369. >,
  370. ) {
  371. const signals = new Set<string>();
  372. const text = `${fetchResult.text}\n${fetchResult.markdown}`.toLowerCase();
  373. if (fetchResult.wordCount > 0 && fetchResult.wordCount < 60) {
  374. signals.add('very_short_content');
  375. }
  376. if (
  377. /(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(
  378. text,
  379. )
  380. ) {
  381. signals.add('possible_paywall');
  382. }
  383. if (fetchResult.sourceKind === 'html') {
  384. const renderedBytes = Math.max(byteLength(fetchResult.text), 1);
  385. const rawBytes = byteLength(fetchResult.rawContent);
  386. const ratio = rawBytes / renderedBytes;
  387. if (
  388. !fetchResult.extractedMain &&
  389. ratio >= 10 &&
  390. fetchResult.wordCount < 1200
  391. ) {
  392. signals.add('high_boilerplate_ratio');
  393. }
  394. }
  395. return [...signals];
  396. }
  397. export function pickContent(
  398. fetchResult: CachedFetch,
  399. format: 'text' | 'markdown' | 'html',
  400. ) {
  401. if (format === 'html') {
  402. if (fetchResult.sourceKind === 'html') {
  403. const htmlContent = fetchResult.extractedMain
  404. ? fetchResult.html
  405. : fetchResult.rawContent;
  406. return withTruncationMarker(htmlContent, format, fetchResult.truncated);
  407. }
  408. return withTruncationMarker(
  409. renderMessageForFormat(
  410. fetchResult.text || fetchResult.rawContent,
  411. format,
  412. ),
  413. format,
  414. fetchResult.truncated,
  415. );
  416. }
  417. if (format === 'text') {
  418. return withTruncationMarker(
  419. cleanFetchedText(fetchResult.text),
  420. format,
  421. fetchResult.truncated,
  422. );
  423. }
  424. return withTruncationMarker(
  425. cleanFetchedMarkdown(fetchResult.markdown),
  426. format,
  427. fetchResult.truncated,
  428. );
  429. }