utils.ts 12 KB

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