crawlforge-mcp-server 6.21.0 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +3 -2
  2. package/server.js +12 -12
  3. package/src/core/ActionExecutor.js +23 -4
  4. package/src/core/AgentOrchestrator.js +141 -19
  5. package/src/core/AuthManager.js +4 -1
  6. package/src/core/LLMsTxtAnalyzer.js +6 -7
  7. package/src/core/LocalizationManager.js +29 -4
  8. package/src/core/ResearchOrchestrator.js +48 -13
  9. package/src/core/SamplingClient.js +38 -8
  10. package/src/core/SnapshotManager.js +32 -13
  11. package/src/core/analysis/ContentAnalyzer.js +26 -25
  12. package/src/core/crawlers/BFSCrawler.js +48 -10
  13. package/src/core/processing/ContentProcessor.js +4 -2
  14. package/src/core/queue/QueueManager.js +16 -2
  15. package/src/resources/ResourceRegistry.js +19 -5
  16. package/src/schemas/toolOutputSchemas.js +23 -15
  17. package/src/server/fallbackHints.js +37 -7
  18. package/src/server/withAuth.js +27 -44
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  22. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
  26. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  28. package/src/tools/advanced/BrowserSessionTool.js +11 -2
  29. package/src/tools/advanced/batchScrape/queue.js +4 -2
  30. package/src/tools/advanced/batchScrape/worker.js +50 -11
  31. package/src/tools/agent/agent.js +1 -1
  32. package/src/tools/basic/_fetch.js +2 -1
  33. package/src/tools/basic/extractMetadata.js +2 -1
  34. package/src/tools/basic/extractText.js +12 -8
  35. package/src/tools/crawl/mapSite.js +8 -0
  36. package/src/tools/extract/extractContent.js +2 -1
  37. package/src/tools/extract/extractStructured.js +4 -2
  38. package/src/tools/extract/extractWithLlm.js +8 -4
  39. package/src/tools/extract/processDocument.js +29 -1
  40. package/src/tools/extract/summarizeContent.js +5 -1
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
  42. package/src/tools/scrape/_mainContent.js +54 -2
  43. package/src/tools/scrape/unifiedScrape.js +94 -6
  44. package/src/tools/search/adapters/braveSearch.js +27 -5
  45. package/src/tools/search/providers/searxng.js +20 -3
  46. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  47. package/src/tools/search/searchWeb.js +2 -2
  48. package/src/tools/tracking/trackChanges/differ.js +2 -1
  49. package/src/tools/tracking/trackChanges/index.js +6 -1
  50. package/src/tools/tracking/trackChanges/schema.js +15 -1
  51. package/src/utils/fetchError.js +47 -0
  52. package/src/utils/fetchLadder.js +5 -5
  53. package/src/utils/htmlToMarkdown.js +36 -2
  54. package/src/utils/ssrfGuard.js +2 -1
@@ -11,10 +11,14 @@ import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
11
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
13
  import { fetchResigned } from '../../../utils/resignedFetch.js';
14
+ import { fetchErrorMessage } from '../../../utils/fetchError.js';
14
15
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
16
+ import { extractMainContent, isThinMainContent, keepPageHeading } from '../../scrape/_mainContent.js';
15
17
  import { elementText } from '../../../utils/elementText.js';
16
18
  import { pageTitle } from '../../../utils/pageTitle.js';
17
19
  import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
20
+ import { requestContext } from '../../../server/requestContext.js';
21
+ import { classifyError, CLASS_HINTS, FALLBACK_HINTS } from '../../../server/fallbackHints.js';
18
22
 
19
23
  /**
20
24
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -57,7 +61,9 @@ export async function fetchUrl(url, options = {}) {
57
61
  } catch (error) {
58
62
  if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
59
63
  if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
60
- throw error;
64
+ const message = fetchErrorMessage(error, url);
65
+ if (message === error.message) throw error;
66
+ throw new Error(message, { cause: error });
61
67
  } finally {
62
68
  // Cleared only after the body read finishes (or the fetch itself fails),
63
69
  // so the abort timer keeps protecting against a trickling body, not just
@@ -103,13 +109,14 @@ export async function scrapeUrl(config, options, defaultTimeout) {
103
109
  result.extracted = extractStructuredData($, { ...config.selectors, ...options.extractionSchema });
104
110
  }
105
111
 
106
- result.content = generateFormats($, html, options.formats);
112
+ result.content = generateFormats($, html, options.formats, config.url);
107
113
  return result;
108
114
  } catch (error) {
109
115
  return {
110
116
  success: false,
111
117
  url: config.url,
112
118
  error: error.message,
119
+ next_step: failedEntryHint(error.message),
113
120
  timestamp: Date.now(),
114
121
  executionTime: Date.now() - startTime,
115
122
  metadata: config.metadata || {}
@@ -117,6 +124,17 @@ export async function scrapeUrl(config, options, defaultTimeout) {
117
124
  }
118
125
  }
119
126
 
127
+ /**
128
+ * The next step for one failed URL (R25 3.1): its error class's hint, else
129
+ * scrape's. A batch with failed URLs still succeeds, so withAuth never hints
130
+ * it. Classified from the message alone: the invocation's preflight-refusal
131
+ * flag belongs to the whole batch, and would give every URL one URL's refusal.
132
+ */
133
+ export function failedEntryHint(message) {
134
+ const c = requestContext.exit(() => classifyError(null, message));
135
+ return c ? CLASS_HINTS[c.cls]('batch_scrape', c.params) : FALLBACK_HINTS.scrape;
136
+ }
137
+
120
138
  function extractStructuredData($, selectors) {
121
139
  const extracted = {};
122
140
  for (const [key, selector] of Object.entries(selectors)) {
@@ -144,7 +162,7 @@ function valueText($, el) {
144
162
  : flattenText($, $el);
145
163
  }
146
164
 
147
- export function generateFormats($, html, formats) {
165
+ export function generateFormats($, html, formats, url) {
148
166
  // Script, style, noscript and template bodies are never rendered text.
149
167
  // Left in, the text format carried an entire theme-toggle script
150
168
  // (erlang.org/downloads) and CSS rules (opennet.ru) — R15, 2026-09-04.
@@ -153,7 +171,7 @@ export function generateFormats($, html, formats) {
153
171
  const content = {};
154
172
  if (formats.includes('html')) content.html = html;
155
173
  if (formats.includes('text')) content.text = flattenText($);
156
- if (formats.includes('markdown')) content.markdown = buildMarkdown($);
174
+ if (formats.includes('markdown')) content.markdown = buildMarkdown($, html, url);
157
175
  if (formats.includes('json')) {
158
176
  content.json = {
159
177
  title: pageTitle($),
@@ -166,8 +184,15 @@ export function generateFormats($, html, formats) {
166
184
  return content;
167
185
  }
168
186
 
169
- function buildMarkdown($) {
170
- const title = pageTitle($);
187
+ /**
188
+ * The page's main content, by the same pass scrape's markdown uses: converting
189
+ * the whole <main> handed back Wikipedia's language list and menus (R25,
190
+ * 2026-10-19). A page Readability finds no article in, or only a thin
191
+ * fragment of, falls back to the first content container below.
192
+ */
193
+ function mainContentHtml($, html, url) {
194
+ const main = extractMainContent(html, url);
195
+ if (main.html && !isThinMainContent(main.html, html)) return keepPageHeading(main.html, $);
171
196
 
172
197
  const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
173
198
  let $body = null;
@@ -176,17 +201,31 @@ function buildMarkdown($) {
176
201
  if ($body.length > 0) break;
177
202
  }
178
203
  if (!$body || $body.length === 0) $body = $('body');
204
+ return $.html($body);
205
+ }
206
+
207
+ // "Web scraping - Wikipedia" names the page "Web scraping": the H1 plus a
208
+ // separator and the site's name.
209
+ function titleRepeatsHeading(title, heading) {
210
+ const t = title.toLowerCase();
211
+ const h = heading.toLowerCase();
212
+ return t === h || (t.startsWith(h) && /^\s+[-–—|·]\s+\S/.test(t.slice(h.length)));
213
+ }
214
+
215
+ function buildMarkdown($, html, url) {
216
+ const title = pageTitle($);
217
+ const mainHtml = mainContentHtml($, html, url);
179
218
 
180
219
  // Full-fidelity conversion via the shared Turndown helper (same converter
181
220
  // the unified `scrape` tool uses). The previous hand-rolled h1–h3/p/li walk
182
221
  // silently dropped any text living outside those tags (e.g. quotes in
183
222
  // <span>/<small> on quotes.toscrape.com).
184
- let md = htmlToMarkdown($.html($body));
223
+ let md = htmlToMarkdown(mainHtml);
185
224
 
186
- // C3: de-dup title — only emit the <title> heading if the page has no <h1>
187
- // or if the first <h1> text differs from the <title> text (case-insensitive).
188
- const firstH1 = $body.find('h1').first().text().trim();
189
- const titleDuplicated = firstH1 && firstH1.toLowerCase() === title.toLowerCase();
225
+ // C3: de-dup title — only emit the <title> heading if the content has no
226
+ // <h1> or its first <h1> is not what the <title> names (case-insensitive).
227
+ const firstH1 = load(mainHtml)('h1').first().text().replace(/\s+/g, ' ').trim();
228
+ const titleDuplicated = firstH1 && titleRepeatsHeading(title, firstH1);
190
229
  if (title && !titleDuplicated) md = `# ${title}\n\n${md}`;
191
230
 
192
231
  return md.trim();
@@ -16,7 +16,7 @@ import { AGENT_ESCALATION_CREDITS } from './escalation.js';
16
16
  export const AGENT_BASE_CREDITS = 8;
17
17
 
18
18
  export const AgentInputSchema = z.object({
19
- prompt: z.string().min(1).max(2000).describe('Natural-language task or question'),
19
+ prompt: z.string().trim().min(1).max(2000).describe('Natural-language task or question'),
20
20
  urls: z.array(z.string().url()).max(20).optional().describe('Optional seed URLs to include (max 20)'),
21
21
  schema: z.record(z.any()).optional().describe('Optional JSON schema for structured output'),
22
22
  model: z.enum(['default', 'pro']).optional().default('default').describe('"default" = SamplingClient loop; "pro" = full ResearchOrchestrator'),
@@ -9,6 +9,7 @@ import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
9
9
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
11
  import { fetchResigned } from '../../utils/resignedFetch.js';
12
+ import { withFetchCause } from '../../utils/fetchError.js';
12
13
 
13
14
  /**
14
15
  * Fetch a URL with a configurable timeout and body-size cap.
@@ -68,7 +69,7 @@ export async function fetchWithTimeout(url, options = {}) {
68
69
  if (error.name === 'AbortError') {
69
70
  throw new Error(`Request timeout after ${timeout}ms`);
70
71
  }
71
- throw error;
72
+ throw withFetchCause(error, url);
72
73
  }
73
74
 
74
75
  // A host asking us to back off is honoured on the *next* request to it,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { load } from 'cheerio';
9
9
  import { fetchWithTimeout } from './_fetch.js';
10
+ import { fetchErrorMessage } from '../../utils/fetchError.js';
10
11
  import { parseJsonLd, filterJsonLdByType } from '../../utils/jsonLd.js';
11
12
 
12
13
  /**
@@ -161,7 +162,7 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots,
161
162
  };
162
163
  } catch (error) {
163
164
  return {
164
- content: [{ type: 'text', text: `Failed to extract metadata: ${error.message}` }],
165
+ content: [{ type: 'text', text: `Failed to extract metadata: ${fetchErrorMessage(error, url)}` }],
165
166
  isError: true
166
167
  };
167
168
  }
@@ -15,6 +15,7 @@ import { JSDOM } from 'jsdom';
15
15
  import { Readability } from '@mozilla/readability';
16
16
  import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
17
17
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
18
+ import { tooLargeForReadability } from '../scrape/_mainContent.js';
18
19
 
19
20
  // Block-level elements whose boundaries should become paragraph breaks
20
21
  const BLOCK_ELEMENTS = new Set([
@@ -66,14 +67,17 @@ export function extractBlockText($) {
66
67
  * @returns {string}
67
68
  */
68
69
  export function readabilityToMarkdown(html, pageUrl) {
69
- let articleHtml;
70
- try {
71
- const dom = new JSDOM(html, { url: pageUrl });
72
- const reader = new Readability(dom.window.document);
73
- const article = reader.parse();
74
- articleHtml = article ? article.content : html;
75
- } catch {
76
- articleHtml = html;
70
+ let articleHtml = html;
71
+ // A page too large for Readability is converted whole (R25).
72
+ if (!tooLargeForReadability(html)) {
73
+ try {
74
+ const dom = new JSDOM(html, { url: pageUrl });
75
+ const reader = new Readability(dom.window.document);
76
+ const article = reader.parse();
77
+ if (article) articleHtml = article.content;
78
+ } catch {
79
+ // the whole page
80
+ }
77
81
  }
78
82
  const $ = load(articleHtml);
79
83
  absoluteUrls($, pageUrl);
@@ -167,6 +167,11 @@ export class MapSiteTool {
167
167
  // unresolvable host used to return a success with total_urls: 0 (R24).
168
168
  try {
169
169
  const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
170
+ // The start page is a page of the site too: a one-page site with no
171
+ // sitemap used to map to 0 URLs (R25 1.2).
172
+ if (!domainFilter || domainFilter.isAllowed(validated.url).allowed) {
173
+ urls.add(normalizeUrl(validated.url));
174
+ }
170
175
  pageUrls.forEach(url => {
171
176
  if (urls.size < poolLimit) {
172
177
  urls.add(normalizeUrl(url));
@@ -200,6 +205,9 @@ export class MapSiteTool {
200
205
 
201
206
  // Convert to array and limit
202
207
  const urlArray = pool.slice(0, validated.max_urls);
208
+ if (urlArray.length === 0) {
209
+ warnings.push('No URLs were found: no sitemap listed any, and the start page and its links were all excluded by the domain filter.');
210
+ }
203
211
 
204
212
  // Fetch metadata if requested
205
213
  if (validated.include_metadata) {
@@ -401,7 +401,8 @@ export class ExtractContentTool {
401
401
  */
402
402
  extractTitleFromHTML(html) {
403
403
  const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
404
- return titleMatch ? titleMatch[1].trim() : null;
404
+ // Decode entities: MDN's <table> page is titled "&lt;table&gt; ...".
405
+ return titleMatch ? load(titleMatch[1], null, false).root().text().trim() : null;
405
406
  }
406
407
 
407
408
  /**
@@ -10,6 +10,7 @@ import { load } from 'cheerio';
10
10
  import { LLMManager } from '../../core/llm/LLMManager.js';
11
11
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
12
  import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
+ import { fetchErrorMessage } from '../../utils/fetchError.js';
13
14
  import { extractMainContent } from '../scrape/_mainContent.js';
14
15
  import { verifyNumericProvenance } from '../../utils/provenance.js';
15
16
  import { validateAgainstSchema, validateFieldsAgainstSchema } from '../../utils/schemaValidate.js';
@@ -532,6 +533,7 @@ export class ExtractStructuredTool {
532
533
  };
533
534
 
534
535
  } catch (error) {
536
+ const message = fetchErrorMessage(error, params.url);
535
537
  return {
536
538
  success: false,
537
539
  url: params.url || 'unknown',
@@ -540,8 +542,8 @@ export class ExtractStructuredTool {
540
542
  confidence: 0,
541
543
  schema_used: params.schema || {},
542
544
  processingTime: Date.now() - startTime,
543
- error: `Structured extraction failed: ${error.message}`,
544
- validation: { valid: false, errors: [error.message] }
545
+ error: `Structured extraction failed: ${message}`,
546
+ validation: { valid: false, errors: [message] }
545
547
  };
546
548
  }
547
549
  }
@@ -529,8 +529,10 @@ export class ExtractWithLlm {
529
529
  }));
530
530
  } catch (llmErr) {
531
531
  if (signal?.aborted) return { success: false, error: `LLM call aborted: ${llmErr.message}` };
532
- // D1.3: If provider is 'auto'/'ollama' and it failed, try MCP sampling as final fallback
533
- if (providerParam === 'auto' || providerParam === 'ollama') {
532
+ // D1.3: If provider is 'auto'/'ollama' and it failed, try MCP sampling as final fallback.
533
+ // Not when the caller named a model: sampling would answer with a
534
+ // different one, silently (R25 3.3).
535
+ if ((providerParam === 'auto' || providerParam === 'ollama') && !modelParam) {
534
536
  try {
535
537
  const SamplingClient = await getSamplingClient();
536
538
  const samplingClient = new SamplingClient({ mcpServer: this._mcpServer });
@@ -639,7 +641,9 @@ export class ExtractWithLlm {
639
641
  if (checked.skipped) provenance.skipped = checked.skipped;
640
642
  }
641
643
 
642
- // C3: surface truncation metadata so callers know the input was clipped
644
+ // C3: surface truncation metadata so callers know the input was clipped.
645
+ // `input_truncated`, not `truncated`: a top-level `truncated: true` means
646
+ // "this result is a preview, read the rest with read_result" (R25).
643
647
  const result = {
644
648
  success: true,
645
649
  data: parsed,
@@ -649,7 +653,7 @@ export class ExtractWithLlm {
649
653
  usage
650
654
  };
651
655
  if (inputTruncated) {
652
- result.truncated = true;
656
+ result.input_truncated = true;
653
657
  result.original_length = original_length;
654
658
  }
655
659
  if (fetchWarnings.length > 0) result.warnings = fetchWarnings;
@@ -300,6 +300,33 @@ export class ProcessDocumentTool {
300
300
  tool: 'process_document'
301
301
  });
302
302
  if (gate.warnings.length > 0) result.warnings = gate.warnings;
303
+
304
+ // What came back is checked before the PDF parser sees it, as the url
305
+ // branch does: a PNG fetched as pdf_url failed as "Invalid PDF
306
+ // structure" (R25 3.3).
307
+ const response = await safeFetch(source, {
308
+ headers: { ...gate.headers },
309
+ onRedirect: gate.onRedirect,
310
+ signal: AbortSignal.timeout(30000)
311
+ });
312
+ if (!response.ok) {
313
+ if (response.status === 429 || response.status === 503) {
314
+ noteRetryAfter(source, response.headers.get('retry-after'));
315
+ }
316
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
317
+ }
318
+ const contentType = response.headers.get('content-type') || '';
319
+ const buffer = await this.pdfProcessor.readBodyWithSizeCap(response);
320
+ const kind = sniffDocumentKind(contentType, buffer, source);
321
+ if (kind !== 'pdf') {
322
+ throw new Error(
323
+ `${contentType.split(';')[0] || kind} content is not a PDF: sourceType "pdf_url" needs a URL that serves one. ` +
324
+ 'process_document reads PDF, DOCX, HTML and plain text; sourceType "url" detects which.'
325
+ );
326
+ }
327
+ source = buffer;
328
+ sourceType = 'pdf_buffer';
329
+ gate = undefined;
303
330
  }
304
331
 
305
332
  const pdfResult = await this.pdfProcessor.processPDF({
@@ -619,7 +646,8 @@ export class ProcessDocumentTool {
619
646
  */
620
647
  extractTitleFromHTML(html) {
621
648
  const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
622
- return titleMatch ? titleMatch[1].trim() : null;
649
+ // Decode entities: MDN's <table> page is titled "&lt;table&gt; ...".
650
+ return titleMatch ? load(titleMatch[1], null, false).root().text().trim() : null;
623
651
  }
624
652
 
625
653
  /**
@@ -211,11 +211,15 @@ export class SummarizeContentTool {
211
211
  return result;
212
212
 
213
213
  } catch (error) {
214
+ // A ZodError's message is its issue array as JSON; one line per issue reads better.
215
+ const reason = error instanceof z.ZodError
216
+ ? `Invalid input - ${error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')}`
217
+ : error.message;
214
218
  return {
215
219
  originalText: params.text?.substring(0, 100) || 'unknown',
216
220
  summarizedAt: new Date().toISOString(),
217
221
  success: false,
218
- error: `Content summarization failed: ${error.message}`,
222
+ error: `Content summarization failed: ${reason}`,
219
223
  processingTime: Date.now() - startTime,
220
224
  summary: {
221
225
  text: '',
@@ -33,7 +33,7 @@ const GenerateLLMsTxtSchema = z.object({
33
33
  robotsStyle: z.boolean().optional().default(false).describe('Emit legacy robots.txt-style directives instead of spec-compliant llmstxt.org markdown')
34
34
  }).optional().prefault({}),
35
35
 
36
- complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe('Compliance level for generated guidelines'),
36
+ complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe('Applies only with outputOptions.robotsStyle:true, where it sets how many paths the llms.txt Disallow list covers (basic < standard < strict). The default llmstxt.org markdown is the same at every level; llms-full.txt only records the level as a label.'),
37
37
 
38
38
  format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe('Which files to generate')
39
39
  });
@@ -740,7 +740,11 @@ export class GenerateLLMsTxtTool {
740
740
  lines.push('If you encounter issues or need clarification on these guidelines:');
741
741
  lines.push('1. Check this document for guidance');
742
742
  lines.push('2. Review the basic llms.txt file');
743
- lines.push('3. Contact us using the information above');
743
+ // Only when section 1 printed something to contact: without either field
744
+ // this pointed at contact information the file did not contain.
745
+ if (outputOptions.contactEmail || outputOptions.organizationName) {
746
+ lines.push('3. Contact us using the information above');
747
+ }
744
748
  lines.push('');
745
749
  lines.push('### Updates');
746
750
  lines.push('These guidelines may be updated periodically. Check the generation date above');
@@ -49,6 +49,12 @@ import { load } from 'cheerio';
49
49
  // Readability already kept it. Long enough to be unique to that table.
50
50
  const SIGNATURE_LENGTH = 120;
51
51
 
52
+ // Navigation tables are link lists laid out as tables, not data. Wikipedia's
53
+ // navbox and sidebar templates pass the data-table test (a `<th>` and 2+
54
+ // cells a row), so List_of_HTTP_status_codes came back with its "HTTP" sidebar
55
+ // and footer navbox re-attached as pipe tables (R25, 2026-10-19).
56
+ const NAVIGATION_TABLE = 'table.navbox, .navbox-inner, .vertical-navbox, [role="navigation"], nav';
57
+
52
58
  // Copied verbatim from @mozilla/readability's REGEXPS. Two of them delete
53
59
  // content, and both are unanchored: `unlikelyCandidates` removes an element
54
60
  // outright, `negative` docks 25 from its class weight, which is enough for
@@ -151,6 +157,26 @@ export function sanitizeAccidentalClasses(document) {
151
157
  return sanitized;
152
158
  }
153
159
 
160
+ /**
161
+ * Undo MediaWiki's heading wrapper before Readability sees the page.
162
+ *
163
+ * Since 2024 MediaWiki emits every section heading as
164
+ * `<div class="mw-heading"><h2>History</h2><span class="mw-editsection">[edit]</span></div>`.
165
+ * Readability scores that div as a link-heavy fragment: the History and
166
+ * Techniques h2s of Wikipedia's *Web scraping* article were dropped, and the
167
+ * headings that survived each came back followed by a "[edit]" link line
168
+ * (R25, 2026-10-19). Deleting the edit links and unwrapping the div leaves the
169
+ * bare heading Readability keeps.
170
+ *
171
+ * @param {Document} document - mutated in place
172
+ */
173
+ export function unwrapMediaWikiHeadings(document) {
174
+ for (const el of Array.from(document.querySelectorAll('.mw-editsection'))) el.remove();
175
+ for (const wrapper of Array.from(document.querySelectorAll('div.mw-heading'))) {
176
+ wrapper.replaceWith(...Array.from(wrapper.childNodes));
177
+ }
178
+ }
179
+
154
180
  /**
155
181
  * Readability's own data-table test (`_markDataTables`): 10+ rows or more than
156
182
  * 4 columns means the table carries data rather than layout, counting
@@ -232,6 +258,7 @@ export function recoverDroppedTables(html, url, keptText = '') {
232
258
  // A nested table travels with its parent; re-attaching it separately
233
259
  // would duplicate it.
234
260
  .filter((table) => !table.parentElement?.closest('table'))
261
+ .filter((table) => !table.closest(NAVIGATION_TABLE))
235
262
  .filter((table) => isDataTable(table) || isHeadedTable(table))
236
263
  .filter((table) => {
237
264
  const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
@@ -243,6 +270,28 @@ export function recoverDroppedTables(html, url, keptText = '') {
243
270
  }
244
271
  }
245
272
 
273
+ // Readability's time grows faster than the page, and all of it blocks the
274
+ // event loop: about 1 s at 25k elements (the largest Wikipedia articles), 9 s
275
+ // at 114k (docs.python.org's whatsnew/changelog, 7.3 MB), which starved other
276
+ // crawls past their 30 s queue budget (R25 Phase 2 follow-up). Above this many
277
+ // elements a page skips the main-content pass and callers use the whole page.
278
+ export const MAX_READABILITY_ELEMENTS = 50000;
279
+
280
+ /**
281
+ * True when `html` has more elements than Readability should be given. Counts
282
+ * opening tags with a regex (about 5 ms on 7 MB) rather than building a DOM,
283
+ * which would itself cost seconds.
284
+ * @param {string} html
285
+ */
286
+ export function tooLargeForReadability(html) {
287
+ const tag = /<[a-zA-Z]/g;
288
+ let count = 0;
289
+ while (tag.exec(html)) {
290
+ if (++count > MAX_READABILITY_ELEMENTS) return true;
291
+ }
292
+ return false;
293
+ }
294
+
246
295
  /**
247
296
  * Extract a page's main content, re-attaching data tables Readability dropped.
248
297
  *
@@ -252,14 +301,17 @@ export function recoverDroppedTables(html, url, keptText = '') {
252
301
  *
253
302
  * @param {string} html - the page's HTML
254
303
  * @param {string} [url] - document URL, used as the base for relative links
255
- * @returns {{ html: string|null, title: string, tablesRecovered: number }} `html`
256
- * is null when Readability found no article; callers decide what to fall back to.
304
+ * @returns {{ html: string|null, title: string, tablesRecovered: number, tooLarge?: boolean }}
305
+ * `html` is null when Readability found no article, or was not run because the
306
+ * page is too large (`tooLarge`); callers decide what to fall back to.
257
307
  */
258
308
  export function extractMainContent(html, url) {
309
+ if (tooLargeForReadability(html)) return { html: null, title: '', tablesRecovered: 0, tooLarge: true };
259
310
  let article;
260
311
  try {
261
312
  const dom = new JSDOM(html, { url });
262
313
  sanitizeAccidentalClasses(dom.window.document);
314
+ unwrapMediaWikiHeadings(dom.window.document);
263
315
  article = new Readability(dom.window.document).parse();
264
316
  } catch {
265
317
  return { html: null, title: '', tablesRecovered: 0 };
@@ -27,7 +27,8 @@ import { toPublicUnit, parseChosenIndexes, groundingCheck, headingUnits, NOT_IN_
27
27
  import { setActualCost } from '../../server/requestContext.js';
28
28
  import { fenceUntrusted } from '../../utils/untrustedContent.js';
29
29
  import { fetchAndParse } from '../extract/_fetchAndParse.js';
30
- import { extractMainContent, isThinMainContent, keepPageHeading } from './_mainContent.js';
30
+ import { fetchErrorMessage } from '../../utils/fetchError.js';
31
+ import { extractMainContent, isThinMainContent, keepPageHeading, MAX_READABILITY_ELEMENTS } from './_mainContent.js';
31
32
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
32
33
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
33
34
  import { extractBlockText } from '../basic/extractText.js';
@@ -53,6 +54,85 @@ export const JSON_TIMEOUT_MS = 60000;
53
54
 
54
55
  const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
55
56
 
57
+ // ── Question evidence support (R25 1.4) ──────────────────────────────────────
58
+ // A ranked unit is not support for an answer by being ranked: before
59
+ // crawlforge-extractors 1.18.0 the ranker scored link URLs, and Wikipedia's
60
+ // reference list answered "Who created Python…?" with citation titles — as
61
+ // grounded:true. These checks read only the units this call returns, so they
62
+ // hold with any extractors version.
63
+ const EVIDENCE_LINK = /(!?)\[((?:\\.|[^[\]\\])*)\]\((?:\\.|[^()\\\s]|\([^()\s]*\))*(?:\s+"(?:\\.|[^"\\])*")?\)/g;
64
+ const REFERENCE_HEADING = /^(?:(?:references?|notes?|citations?|sources?|footnotes?|bibliography|works cited)(?:\s*(?:,|&|and)\s*|\s*$))+$/i;
65
+ // Words any question shares with any page; they show no unit answers it.
66
+ const QUESTION_STOPWORDS = new Set([
67
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'as', 'into', 'about',
68
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'do', 'does', 'did', 'has', 'have', 'had', 'can', 'could',
69
+ 'will', 'would', 'should', 'may', 'might', 'it', 'its', 'this', 'that', 'these', 'those', 'there', 'their', 'they',
70
+ 'he', 'she', 'his', 'her', 'i', 'you', 'we', 'my', 'your', 'our', 'me', 'us', 'them', 'any', 'some', 'not', 'no',
71
+ 'who', 'whom', 'whose', 'what', 'which', 'when', 'where', 'why', 'how', 'many', 'much'
72
+ ]);
73
+ // crawlforge-extractors' word runs: a CJK run and any other run never merge.
74
+ const CJK_CHAR = '[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}]';
75
+ const QUESTION_TOKEN = new RegExp(`(?:(?=${CJK_CHAR})[\\p{L}\\p{N}])+|(?:(?!${CJK_CHAR})[\\p{L}\\p{N}\\p{M}])+`, 'gu');
76
+ const CJK_RUN = new RegExp(`^${CJK_CHAR}`, 'u');
77
+
78
+ /** A unit's text as a reader sees it: link labels without URLs, no citation markers. */
79
+ function visibleEvidenceText(text) {
80
+ let out = String(text ?? '');
81
+ for (let pass = 0; pass < 2 && out.includes(']('); pass++) {
82
+ out = out.replace(EVIDENCE_LINK, (match, _bang, label) => (/\]\(#cite_(?:note|ref)/.test(match) ? ' ' : label));
83
+ }
84
+ return out.toLowerCase();
85
+ }
86
+
87
+ /** An entry of a references-type list: under such a heading, or carrying a Wikipedia back-link. */
88
+ function isReferenceUnit(unit) {
89
+ if (/\]\(#cite_ref/.test(String(unit.text ?? ''))) return true;
90
+ return typeof unit.heading === 'string' && REFERENCE_HEADING.test(unit.heading.trim());
91
+ }
92
+
93
+ /**
94
+ * The question's content terms, stemmed lightly ("released" → "releas") so a
95
+ * substring test finds their inflections; a CJK run becomes bigrams.
96
+ */
97
+ function questionTerms(question) {
98
+ const terms = new Set();
99
+ for (const [run] of String(question ?? '').toLowerCase().matchAll(QUESTION_TOKEN)) {
100
+ if (CJK_RUN.test(run)) {
101
+ const chars = Array.from(run);
102
+ if (chars.length === 1) terms.add(run);
103
+ for (let i = 0; i + 1 < chars.length; i++) terms.add(chars[i] + chars[i + 1]);
104
+ } else if (!QUESTION_STOPWORDS.has(run)) {
105
+ terms.add(run.length > 4 ? run.replace(/(?:ing|ed|es|s)$/, '').replace(/(.{4,})e$/, '$1') : run);
106
+ }
107
+ }
108
+ return [...terms];
109
+ }
110
+
111
+ /**
112
+ * Whether the ranked evidence can ground an answer: some unit outside a
113
+ * reference list whose visible text (or heading) holds a content term of the
114
+ * question. Returns the reason when it cannot.
115
+ * @param {Array<{ text: string, heading?: string | null }>} evidence
116
+ * @param {string} question
117
+ * @returns {{ supported: boolean, reason?: string }}
118
+ */
119
+ export function evidenceSupport(evidence, question) {
120
+ if (evidence.length === 0) return { supported: false };
121
+ const body = evidence.filter((unit) => !isReferenceUnit(unit));
122
+ if (body.length === 0) {
123
+ return { supported: false, reason: 'every evidence unit is a reference-list entry (References, Notes, Citations or Sources)' };
124
+ }
125
+ const terms = questionTerms(question);
126
+ if (terms.length === 0) return { supported: true };
127
+ const holds = body.some((unit) => {
128
+ const visible = `${visibleEvidenceText(unit.text)}\n${visibleEvidenceText(unit.heading)}`;
129
+ return terms.some((term) => visible.includes(term));
130
+ });
131
+ return holds
132
+ ? { supported: true }
133
+ : { supported: false, reason: 'no evidence unit outside a reference list contains a term of the question in its visible text' };
134
+ }
135
+
56
136
  // The six public fields in tools/list order, each carrying the description
57
137
  // the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
58
138
  // keeping its own copy (Phase 0, 0.3).
@@ -359,7 +439,7 @@ export class UnifiedScrapeTool {
359
439
  errorDocuments: true
360
440
  }));
361
441
  } catch (err) {
362
- throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
442
+ throw new Error(`scrape: fetch failed for ${url}: ${fetchErrorMessage(err, url)}`);
363
443
  }
364
444
  warnings.push(...fetchWarnings);
365
445
 
@@ -504,6 +584,11 @@ export class UnifiedScrapeTool {
504
584
  if (mainHtml !== null) return mainHtml;
505
585
  const main = extractMainContent(html, finalUrl);
506
586
  mainHtml = main.html ?? html;
587
+ if (main.tooLarge) {
588
+ warnings.push(
589
+ `mainContent: the page has more than ${MAX_READABILITY_ELEMENTS.toLocaleString('en-US')} elements, too many for main-content extraction; the whole page is used instead`
590
+ );
591
+ }
507
592
  if (main.tablesRecovered > 0) {
508
593
  warnings.push(
509
594
  `mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
@@ -638,7 +723,7 @@ export class UnifiedScrapeTool {
638
723
  `json: output did not match the requested schema — ${(result.validationErrors || []).join('; ')}`
639
724
  );
640
725
  }
641
- if (result.truncated) {
726
+ if (result.input_truncated) {
642
727
  warnings.push(
643
728
  `json: page text was truncated from ${result.original_length} chars before extraction; ` +
644
729
  'fields appearing late in the page may be missing'
@@ -686,8 +771,11 @@ export class UnifiedScrapeTool {
686
771
  const evidence = rankUnits(candidates, fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
687
772
  const evidenceText = evidence.map((unit) => unit.text).join('\n');
688
773
  let text = evidenceText;
689
- // An empty answer rests on nothing, so it is never grounded.
690
- let grounded = evidence.length > 0;
774
+ // An empty answer rests on nothing, so it is never grounded; nor
775
+ // does one resting on reference-list entries or link URLs alone.
776
+ const support = evidenceSupport(evidence, fmt.question);
777
+ if (support.reason) warnings.push(`question: ${support.reason}; grounded: false`);
778
+ let grounded = support.supported;
691
779
  if (fmt.mode === 'model' && evidence.length > 0) {
692
780
  try {
693
781
  text = await this._answerQuestion(evidence, fmt.question);
@@ -698,7 +786,7 @@ export class UnifiedScrapeTool {
698
786
  warnings.push('question: the model found no answer in the evidence; grounded: false');
699
787
  } else {
700
788
  const check = groundingCheck(text, evidenceText, fmt.question);
701
- grounded = check.grounded;
789
+ grounded = check.grounded && support.supported;
702
790
  if (!grounded) {
703
791
  warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
704
792
  }