crawlforge-mcp-server 6.17.1 → 6.18.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 (29) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/server.js +18 -9
  4. package/src/core/ActionExecutor.js +57 -1
  5. package/src/core/AgentOrchestrator.js +58 -3
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/crawlers/BFSCrawler.js +7 -0
  8. package/src/core/queue/QueueManager.js +3 -0
  9. package/src/schemas/toolOutputSchemas.js +6 -4
  10. package/src/server/fallbackHints.js +4 -4
  11. package/src/server/inlineThreshold.js +76 -10
  12. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
  14. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
  15. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
  16. package/src/tools/advanced/BrowserSessionTool.js +13 -1
  17. package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
  18. package/src/tools/advanced/batchScrape/worker.js +8 -0
  19. package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
  20. package/src/tools/basic/_targetFailure.js +54 -0
  21. package/src/tools/basic/extractLinks.js +62 -42
  22. package/src/tools/basic/extractText.js +79 -14
  23. package/src/tools/crawl/crawlDeep.js +16 -5
  24. package/src/tools/crawl/mapSite.js +49 -41
  25. package/src/tools/extract/_fetchAndParse.js +15 -34
  26. package/src/tools/scrape/unifiedScrape.js +14 -2
  27. package/src/tools/tracking/trackChanges/index.js +33 -6
  28. package/src/tools/tracking/trackChanges/schema.js +2 -2
  29. package/src/utils/fetchLadder.js +243 -0
package/README.md CHANGED
@@ -178,8 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
178
178
  | Tool | Credits | What it does |
179
179
  |------|---------|--------------|
180
180
  | `fetch_url` | 1 | Fetch content from any URL |
181
- | `extract_text` | 1 | Extract clean text from web pages |
182
- | `extract_links` | 1 | Get all links from a page |
181
+ | `extract_text` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Extract clean text from web pages |
182
+ | `extract_links` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Get all links from a page |
183
183
  | `extract_metadata` | 1 | Extract page metadata (title, OG tags, schema.org) |
184
184
  | `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
185
185
  | `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.17.1",
3
+ "version": "6.18.1",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -121,7 +121,7 @@
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.12.0",
124
+ "crawlforge-extractors": "^1.15.0",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -52,8 +52,9 @@ import { createOAuthProvider } from "./src/server/auth/oauth.js";
52
52
  import { createMetricsRegistry } from "./src/observability/metrics.js";
53
53
  // Basic tool handlers (extracted from server.js)
54
54
  import { fetchUrlHandler } from "./src/tools/basic/fetchUrl.js";
55
- import { extractTextHandler } from "./src/tools/basic/extractText.js";
56
- import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
55
+ import { createExtractTextHandler } from "./src/tools/basic/extractText.js";
56
+ import { createExtractLinksHandler } from "./src/tools/basic/extractLinks.js";
57
+ import { EXTRACT_ESCALATION_SHAPE } from "./src/utils/fetchLadder.js";
57
58
  import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
58
59
  import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
59
60
  import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
@@ -112,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
112
113
  // Create the server
113
114
  const server = new McpServer({
114
115
  name: "crawlforge",
115
- version: "6.17.1",
116
+ version: "6.18.1",
116
117
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
117
118
  homepage: "https://www.crawlforge.dev",
118
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -439,29 +440,37 @@ registerToolIfEnabled("fetch_url", {
439
440
 
440
441
  // Tool: extract_text
441
442
  registerToolIfEnabled("extract_text", {
442
- description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
443
+ description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
443
444
  annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
444
445
  inputSchema: {
445
446
  url: z.string().url().describe("The URL to extract text from"),
446
447
  remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
447
448
  remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
448
449
  output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
450
+ selector: z.string().optional().describe("CSS selector: read only the matched elements (nav/header/footer are then kept). No match is an error"),
451
+ max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut and ends with \"...\""),
452
+ ...EXTRACT_ESCALATION_SHAPE,
449
453
  ...COMPLIANCE_PARAMS,
450
454
  ...REDACT_PII_PARAM
451
455
  }
452
- }, withAuth("extract_text", extractTextHandler));
456
+ }, withAuth("extract_text", createExtractTextHandler({
457
+ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_text' })
458
+ })));
453
459
 
454
460
  // Tool: extract_links
455
461
  registerToolIfEnabled("extract_links", {
456
- description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true returns only outbound links. Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
462
+ description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
457
463
  annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
458
464
  inputSchema: {
459
465
  url: z.string().url().describe("The URL to extract links from"),
460
- filter_external: z.boolean().optional().default(false).describe("Only return external links"),
466
+ filter_external: z.boolean().optional().default(false).describe("Drop internal (same-host) links"),
461
467
  base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
468
+ ...EXTRACT_ESCALATION_SHAPE,
462
469
  ...COMPLIANCE_PARAMS
463
470
  }
464
- }, withAuth("extract_links", extractLinksHandler));
471
+ }, withAuth("extract_links", createExtractLinksHandler({
472
+ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_links' })
473
+ })));
465
474
 
466
475
  // Tool: extract_metadata
467
476
  registerToolIfEnabled("extract_metadata", {
@@ -1022,7 +1031,7 @@ registerToolIfEnabled("scrape_with_actions", {
1022
1031
  fields: z.array(z.object({
1023
1032
  selector: z.string(),
1024
1033
  value: z.string(),
1025
- type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text'),
1034
+ type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text').describe("How the field is filled: text types the value, select picks the option by value or label, checkbox/radio check the input (a selector matching a group checks the member whose value attribute equals value; a checkbox with value \"false\" is unchecked). file is not supported and fails that action"),
1026
1035
  waitAfter: z.number().min(0).max(5000).default(100)
1027
1036
  })),
1028
1037
  submitSelector: z.string().optional(),
@@ -129,6 +129,17 @@ const SelectActionSchema = BaseActionSchema.extend({
129
129
  message: 'Select action requires value or values'
130
130
  });
131
131
 
132
+ // Not on any tool's public action list: scrape_with_actions' formAutoFill emits
133
+ // it for a field declared `type: "checkbox"` or `type: "radio"`. `fieldType`
134
+ // is a plain string so that a field type nothing here can fill ("file")
135
+ // arrives as an action that fails, instead of being typed into as text.
136
+ const CheckActionSchema = BaseActionSchema.extend({
137
+ type: z.literal('check'),
138
+ selector: z.string(),
139
+ value: z.string(),
140
+ fieldType: z.string()
141
+ });
142
+
132
143
  const HoverActionSchema = BaseActionSchema.extend({
133
144
  type: z.literal('hover'),
134
145
  selector: z.string(),
@@ -175,6 +186,7 @@ const ActionSchema = z.union([
175
186
  PressActionSchema,
176
187
  ScrollActionSchema,
177
188
  SelectActionSchema,
189
+ CheckActionSchema,
178
190
  HoverActionSchema,
179
191
  NavigateActionSchema,
180
192
  ScreenshotActionSchema,
@@ -1029,6 +1041,8 @@ export class ActionExecutor extends EventEmitter {
1029
1041
  return await this.executeScrollAction(page, action);
1030
1042
  case 'select':
1031
1043
  return await this.executeSelectAction(page, action);
1044
+ case 'check':
1045
+ return await this.executeCheckAction(page, action);
1032
1046
  case 'hover':
1033
1047
  return await this.executeHoverAction(page, action);
1034
1048
  case 'navigate':
@@ -1309,6 +1323,45 @@ export class ActionExecutor extends EventEmitter {
1309
1323
  };
1310
1324
  }
1311
1325
 
1326
+ /**
1327
+ * Execute check action — a formAutoFill checkbox or radio field.
1328
+ *
1329
+ * A selector that names a whole group (`input[name="size"]`) is narrowed to
1330
+ * the member whose `value` attribute is the field's value, the way the form
1331
+ * itself pairs name and value; `.first()` would check whichever came first.
1332
+ * A selector that names one input checks that input, except a checkbox with
1333
+ * value "false", which is unchecked.
1334
+ * @param {Page} page - Playwright page
1335
+ * @param {Object} action - Check action
1336
+ * @returns {Promise<Object>} Check result
1337
+ */
1338
+ async executeCheckAction(page, action) {
1339
+ if (action.fieldType !== 'checkbox' && action.fieldType !== 'radio') {
1340
+ throw new Error(
1341
+ `formAutoFill field type "${action.fieldType}" is not supported ` +
1342
+ '(supported: text, select, checkbox, radio)'
1343
+ );
1344
+ }
1345
+
1346
+ const timeout = this.actionTimeout(action);
1347
+ const matches = page.locator(this.resolveSelector(page, action.selector));
1348
+ await matches.first().waitFor({ state: 'attached', timeout });
1349
+
1350
+ const isGroup = (await matches.count()) > 1;
1351
+ const target = isGroup
1352
+ ? matches.and(page.locator(`[value=${JSON.stringify(action.value)}]`)).first()
1353
+ : matches.first();
1354
+ const checked = isGroup || action.fieldType === 'radio' || action.value !== 'false';
1355
+
1356
+ await target.setChecked(checked, { timeout });
1357
+
1358
+ return {
1359
+ selector: action.selector,
1360
+ value: action.value,
1361
+ checked
1362
+ };
1363
+ }
1364
+
1312
1365
  /**
1313
1366
  * Execute hover action
1314
1367
  * @param {Page} page - Playwright page
@@ -1581,9 +1634,12 @@ export class ActionExecutor extends EventEmitter {
1581
1634
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1582
1635
  await assertUrlAllowed(url, { resolveDns: true });
1583
1636
 
1637
+ // The initial load and a retry's reload pass no timeout of their own, so
1638
+ // browserOptions.timeout bounds them as it bounds the actions (0.2); a
1639
+ // navigate action's own timeout comes first.
1584
1640
  const response = await page.goto(url, {
1585
1641
  waitUntil: options.waitUntil || 'domcontentloaded',
1586
- timeout: options.timeout || 30000
1642
+ timeout: options.timeout || options.browserOptions?.timeout || 30000
1587
1643
  });
1588
1644
 
1589
1645
  // Re-validate where the navigation went: a redirect could have taken us
@@ -172,6 +172,40 @@ export function isCurrentStateTask(prompt) {
172
172
  return CURRENT_STATE_RE.test(prompt || '');
173
173
  }
174
174
 
175
+ /**
176
+ * The names a prompt uses: words carrying a capital letter that is not just
177
+ * the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
178
+ * "What"). These are what the task is about, so a planned query that drops
179
+ * one has lost its subject.
180
+ */
181
+ export function namedTerms(prompt) {
182
+ const words = (prompt || '').split(/\s+/);
183
+ const terms = [];
184
+ words.forEach((raw, i) => {
185
+ if (raw.includes('://')) return;
186
+ const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').replace(/['’]s$/, '');
187
+ if (word.length < 2) return;
188
+ const opensSentence = i === 0 || /[.?!:]$/.test(words[i - 1]);
189
+ if (!/\p{Lu}/u.test(opensSentence ? word.slice(1) : word)) return;
190
+ if (!terms.some(t => t.toLowerCase() === word.toLowerCase())) terms.push(word);
191
+ });
192
+ return terms;
193
+ }
194
+
195
+ /**
196
+ * A task scoped to its seed URLs: the caller passed `urls` and the prompt
197
+ * points at them ("this page", "these URLs", "the given site"). The answer is
198
+ * then on those pages and nowhere else, so GATHER runs no web search — search
199
+ * results for the task's words are other people's pages. Asked how many books
200
+ * "this page" lists, the agent searched "count books on webpage" and merged
201
+ * Reddit threads about word counts into the evidence (R24, 2026-10-03).
202
+ */
203
+ const SEED_SCOPE_RE = /\b(?:this|these|the (?:given|provided|above|following|seed))\s+(?:web\s?)?(?:pages?|urls?|sites?|websites?|links?|documents?|articles?)\b/i;
204
+
205
+ export function isSeedScopedTask(prompt, seedUrls) {
206
+ return Array.isArray(seedUrls) && seedUrls.length > 0 && SEED_SCOPE_RE.test(prompt || '');
207
+ }
208
+
175
209
  // ── Orchestrator ──────────────────────────────────────────────────────────────
176
210
 
177
211
  export class AgentOrchestrator {
@@ -358,6 +392,17 @@ export class AgentOrchestrator {
358
392
  );
359
393
  }
360
394
  // A current-state plan is told to make its first query the bare entity
395
+ // name, and for a prompt naming two things a small model can keep the
396
+ // wrong one: "latest stable version of the TypeScript npm package" was
397
+ // searched as the single word "npm" (R24, 2026-10-03). The first query
398
+ // carries every name the prompt uses; what the planner wrote stays as the
399
+ // qualifier ("TypeScript npm").
400
+ if (currentState) {
401
+ const first = searchQueries[0].toLowerCase();
402
+ const dropped = namedTerms(prompt).filter(t => !first.includes(t.toLowerCase()));
403
+ if (dropped.length > 0) searchQueries[0] = `${dropped.join(' ')} ${searchQueries[0]}`;
404
+ }
405
+ // A current-state plan is told to make its first query the bare entity
361
406
  // name, and a small model often stops there. The bare query surfaces the
362
407
  // live front page, which rarely states the fact asked for: "what does
363
408
  // Southwest charge for a first checked bag" fetched southwest.com's home,
@@ -397,7 +442,7 @@ export class AgentOrchestrator {
397
442
  /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
398
443
  const excerpts = new Map();
399
444
 
400
- if (urlQueue.length < capUrls) {
445
+ if (urlQueue.length < capUrls && !isSeedScopedTask(prompt, seedUrls)) {
401
446
  try {
402
447
  const searchTool = await this._getSearchTool();
403
448
  for (const q of searchQueries) {
@@ -607,14 +652,24 @@ export class AgentOrchestrator {
607
652
  schema,
608
653
  provider: 'auto'
609
654
  });
655
+ // A required field left null is an incomplete answer, not a
656
+ // structured success (R24, 2026-10-03).
657
+ const missingRequired = result.success && Array.isArray(schema.required)
658
+ ? schema.required.filter(key => result.data?.[key] == null || result.data[key] === '')
659
+ : [];
660
+ const missingReason = missingRequired.length > 0
661
+ ? `Required ${missingRequired.length === 1 ? 'field' : 'fields'} ${missingRequired.map(k => `"${k}"`).join(', ')} ` +
662
+ `${missingRequired.length === 1 ? 'is' : 'are'} null: the fetched sources do not state ${missingRequired.length === 1 ? 'it' : 'them'}.`
663
+ : undefined;
664
+ if (missingReason) warnings.push(missingReason);
610
665
  return {
611
666
  success: result.success,
612
667
  answer: result.success ? result.data : null,
613
668
  structured: true,
614
669
  search_results: searchResults,
615
670
  evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
616
- degraded: !result.success,
617
- reason: result.success ? undefined : result.error,
671
+ degraded: !result.success || missingRequired.length > 0,
672
+ reason: result.success ? missingReason : result.error,
618
673
  steps: step,
619
674
  urls_fetched: urlsFetched,
620
675
  stealth_retries: usage.escalations,
@@ -687,6 +687,12 @@ class AuthManager {
687
687
  return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
688
688
  }
689
689
 
690
+ // extract_text and extract_links (fix plan Phase E2) run the same ladder:
691
+ // projected 1+5 when escalating, charged 1 when the plain fetch sufficed.
692
+ if (tool === 'extract_text' || tool === 'extract_links') {
693
+ return costs[tool] + scrapeEscalationSurcharge(params?.escalate) + redaction;
694
+ }
695
+
690
696
  // search_web's batch form (5.1) runs one backend search per query, so it
691
697
  // is priced as that many search_web calls. The count rule lives with the
692
698
  // schema that declares `queries` (src/tools/search/batchSearch.js).
@@ -778,6 +784,12 @@ class AuthManager {
778
784
  ? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
779
785
  : 'Fixed cost per invocation.';
780
786
  break;
787
+ case 'extract_text':
788
+ case 'extract_links':
789
+ note = params?.escalate === true
790
+ ? 'Base 1; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran.'
791
+ : 'Fixed cost per invocation.';
792
+ break;
781
793
  case 'agent': {
782
794
  const agentUrls = params?.maxUrls || 10;
783
795
  const isPro = params?.model === 'pro';
@@ -219,6 +219,12 @@ export class BFSCrawler {
219
219
  return;
220
220
  }
221
221
  this.visited.set(normalizedUrl, url);
222
+ // Budget spent: drop what is still waiting. Each of those tasks would only
223
+ // return at the cap check above, but the queue releases 10 a second, so a
224
+ // seed with 2,290 links held a max_pages:3 crawl open for 232 s (R24).
225
+ if (this.visited.size >= this.maxPages) {
226
+ this.queue.clear();
227
+ }
222
228
 
223
229
  try {
224
230
  // Check cache first
@@ -318,6 +324,7 @@ export class BFSCrawler {
318
324
  url,
319
325
  depth,
320
326
  error: error.message,
327
+ ...(error.code ? { code: error.code } : {}),
321
328
  timestamp: new Date().toISOString()
322
329
  });
323
330
  }
@@ -77,6 +77,9 @@ export class QueueManager {
77
77
  getStats() {
78
78
  return {
79
79
  ...this.stats,
80
+ // Read live: the 'active' event fires when a task starts, never when the
81
+ // last one ends, so the stored value stayed at 8 on a finished crawl.
82
+ active: this.queue.pending,
80
83
  size: this.queue.size,
81
84
  pending: this.queue.pending,
82
85
  isPaused: this.queue.isPaused
@@ -181,6 +181,7 @@ const mapSiteShape = {
181
181
  url: z.string().optional(),
182
182
  score: z.number().optional()
183
183
  }).passthrough()).optional().describe('Present only when the `search` param was set'),
184
+ warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only'),
184
185
  _cost: costShape
185
186
  };
186
187
 
@@ -378,16 +379,17 @@ const crawlDeepShape = {
378
379
  error: z.string().optional(),
379
380
  url: z.string().optional(),
380
381
  crawl_depth: z.number().optional(),
381
- pages_crawled: z.number().optional(),
382
- pages_found: z.number().optional(),
383
- error_count: z.number().optional(),
382
+ pages_attempted: z.number().optional().describe('URLs the crawl tried: pages_crawled + error_count'),
383
+ pages_crawled: z.number().optional().describe('Pages fetched and returned in results'),
384
+ pages_found: z.number().optional().describe('Same as pages_crawled; kept for existing clients'),
385
+ error_count: z.number().optional().describe('URLs that failed; one entry each in errors[]'),
384
386
  duration_ms: z.number().optional(),
385
387
  pages_per_second: z.number().optional(),
386
388
  results: z.array(crawlDeepPageShape).optional(),
387
389
  errors: z.array(z.unknown()).optional(),
388
390
  stats: z.unknown().optional(),
389
391
  site_structure: z.object({
390
- total_pages: z.number().optional(),
392
+ total_pages: z.number().optional().describe('Equals pages_crawled; failed URLs are not counted'),
391
393
  depth_distribution: z.record(z.number()).optional()
392
394
  .describe('Pages per crawl depth (links from the start URL)'),
393
395
  path_depth_distribution: z.record(z.number()).optional()
@@ -11,8 +11,8 @@
11
11
 
12
12
  export const FALLBACK_HINTS = Object.freeze({
13
13
  fetch_url: 'For a JS-rendered page or an empty shell use scrape; after a 403/429/CAPTCHA/challenge page use stealth_mode operation:"scrape". Do not repeat the same fetch_url call.',
14
- extract_text: 'Use scrape formats:["markdown"] (renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
15
- extract_links: 'Use scrape formats:["links"]; for a whole site use map_site.',
14
+ extract_text: 'After a 403/429/444/challenge page call extract_text again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["markdown"]; for a PDF use process_document.',
15
+ extract_links: 'After a 403/429/444/challenge page call extract_links again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["links"]; for a whole site use map_site.',
16
16
  extract_metadata: 'Use scrape formats:["metadata"]; after a 403/429/challenge page use stealth_mode operation:"scrape".',
17
17
  extract_content: 'Use scrape formats:["markdown"] (same clean output, renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
18
18
  extract_embedded_state: 'Call again without `path` to see the top-level keys, or use scrape formats:["markdown"] if the page has no framework payload.',
@@ -56,8 +56,8 @@ export const SCRAPE_ESCALATED_HINT =
56
56
  'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
57
57
  'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
58
58
 
59
- /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
60
- const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
59
+ /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2; extract_text and extract_links since Phase E2). */
60
+ const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state', 'extract_text', 'extract_links']);
61
61
 
62
62
  /** The hint for this result: the tool's, unless the call already escalated. */
63
63
  function hintFor(toolName, parsed) {
@@ -20,8 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
20
20
  /**
21
21
  * Per-tool rule. `textPaths` are dotted paths tried in order; the first one
22
22
  * holding a string becomes the text view the preview and read_result work
23
- * on. An empty list means the pretty-printed JSON is the view. `when` gates
24
- * on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
23
+ * on. An empty list means the pretty-printed JSON is the view. `keep` names
24
+ * top-level fields carried inline past the scalar filter (keepNamedFields).
25
+ * `when` gates on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
25
26
  * below).
26
27
  */
27
28
  /** The browser_session operations that hand back content worth shaping. */
@@ -37,7 +38,14 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
37
38
  // an async job's page came back as 111 KB whole (R20, 2026-09-07).
38
39
  get_batch_results: { textPaths: [], truncate: true },
39
40
  stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
40
- scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
41
+ // `keep` names the top-level reports that stay inline past the scalar
42
+ // filter (keepNamedFields): a walled Guardian chain over 40,000 chars lost
43
+ // `blocked`, `navigations` and every action's outcome (2026-10-03).
44
+ scrape_with_actions: {
45
+ textPaths: ['content.markdown', 'content.text', 'content.html'],
46
+ truncate: true,
47
+ keep: ['error', 'blocked', 'consent', 'navigations', 'actionResults']
48
+ },
41
49
  // `read` hands back the same content shape scrape_with_actions does, and was
42
50
  // the one content-returning tool with no cap: a read of the World War II
43
51
  // article returned 541,308 characters inline where scrape returned 42,259
@@ -48,6 +56,7 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
48
56
  browser_session: {
49
57
  textPaths: ['content.markdown', 'content.text', 'content.html', 'snapshot.tree'],
50
58
  truncate: true,
59
+ keep: ['error', 'blocked', 'actionResults'],
51
60
  when: (params) => BROWSER_SESSION_CONTENT_OPERATIONS.has(params?.operation)
52
61
  },
53
62
  process_document: { textPaths: ['content.text'], truncate: true },
@@ -111,6 +120,42 @@ export function keepSmallContentFields(content, textPaths = [], maxInline = DEFA
111
120
  return Object.keys(kept).length > 0 ? kept : null;
112
121
  }
113
122
 
123
+ /**
124
+ * The top-level fields a tool names in `keep`, each carried inline when its
125
+ * JSON fits a quarter of the inline budget. An actionResults list over that
126
+ * keeps every entry without its `result` (a snapshot tree, an extracted
127
+ * value), so which action ran and whether it worked stays inline.
128
+ * @returns {{ kept: object|null, partial: string[], dropped: string[] }}
129
+ */
130
+ export function keepNamedFields(resultObject, keys = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
131
+ const cap = Math.max(1000, Math.floor(maxInline / 4));
132
+ const kept = {};
133
+ const partial = [];
134
+ const dropped = [];
135
+ for (const key of keys) {
136
+ const value = resultObject[key];
137
+ if (value === undefined) continue;
138
+ if (JSON.stringify(value).length <= cap) {
139
+ kept[key] = value;
140
+ continue;
141
+ }
142
+ if (key === 'actionResults' && Array.isArray(value)) {
143
+ const slim = value.map((entry) => {
144
+ if (!entry || typeof entry !== 'object') return entry;
145
+ const { result: _result, ...rest } = entry;
146
+ return rest;
147
+ });
148
+ if (JSON.stringify(slim).length <= cap) {
149
+ kept[key] = slim;
150
+ partial.push(key);
151
+ continue;
152
+ }
153
+ }
154
+ dropped.push(key);
155
+ }
156
+ return { kept: Object.keys(kept).length > 0 ? kept : null, partial, dropped };
157
+ }
158
+
114
159
  function warningsOf(resultObject) {
115
160
  return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
116
161
  }
@@ -156,7 +201,16 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
156
201
 
157
202
  const preview = text.slice(0, maxInline);
158
203
  const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
159
- const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
204
+ const named = keepNamedFields(resultObject, config.keep, maxInline);
205
+ const keptNames = [
206
+ ...(named.kept ? Object.keys(named.kept).filter((key) => !named.partial.includes(key)) : []),
207
+ ...(keptFields ? Object.keys(keptFields).map((key) => `content.${key}`) : [])
208
+ ];
209
+ const keptDesc = [
210
+ keptNames.length > 0 ? `; ${keptNames.join(', ')} kept inline` : '',
211
+ named.partial.includes('actionResults') ? '; actionResults kept without each action\'s result (json_path "actionResults[<i>].result" reads one)' : '',
212
+ named.dropped.length > 0 ? `; ${named.dropped.join(', ')} too large to keep inline (json_path "${named.dropped[0]}" reads it)` : ''
213
+ ].join('');
160
214
  const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
161
215
 
162
216
  const shaped = {};
@@ -171,6 +225,7 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
171
225
  if (resultObject.redaction && typeof resultObject.redaction === 'object') {
172
226
  shaped.redaction = resultObject.redaction;
173
227
  }
228
+ if (named.kept) Object.assign(shaped, named.kept);
174
229
  // The query-scoped formats live beside the page text under `content`
175
230
  // (highlights, answer, json, metadata, links). They are the small, exact
176
231
  // answer the caller paid for, and truncating the markdown must not drop
@@ -191,6 +246,14 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
191
246
  return { result: shaped, stored: true };
192
247
  }
193
248
 
249
+ /**
250
+ * Room for the `_cost` block withAuth adds to the text after shaping:
251
+ * pretty-printed, projected/actual/remaining_credits and the projection
252
+ * note, whose longest form for extract_embedded_state (escalate:true) is
253
+ * about 200 chars, about 340 in all.
254
+ */
255
+ export const COST_BLOCK_RESERVE = 400;
256
+
194
257
  /** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
195
258
  export function embeddedDataKeys(data) {
196
259
  if (Array.isArray(data)) return `array(${data.length})`;
@@ -227,10 +290,12 @@ export function wholeLinePreview(text, budget) {
227
290
  * everything that says what the page carries — url, path, bytes, the whole
228
291
  * `found` list, data_keys, the escalation report and window_state's list —
229
292
  * then a preview of the pretty-printed JSON cut at a line boundary. The
230
- * preview gets whatever max_inline_chars leaves after the rest, measured as
231
- * compact JSON, so the whole inline result stays within the limit whenever
232
- * the rest fits. The full result is under the handle for read_result
233
- * json_path. The REST route shapes it the same way; keep the two in step.
293
+ * preview gets whatever max_inline_chars leaves after the rest, measured the
294
+ * way withAuth sends it — pretty-printed, with COST_BLOCK_RESERVE held back
295
+ * for the `_cost` block it adds after shaping — so the text the client
296
+ * receives stays within the limit whenever the rest fits. The full result is
297
+ * under the handle for read_result json_path. The REST route shapes it the
298
+ * same way, measured as the compact JSON it sends; keep the two in step.
234
299
  */
235
300
  function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
236
301
  const dataKeys = embeddedDataKeys(resultObject.data);
@@ -257,8 +322,9 @@ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expir
257
322
  expires_at,
258
323
  warnings: [...warningsOf(resultObject), hint]
259
324
  };
260
- // `"preview":"",` is what the key itself adds once the value is in.
261
- const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
325
+ // The preview's value is one line of the pretty-printed text and escapes
326
+ // the same way there as in compact JSON; `""` is its empty value's quotes.
327
+ const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + COST_BLOCK_RESERVE;
262
328
  const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
263
329
  return { ...shaped, preview, ...tail };
264
330
  }
@@ -154,7 +154,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
154
154
  | `formats` | `["json"]` | `markdown`, `html`, `json`, `text`, `screenshots`. |
155
155
  | `captureIntermediateStates` | `false` | Snapshot after each action. |
156
156
  | `captureScreenshots` | `true` | Screenshot during execution. |
157
- | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
157
+ | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. Each field's `type` picks how it is filled: `text` (default) types, `select` picks the option by value or label, `checkbox`/`radio` check the input (a group selector such as `input[name=size]` checks the member whose value attribute equals `value`). `file` is not supported and fails that action. |
158
158
  | `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
159
159
  | `continueOnActionError` | `false` | Keep going if one action fails. |
160
160
  | `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
@@ -8,8 +8,8 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
8
8
  | Tool | Notes |
9
9
  |------|-------|
10
10
  | `fetch_url` | Raw HTTP fetch. |
11
- | `extract_text` | Tag-stripped text/markdown. |
12
- | `extract_links` | All links on a page. |
11
+ | `extract_text` | Tag-stripped text/markdown. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
12
+ | `extract_links` | All links on a page. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
13
13
  | `extract_metadata` | Title / meta / OG / schema.org. |
14
14
  | `scrape_template` | Pre-built site extractor. |
15
15
  | `list_ollama_models` | List local LLMs. |
@@ -158,6 +158,10 @@ Returns headers + body. Good for JSON/XML APIs or as a first step before
158
158
  `extract_text` (cost 1) = faster, strips tags; pass `output_format:"markdown"`
159
159
  for RAG-friendly output.
160
160
 
161
+ `extract_text` and `extract_links` take `escalate:true` for a site known to
162
+ block: the plain fetch runs first and the stealth browser re-reads the page
163
+ only if it is walled (projected 6, charged 1 when the plain fetch worked).
164
+
161
165
  ## extract_links / extract_metadata (cost: 1 each)
162
166
 
163
167
  ```json
@@ -34,14 +34,28 @@ your provider).
34
34
  | `remove_scripts` | boolean | `true` | Strip `script` tags. |
35
35
  | `remove_styles` | boolean | `true` | Strip `style` tags. |
36
36
  | `output_format` | enum | `text` | `text` or `markdown` (use markdown for RAG). |
37
+ | `selector` | string | — | CSS selector: read only the matched elements (nav/header/footer inside them are kept). No match is an error: `No elements found for selector: <sel>`. |
38
+ | `max_length` | integer | — | 1–1,000,000. Text or markdown longer than this is cut and ends with `...`; `word_count`/`char_count` count the cut result. |
39
+ | `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
40
+ | `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
41
+
42
+ Text mode returns one line per block element (`<h1>Hi</h1><p>there</p>` reads `Hi\nthere`). The REST route (`/api/v1/tools/extract_text`) takes the same parameters plus `preserve_links` and `preserve_formatting`, which the MCP tool does not.
43
+
44
+ A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
37
45
 
38
46
  ## extract_links (cost: 1)
39
47
 
40
48
  | Param | Type | Default | Notes |
41
49
  |-------|------|---------|-------|
42
50
  | `url` | string (URL) | — | Required. |
43
- | `filter_external` | boolean | `false` | Only return outbound links. |
44
- | `base_url` | string (URL) | — | Resolve relative links against this. |
51
+ | `filter_external` | boolean | `false` | Drop internal (same-host) links; external and `other` links stay. |
52
+ | `base_url` | string (URL) | — | Resolve relative links against this. Without it, the page's `<base href>`, else the page URL. |
53
+ | `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
54
+ | `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
55
+
56
+ Each link is `{ href, text, type, domain, rel, original_href }` (plus `title` when the link has one). `type` is `internal` (same host as the page), `external` or `other` (`mailto:`, `tel:`, `javascript:` and other non-web schemes; only `mailto:`/`tel:` keep an `href`, the rest are `null`). `#fragment`-only links are skipped, and links are deduplicated on the URL without its fragment or trailing slash. Counts: `total_count`, `internal_count`, `external_count`, `other_count`. The REST route returns the same records.
57
+
58
+ A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
45
59
 
46
60
  ## extract_metadata (cost: 1)
47
61