crawlforge-mcp-server 6.21.1 → 6.22.1

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 (52) 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 +228 -21
  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 +76 -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 +22 -14
  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/providers/searxng.js +20 -3
  45. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  46. package/src/tools/tracking/trackChanges/differ.js +2 -1
  47. package/src/tools/tracking/trackChanges/index.js +6 -1
  48. package/src/tools/tracking/trackChanges/schema.js +15 -1
  49. package/src/utils/fetchError.js +47 -0
  50. package/src/utils/fetchLadder.js +5 -5
  51. package/src/utils/htmlToMarkdown.js +36 -2
  52. package/src/utils/ssrfGuard.js +2 -1
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-stealth-browsing
3
3
  description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
4
4
  metadata:
5
- version: 6.21.1
5
+ version: 6.22.1
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -32,20 +32,13 @@ Still never reach for `stealth_mode` first.
32
32
 
33
33
  ## stealth_mode (cost: 5)
34
34
 
35
- `stealth_mode` is operation-based. Typical flow: create a context, then create a
36
- page that navigates to the target URL.
35
+ `stealth_mode` is operation-based. `operation:"scrape"` is the one-shot path:
36
+ it renders one URL and returns the requested formats (examples below).
37
37
 
38
- ```json
39
- {
40
- "tool": "stealth_mode",
41
- "params": {
42
- "operation": "create_context",
43
- "stealthConfig": { "level": "advanced", "simulateHumanBehavior": true }
44
- }
45
- }
46
- ```
47
-
48
- Then use the returned `contextId`:
38
+ `create_context` then `create_page` is a navigation and challenge check, not a
39
+ multi-step path: `create_page` loads `urlToTest`, reports its status, title and
40
+ any challenge page it detected, and closes the page before the call returns.
41
+ Nothing on that page carries over to a later call.
49
42
 
50
43
  ```json
51
44
  {
@@ -54,6 +47,10 @@ Then use the returned `contextId`:
54
47
  }
55
48
  ```
56
49
 
50
+ For work over several calls on one page (log in, then read; click through a
51
+ flow), use `browser_session` with `stealth:true` (see the
52
+ crawlforge-browser-sessions skill).
53
+
57
54
  Operations: `scrape`, `configure`, `create_context`, `create_page`,
58
55
  `get_stats`, `cleanup`. `stealthConfig.level` is `basic` / `medium` (default) /
59
56
  `advanced`. Always run `cleanup` when done to release the browser.
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-structured-extraction
3
3
  description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
4
4
  metadata:
5
- version: 6.21.1
5
+ version: 6.22.1
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-web-scraping
3
3
  description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
4
4
  metadata:
5
- version: 6.21.1
5
+ version: 6.22.1
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { z } from 'zod';
27
27
  import { load } from 'cheerio';
28
+ import { absoluteUrls } from 'crawlforge-extractors';
28
29
  import { createHash } from 'node:crypto';
29
30
 
30
31
  import ActionExecutor from '../../core/ActionExecutor.js';
@@ -577,8 +578,16 @@ export class BrowserSessionTool {
577
578
  if (params.formats.includes('markdown')) {
578
579
  // Readability finds no article on most app pages, and then no markdown is
579
580
  // produced at all (R20, 2026-09-07). Convert the DOM we hold instead of
580
- // handing back a placeholder.
581
- content.markdown = (params.onlyMainContent && extracted.content?.markdown) || htmlToMarkdown(html);
581
+ // handing back a placeholder. The raw DOM carries no base URL, so its
582
+ // links are resolved against the page first: HN's "item?id=…" hrefs came
583
+ // back relative and unusable (R25, 2026-10-19).
584
+ let markdown = params.onlyMainContent && extracted.content?.markdown;
585
+ if (!markdown) {
586
+ const $page = load(html);
587
+ absoluteUrls($page, url);
588
+ markdown = htmlToMarkdown($page.html());
589
+ }
590
+ content.markdown = markdown;
582
591
  }
583
592
  if (params.formats.includes('json')) {
584
593
  content.json = {
@@ -3,7 +3,7 @@
3
3
  * Semaphore-based concurrency runner that dispatches work to worker.js.
4
4
  */
5
5
 
6
- import { scrapeUrl } from './worker.js';
6
+ import { scrapeUrl, failedEntryHint } from './worker.js';
7
7
 
8
8
  /** Semaphore for concurrency limiting. */
9
9
  class Semaphore {
@@ -57,10 +57,12 @@ export async function scrapeUrlsBatch(urlConfigs, options, defaultTimeout) {
57
57
 
58
58
  return settled.map((result, index) => {
59
59
  if (result.status === 'fulfilled') return result.value;
60
+ const error = result.reason?.message || 'Unknown error';
60
61
  return {
61
62
  success: false,
62
63
  url: urlConfigs[index].url,
63
- error: result.reason?.message || 'Unknown error',
64
+ error,
65
+ next_step: failedEntryHint(error),
64
66
  timestamp: Date.now()
65
67
  };
66
68
  });
@@ -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 };