crawlforge-mcp-server 6.11.0 → 6.12.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.
package/CLAUDE.md CHANGED
@@ -236,6 +236,11 @@ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the es
236
236
  # from the PROXY_ROTATION_* family, which belongs to
237
237
  # localization. The agent's automatic stealth retry
238
238
  # (stealth review Phase 3) reads it too.
239
+ CRAWLFORGE_IMPIT= # `off` skips escalation's impit step (a Chrome TLS handshake with
240
+ # the honest UA, tried before the browser; stealth review Phase 7).
241
+ # Exit-IP dependent like the engine: it cleared indeed.com from a
242
+ # residential IP and nothing from the hosted datacenter IP, so
243
+ # render.yaml sets it off there. Measure with scripts/impit-probe.mjs.
239
244
  CRAWLFORGE_CLEARANCE_JAR= # `off` disables the clearance jar (stealth review Phase 4):
240
245
  # cf_clearance/__cf_bm/datadome cookies a stealth render earned
241
246
  # are replayed to the next stealth context with the same engine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.11.0",
3
+ "version": "6.12.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.9.0",
124
+ "crawlforge-extractors": "^1.10.0",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -41,6 +41,7 @@ import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
41
41
  import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
42
42
  import AuthManager from "./src/core/AuthManager.js";
43
43
  import { makeWithAuth } from "./src/server/withAuth.js";
44
+ import { stripScreenshotData } from "./src/server/screenshotResources.js";
44
45
  // Transport helpers
45
46
  import { connectStdio } from "./src/server/transports/stdio.js";
46
47
  import { connectStreamableHttp } from "./src/server/transports/streamableHttp.js";
@@ -61,7 +62,7 @@ import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
61
62
  import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
62
63
  import { markPreflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
63
64
  import { recordStealthEscalation } from "./src/utils/complianceAudit.js";
64
- import { loadImpit, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
65
+ import { loadImpit, impitEnabled, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
65
66
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
66
67
  import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
67
68
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
@@ -110,7 +111,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
110
111
  // Create the server
111
112
  const server = new McpServer({
112
113
  name: "crawlforge",
113
- version: "6.11.0",
114
+ version: "6.12.1",
114
115
  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.",
115
116
  homepage: "https://www.crawlforge.dev",
116
117
  icon: "https://www.crawlforge.dev/icon.png",
@@ -259,7 +260,8 @@ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
259
260
  // Stealth review Phase 7: under "auto", a Chrome TLS handshake with the
260
261
  // honest User-Agent is tried before any browser launches. A caller who
261
262
  // named an engine asked for that browser, so it is not tried for them.
262
- if (engine === 'auto' && await loadImpit()) {
263
+ // CRAWLFORGE_IMPIT=off skips it for a deployment whose exit IP it cannot help.
264
+ if (engine === 'auto' && impitEnabled() && await loadImpit()) {
263
265
  recordStealthEscalation({ url, tool, engine: IMPIT_ENGINE, ...auditIdentity });
264
266
  const page = await impitFetchPage(url);
265
267
  if (page) return { ...page, warnings };
@@ -939,12 +941,12 @@ registerToolIfEnabled("read_result", {
939
941
 
940
942
  // Tool: scrape_with_actions
941
943
  registerToolIfEnabled("scrape_with_actions", {
942
- description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
944
+ description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
943
945
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
944
946
  inputSchema: {
945
947
  url: z.string().url().describe("The URL to scrape"),
946
948
  actions: z.array(z.object({
947
- type: z.enum(['snapshot', 'wait', 'click', 'type', 'press', 'scroll', 'screenshot', 'executeJavaScript', 'select', 'hover', 'navigate']),
949
+ type: z.enum(['snapshot', 'wait', 'click', 'type', 'press', 'scroll', 'screenshot', 'executeJavaScript', 'select', 'hover', 'navigate']).describe("executeJavaScript is disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; on the hosted API it is refused."),
948
950
  selector: z.string().optional().describe("A CSS selector, or a @e1 ref from an earlier snapshot action in this chain"),
949
951
  text: z.string().optional(),
950
952
  key: z.string().optional(),
@@ -1021,7 +1023,7 @@ registerToolIfEnabled("scrape_with_actions", {
1021
1023
  includeImages: z.boolean().default(true)
1022
1024
  }).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response."),
1023
1025
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
1024
- maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
1026
+ maxRetries: z.number().min(0).max(3).default(0).describe("Whole-chain retries on failure (0-3). A retry re-navigates to the starting URL and replays every action; each attempt is reported under attempts[]."),
1025
1027
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
1026
1028
  respect_robots: COMPLIANCE_PARAMS.respect_robots,
1027
1029
  ...MAX_INLINE_CHARS_PARAM,
@@ -1035,17 +1037,11 @@ registerToolIfEnabled("scrape_with_actions", {
1035
1037
  // resources (the documented contract) and annotate each with its URI.
1036
1038
  // The base64 `data` is dropped once stored, as the stealth_mode block
1037
1039
  // below already does: a failed chain's error screenshot came back inline
1038
- // as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09).
1039
- if (Array.isArray(result.screenshots)) {
1040
- result.screenshots = result.screenshots.map((shot) => {
1041
- if (shot?.actionId && shot?.data) {
1042
- resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1043
- const { data, ...rest } = shot;
1044
- return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
1045
- }
1046
- return shot;
1047
- });
1048
- }
1040
+ // as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09). The
1041
+ // same bytes also sit in actionResults[].result, content.screenshots and
1042
+ // attempts[].results, so every copy is stripped, not just the top-level
1043
+ // array (Phase 0, 0.4).
1044
+ stripScreenshotData(result, (actionId, data) => resourceRegistry.storeScreenshot(actionId, data));
1049
1045
 
1050
1046
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1051
1047
  } catch (error) {
@@ -12,6 +12,7 @@ export function register(program) {
12
12
  .description('Run browser automation actions against a URL')
13
13
  .requiredOption('--script <file>', 'JSON file containing action script')
14
14
  .option('--screenshot', 'Capture screenshots during action execution')
15
+ .addHelpText('after', '\nexecuteJavaScript actions need ALLOW_JAVASCRIPT_EXECUTION=true in the server environment; otherwise they are refused.\n')
15
16
  .action(async (url, opts, cmd) => {
16
17
  const globals = cmd.parent.opts();
17
18
  const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
@@ -22,11 +22,12 @@ const JS_EXECUTION_TIMEOUT_MS = parseInt(process.env.JS_EXECUTION_TIMEOUT_MS ||
22
22
  // for, the backstop can only say "timed out".
23
23
  const ACTION_TIMEOUT_GRACE_MS = 2000;
24
24
 
25
- // Ceiling for a single error-recovery strategy. By the time recovery runs the
26
- // action has already spent its whole timeout failing, so each strategy gets a
27
- // bounded slice — granting it another full deadline made a chain that was never
28
- // going to work cost several times its stated timeout.
29
- const RECOVERY_TIMEOUT_MS = 5000;
25
+ // Total error-recovery budget per action, shared by every strategy it tries
26
+ // (their delay() pauses included). By the time recovery runs the action has
27
+ // already spent its whole timeout failing, so recovery gets one bounded slice —
28
+ // giving each strategy another full deadline made a chain that was never going
29
+ // to work cost several times its stated timeout.
30
+ const RECOVERY_TIMEOUT_MS = 3000;
30
31
 
31
32
  // The only states locator.waitFor()/page.waitForSelector() accept. The rest of
32
33
  // the wait-action enum (enabled/disabled/stable) are ElementHandle states and
@@ -176,6 +177,8 @@ const ActionChainSchema = z.object({
176
177
  continueOnError: z.boolean().default(false),
177
178
  timeout: z.number().min(1000).max(300000).default(30000),
178
179
  retryChain: z.number().min(0).max(3).default(0),
180
+ // Per-chain override of the constructor's enableScreenshotOnError.
181
+ screenshotOnError: z.boolean().optional(),
179
182
  metadata: z.record(z.any()).prefault({})
180
183
  });
181
184
 
@@ -272,6 +275,10 @@ export class ActionExecutor extends EventEmitter {
272
275
  browserOptions,
273
276
  startTime,
274
277
  results: [],
278
+ // One entry per run of the chain (retryChain replays it); `attempt` is
279
+ // the 1-based run whose per-action results are in `results`.
280
+ attempt: 0,
281
+ attempts: [],
275
282
  errors: [],
276
283
  screenshots: [],
277
284
  metadata: {
@@ -318,7 +325,7 @@ export class ActionExecutor extends EventEmitter {
318
325
  executionContext.error = error.message;
319
326
 
320
327
  // Capture error screenshot if enabled
321
- if (this.enableScreenshotOnError && page) {
328
+ if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
322
329
  try {
323
330
  const errorScreenshot = await this.captureScreenshot(page, {
324
331
  fullPage: true,
@@ -378,6 +385,7 @@ export class ActionExecutor extends EventEmitter {
378
385
  screenshotCount: executionContext.screenshots.length,
379
386
  capturedStates: undefined,
380
387
  capturedStateCount: (executionContext.capturedStates || []).length,
388
+ attempts: undefined, // each carries a copy of a results array
381
389
  results: executionContext.results.map(r => (
382
390
  r?.result?.data !== undefined
383
391
  ? { ...r, result: { ...r.result, data: undefined, dataBytes: typeof r.result.data === 'string' ? r.result.data.length : undefined } }
@@ -402,6 +410,8 @@ export class ActionExecutor extends EventEmitter {
402
410
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
403
411
  executionTime: Date.now() - startTime,
404
412
  results: executionContext.results,
413
+ attempt: executionContext.attempt,
414
+ attempts: executionContext.attempts,
405
415
  screenshots: executionContext.screenshots,
406
416
  capturedStates: executionContext.capturedStates || [],
407
417
  metadata: executionContext.metadata,
@@ -424,6 +434,8 @@ export class ActionExecutor extends EventEmitter {
424
434
  // results, the error screenshot, and any intermediate-state
425
435
  // captures) instead of discarding them.
426
436
  results: executionContext?.results || [],
437
+ attempt: executionContext?.attempt ?? 0,
438
+ attempts: executionContext?.attempts || [],
427
439
  screenshots: executionContext?.screenshots || [],
428
440
  capturedStates: executionContext?.capturedStates || []
429
441
  };
@@ -440,9 +452,11 @@ export class ActionExecutor extends EventEmitter {
440
452
  let lastError;
441
453
 
442
454
  for (let attempt = 0; attempt <= chain.retryChain; attempt++) {
455
+ executionContext.attempt = attempt + 1;
443
456
  try {
444
457
  if (attempt > 0) {
445
458
  this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
459
+ // The failed run's results are already recorded in attempts[].
446
460
  executionContext.results = []; // Clear previous results on retry
447
461
  executionContext.capturedStates = []; // Clear previous captures on retry
448
462
  // Replaying the chain against whatever the failed attempt left behind
@@ -454,7 +468,14 @@ export class ActionExecutor extends EventEmitter {
454
468
 
455
469
  // Execute actions in sequence
456
470
  for (let i = 0; i < chain.actions.length; i++) {
457
- const action = chain.actions[i];
471
+ // chain.timeout is the deadline for actions that name none and the
472
+ // ceiling for those that do — actionTimeout() reads it off the
473
+ // action, so without this the chain's timeout never reached a
474
+ // Playwright call (executeActionsOnPage applies the same default).
475
+ const action = {
476
+ ...chain.actions[i],
477
+ timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
478
+ };
458
479
  const actionResult = await this.executeActionInternal(page, action, executionContext);
459
480
 
460
481
  executionContext.results.push(actionResult);
@@ -509,10 +530,16 @@ export class ActionExecutor extends EventEmitter {
509
530
  }
510
531
  }
511
532
 
533
+ executionContext.attempts.push({
534
+ attempt: attempt + 1, success: true, results: [...executionContext.results]
535
+ });
512
536
  return { success: true, attempt: attempt + 1 };
513
537
 
514
538
  } catch (error) {
515
539
  lastError = error;
540
+ executionContext.attempts.push({
541
+ attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
542
+ });
516
543
  this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
517
544
 
518
545
  if (attempt < chain.retryChain) {
@@ -754,12 +781,18 @@ export class ActionExecutor extends EventEmitter {
754
781
  }
755
782
 
756
783
  /**
757
- * Deadline for one recovery strategy — bounded, see RECOVERY_TIMEOUT_MS.
784
+ * Deadline for one Playwright call inside a recovery strategy: what is left
785
+ * of the shared budget (see RECOVERY_TIMEOUT_MS), never more than the
786
+ * action's own timeout. Strategies call it after their delay() so the pause
787
+ * is charged to the same budget. Floored at 1 because Playwright reads a
788
+ * timeout of 0 as "wait forever".
758
789
  * @param {Object} action - Action configuration
790
+ * @param {number} [deadline] - Epoch ms at which the recovery budget ends
759
791
  * @returns {number} Timeout in ms
760
792
  */
761
- recoveryTimeout(action) {
762
- return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
793
+ recoveryTimeout(action, deadline) {
794
+ const left = deadline === undefined ? RECOVERY_TIMEOUT_MS : deadline - Date.now();
795
+ return Math.max(1, Math.min(this.actionTimeout(action), left));
763
796
  }
764
797
 
765
798
  /**
@@ -1500,16 +1533,35 @@ export class ActionExecutor extends EventEmitter {
1500
1533
  return { success: false };
1501
1534
  }
1502
1535
 
1536
+ // An element that is not in the document cannot be force-clicked, scrolled
1537
+ // into view or focused, so every strategy below would only spend the
1538
+ // budget re-waiting for it. count() answers at once without waiting.
1539
+ if (action.selector) {
1540
+ let matches = 1;
1541
+ try {
1542
+ matches = await this.elementLocator(page, action.selector).count();
1543
+ } catch (_) {
1544
+ // A ref that cannot be resolved, or a page stub in tests: let the
1545
+ // strategies decide.
1546
+ }
1547
+ if (matches === 0) return { success: false };
1548
+ }
1549
+
1503
1550
  const strategies = this.errorRecoveryStrategies.get(action.type) || [];
1504
1551
  // `retries` caps how many strategies get a turn. Walking all of them
1505
1552
  // unconditionally would add a second full round of timeouts to every action
1506
1553
  // that was never going to succeed.
1507
1554
  const budget = Math.max(0, action.retries ?? 1);
1555
+ // One deadline shared by every strategy this action tries, so recovery
1556
+ // costs at most RECOVERY_TIMEOUT_MS on top of the action's own timeout
1557
+ // however many strategies are registered.
1558
+ const deadline = Date.now() + RECOVERY_TIMEOUT_MS;
1508
1559
 
1509
1560
  for (const strategy of strategies.slice(0, budget)) {
1561
+ if (Date.now() >= deadline) break;
1510
1562
  try {
1511
1563
  this.log('info', 'Attempting error recovery with strategy: ' + strategy.name);
1512
- const result = await strategy.recover(page, action, error, executionContext);
1564
+ const result = await strategy.recover(page, action, error, executionContext, deadline);
1513
1565
 
1514
1566
  if (result.success) {
1515
1567
  return {
@@ -1534,21 +1586,20 @@ export class ActionExecutor extends EventEmitter {
1534
1586
  this.errorRecoveryStrategies.set('click', [
1535
1587
  {
1536
1588
  name: 'waitAndRetry',
1537
- recover: async (page, action) => {
1589
+ recover: async (page, action, _error, _context, deadline) => {
1538
1590
  await this.delay(1000);
1539
1591
  await this.elementLocator(page, action.selector)
1540
- .click({ force: true, timeout: this.recoveryTimeout(action) });
1592
+ .click({ force: true, timeout: this.recoveryTimeout(action, deadline) });
1541
1593
  return { success: true, data: { recovered: true, strategy: 'waitAndRetry' } };
1542
1594
  }
1543
1595
  },
1544
1596
  {
1545
1597
  name: 'scrollIntoView',
1546
- recover: async (page, action) => {
1547
- const timeout = this.recoveryTimeout(action);
1598
+ recover: async (page, action, _error, _context, deadline) => {
1548
1599
  const locator = this.elementLocator(page, action.selector);
1549
- await locator.scrollIntoViewIfNeeded({ timeout });
1600
+ await locator.scrollIntoViewIfNeeded({ timeout: this.recoveryTimeout(action, deadline) });
1550
1601
  await this.delay(500);
1551
- await locator.click({ timeout });
1602
+ await locator.click({ timeout: this.recoveryTimeout(action, deadline) });
1552
1603
  return { success: true, data: { recovered: true, strategy: 'scrollIntoView' } };
1553
1604
  }
1554
1605
  }
@@ -1558,33 +1609,20 @@ export class ActionExecutor extends EventEmitter {
1558
1609
  this.errorRecoveryStrategies.set('type', [
1559
1610
  {
1560
1611
  name: 'focusAndRetry',
1561
- recover: async (page, action) => {
1562
- const timeout = this.recoveryTimeout(action);
1612
+ recover: async (page, action, _error, _context, deadline) => {
1563
1613
  const locator = this.elementLocator(page, action.selector);
1564
- await locator.focus({ timeout });
1614
+ await locator.focus({ timeout: this.recoveryTimeout(action, deadline) });
1565
1615
  await this.delay(500);
1566
- await locator.pressSequentially(action.text, { delay: action.delay, timeout });
1616
+ await locator.pressSequentially(action.text, {
1617
+ delay: action.delay, timeout: this.recoveryTimeout(action, deadline)
1618
+ });
1567
1619
  return { success: true, data: { recovered: true, strategy: 'focusAndRetry' } };
1568
1620
  }
1569
1621
  }
1570
1622
  ]);
1571
1623
 
1572
- // Wait action recovery strategies
1573
- this.errorRecoveryStrategies.set('wait', [
1574
- {
1575
- name: 'extendTimeout',
1576
- recover: async (page, action) => {
1577
- if (!action.selector) return { success: false };
1578
- // One more bounded window, not a doubled one: the action has already
1579
- // waited its full timeout, so doubling made a wait that could never
1580
- // resolve cost 3x what the caller asked for.
1581
- await this.waitForCondition(
1582
- page, action.selector, action.condition, this.recoveryTimeout(action)
1583
- );
1584
- return { success: true, data: { recovered: true, strategy: 'extendTimeout' } };
1585
- }
1586
- }
1587
- ]);
1624
+ // No strategies for `wait`: the wait already was the retry, so a failed
1625
+ // one has nothing to recover and gets no second window.
1588
1626
  }
1589
1627
 
1590
1628
  /**
@@ -0,0 +1,50 @@
1
+ /**
2
+ * screenshotResources — move a scrape_with_actions result's screenshot bytes
3
+ * out of the inline result and into crawlforge://screenshot/{actionId}
4
+ * resources.
5
+ *
6
+ * The tool carries the same base64 in up to four places: `screenshots`,
7
+ * `actionResults[].result` (screenshot actions), `content.screenshots`
8
+ * (formats:["screenshots"] copies the same array) and `attempts[].results`
9
+ * (one per chain attempt). Stripping only the first left the others inline
10
+ * (Phase 0, 0.4). Each actionId is published once.
11
+ */
12
+
13
+ /**
14
+ * @param {object} result - the tool result, mutated in place
15
+ * @param {(actionId: string, data: string) => void} publish - stores the base64
16
+ * @returns {object} the same result
17
+ */
18
+ export function stripScreenshotData(result, publish) {
19
+ const published = new Set();
20
+
21
+ const strip = (shot, actionId) => {
22
+ if (!actionId || typeof shot?.data !== 'string') return shot;
23
+ if (!published.has(actionId)) {
24
+ publish(actionId, shot.data);
25
+ published.add(actionId);
26
+ }
27
+ const { data, ...rest } = shot;
28
+ return { ...rest, resourceUri: `crawlforge://screenshot/${actionId}` };
29
+ };
30
+
31
+ const stripResults = (results) => results.map((r) => (
32
+ r?.type === 'screenshot' ? { ...r, result: strip(r.result, r.id) } : r
33
+ ));
34
+
35
+ if (Array.isArray(result.screenshots)) {
36
+ result.screenshots = result.screenshots.map((shot) => strip(shot, shot?.actionId));
37
+ }
38
+ if (Array.isArray(result.content?.screenshots)) {
39
+ result.content.screenshots = result.content.screenshots.map((shot) => strip(shot, shot?.actionId));
40
+ }
41
+ if (Array.isArray(result.actionResults)) {
42
+ result.actionResults = stripResults(result.actionResults);
43
+ }
44
+ if (Array.isArray(result.attempts)) {
45
+ result.attempts = result.attempts.map((a) => (
46
+ Array.isArray(a?.results) ? { ...a, results: stripResults(a.results) } : a
47
+ ));
48
+ }
49
+ return result;
50
+ }
@@ -157,7 +157,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
157
157
  | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
158
158
  | `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
159
159
  | `continueOnActionError` | `false` | Keep going if one action fails. |
160
- | `maxRetries` | `1` | 0–3 retries on failure. |
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[]`. |
161
161
  | `screenshotOnError` | `true` | Capture a screenshot when an error occurs. |
162
162
 
163
163
  ## CLI action-script format
@@ -91,6 +91,12 @@ Cloudflare scores the address and its ASN before it serves a challenge, so a
91
91
  datacenter proxy changes the address without changing the class of address being
92
92
  scored. Route through your own residential proxy — CrawlForge supplies none.
93
93
 
94
+ A self-hosted server can instead hand every stealth session to Browserbase:
95
+ set `CRAWLFORGE_BROWSER_BACKEND=browserbase` and `BROWSERBASE_API_KEY` (see
96
+ `docs/cloud-browser.md`). That account supplies the residential exit and any
97
+ CAPTCHA solving on its own terms; CrawlForge itself never solves a challenge —
98
+ a challenge page is reported as blocked.
99
+
94
100
  Server-wide, set `CRAWLFORGE_STEALTH_PROXIES` (comma-separated proxy URLs): the
95
101
  `scrape` escalation stage, `stealth_mode`, `browser_session`,
96
102
  `scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic
@@ -215,7 +215,7 @@ const ScrapeWithActionsSchema = z.object({
215
215
 
216
216
  // Error handling
217
217
  continueOnActionError: z.boolean().default(false),
218
- maxRetries: z.number().min(0).max(3).default(1),
218
+ maxRetries: z.number().min(0).max(3).default(0),
219
219
  screenshotOnError: z.boolean().default(true),
220
220
 
221
221
  // ── Recording / replay ──────────────────────────────────────────────────
@@ -446,6 +446,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
446
446
  continueOnError: params.continueOnActionError,
447
447
  timeout: browserOptions.timeout || 30000,
448
448
  retryChain: params.maxRetries,
449
+ screenshotOnError: params.screenshotOnError,
449
450
  metadata: {
450
451
  sessionId: sessionContext.id,
451
452
  originalActionCount: params.actions.length,
@@ -483,6 +484,12 @@ export class ScrapeWithActionsTool extends EventEmitter {
483
484
 
484
485
  // Process action results
485
486
  const actionResults = this.processActionResults(chainResult.results);
487
+ // Every attempt the executor made (retryChain > 0 replays the whole
488
+ // chain), in the same shape as actionResults; `attempt` is the one whose
489
+ // results are reported above.
490
+ const attempts = (chainResult.attempts || [
491
+ { attempt: 1, success: chainResult.success, error: chainResult.error, results: chainResult.results }
492
+ ]).map((a) => ({ ...a, results: this.processActionResults(a.results || []) }));
486
493
  const intermediateStates = params.captureIntermediateStates ?
487
494
  await this.extractIntermediateStates(chainResult.capturedStates || [], params) : [];
488
495
 
@@ -527,6 +534,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
527
534
  ...(warnings.length ? { warnings } : {}),
528
535
 
529
536
  actionResults,
537
+ attempt: chainResult.attempt ?? attempts.length,
538
+ attempts,
530
539
  totalActions: params.actions.length,
531
540
  successfulActions: actionResults.filter(r => r.success).length,
532
541
  failedActions: actionResults.filter(r => !r.success).length,
@@ -19,6 +19,9 @@ import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
19
19
  // the caller an exact way to ask for less.
20
20
  const LARGE_RESULT_BYTES = 256_000;
21
21
 
22
+ const BLOCKED_HINT =
23
+ 'stealth_mode operation:"scrape" renders the page in a browser and returns the HTML; the embedded state is in the returned html. Do not repeat this call with a different path.';
24
+
22
25
  /**
23
26
  * @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
24
27
  */
@@ -81,8 +84,15 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
81
84
  }]
82
85
  };
83
86
  } catch (error) {
87
+ // A block or server error is not a path problem: the generic hint
88
+ // ("call again without `path`") would send the caller back into the same
89
+ // wall. Naming the next step here keeps withAuth from appending it.
90
+ const blocked = /^HTTP (403|429|444|5\d\d)\b/.test(error.message);
84
91
  return {
85
- content: [{ type: 'text', text: `Failed to extract embedded state: ${error.message}` }],
92
+ content: [{
93
+ type: 'text',
94
+ text: `Failed to extract embedded state: ${error.message}` + (blocked ? `\nNext step: ${BLOCKED_HINT}` : '')
95
+ }],
86
96
  isError: true
87
97
  };
88
98
  }
@@ -48,6 +48,19 @@ const MIN_UNRENDERED_TEXT_CHARS = 200;
48
48
 
49
49
  let impitClass; // undefined: not tried yet; null: unavailable
50
50
 
51
+ /**
52
+ * Whether this deployment runs the step. `CRAWLFORGE_IMPIT=off` turns it off.
53
+ * Like the stealth engine, what it can clear depends on the exit IP: from a
54
+ * residential IP it cleared indeed.com, and from the hosted instance's
55
+ * datacenter IP (AS14618, Ashburn) it cleared nothing in 3 runs (2026-09-27),
56
+ * so render.yaml sets it off there. Read on every call, like the stealth
57
+ * proxy list, so an operator can change it without a restart.
58
+ * @returns {boolean}
59
+ */
60
+ export function impitEnabled() {
61
+ return String(process.env.CRAWLFORGE_IMPIT || '').trim().toLowerCase() !== 'off';
62
+ }
63
+
51
64
  /**
52
65
  * The Impit class, or null when the optional dependency is absent or its
53
66
  * native bindings do not load on this platform. Tried once per process.