crawlforge-mcp-server 6.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.12.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",
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";
@@ -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.12.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",
@@ -940,12 +941,12 @@ registerToolIfEnabled("read_result", {
940
941
 
941
942
  // Tool: scrape_with_actions
942
943
  registerToolIfEnabled("scrape_with_actions", {
943
- 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\"}]})",
944
945
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
945
946
  inputSchema: {
946
947
  url: z.string().url().describe("The URL to scrape"),
947
948
  actions: z.array(z.object({
948
- 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."),
949
950
  selector: z.string().optional().describe("A CSS selector, or a @e1 ref from an earlier snapshot action in this chain"),
950
951
  text: z.string().optional(),
951
952
  key: z.string().optional(),
@@ -1022,7 +1023,7 @@ registerToolIfEnabled("scrape_with_actions", {
1022
1023
  includeImages: z.boolean().default(true)
1023
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."),
1024
1025
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
1025
- 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[]."),
1026
1027
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
1027
1028
  respect_robots: COMPLIANCE_PARAMS.respect_robots,
1028
1029
  ...MAX_INLINE_CHARS_PARAM,
@@ -1036,17 +1037,11 @@ registerToolIfEnabled("scrape_with_actions", {
1036
1037
  // resources (the documented contract) and annotate each with its URI.
1037
1038
  // The base64 `data` is dropped once stored, as the stealth_mode block
1038
1039
  // below already does: a failed chain's error screenshot came back inline
1039
- // as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09).
1040
- if (Array.isArray(result.screenshots)) {
1041
- result.screenshots = result.screenshots.map((shot) => {
1042
- if (shot?.actionId && shot?.data) {
1043
- resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1044
- const { data, ...rest } = shot;
1045
- return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
1046
- }
1047
- return shot;
1048
- });
1049
- }
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));
1050
1045
 
1051
1046
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1052
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
  }