crawlforge-mcp-server 6.12.0 → 6.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.12.0",
3
+ "version": "6.14.0",
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",
@@ -113,6 +113,7 @@
113
113
  "package.json"
114
114
  ],
115
115
  "dependencies": {
116
+ "@duckduckgo/autoconsent": "^16.43.0",
116
117
  "@modelcontextprotocol/core": "^2.0.0",
117
118
  "@modelcontextprotocol/node": "^2.0.0",
118
119
  "@modelcontextprotocol/server": "^2.0.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";
@@ -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.14.0",
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,13 +941,13 @@ 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, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. 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
- selector: z.string().optional().describe("A CSS selector, or a @e1 ref from an earlier snapshot action in this chain"),
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."),
950
+ selector: z.string().optional().describe("A CSS selector, or a ref from an earlier snapshot action in this chain (@e1, including elements inside shadow roots and iframes)"),
950
951
  text: z.string().optional(),
951
952
  key: z.string().optional(),
952
953
  script: z.string().optional(),
@@ -1013,7 +1014,8 @@ registerToolIfEnabled("scrape_with_actions", {
1013
1014
  viewportHeight: z.number().min(600).max(1080).default(720),
1014
1015
  timeout: z.number().min(10000).max(120000).default(30000),
1015
1016
  stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
1016
- engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium.")
1017
+ engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
1018
+ consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged.")
1017
1019
  }).optional().describe("Browser configuration options"),
1018
1020
  extractionOptions: z.object({
1019
1021
  selectors: z.record(z.string()).optional(),
@@ -1022,7 +1024,7 @@ registerToolIfEnabled("scrape_with_actions", {
1022
1024
  includeImages: z.boolean().default(true)
1023
1025
  }).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
1026
  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"),
1027
+ 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
1028
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
1027
1029
  respect_robots: COMPLIANCE_PARAMS.respect_robots,
1028
1030
  ...MAX_INLINE_CHARS_PARAM,
@@ -1036,17 +1038,11 @@ registerToolIfEnabled("scrape_with_actions", {
1036
1038
  // resources (the documented contract) and annotate each with its URI.
1037
1039
  // The base64 `data` is dropped once stored, as the stealth_mode block
1038
1040
  // 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
- }
1041
+ // as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09). The
1042
+ // same bytes also sit in actionResults[].result, content.screenshots and
1043
+ // attempts[].results, so every copy is stripped, not just the top-level
1044
+ // array (Phase 0, 0.4).
1045
+ stripScreenshotData(result, (actionId, data) => resourceRegistry.storeScreenshot(actionId, data));
1050
1046
 
1051
1047
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1052
1048
  } catch (error) {
@@ -1056,7 +1052,7 @@ registerToolIfEnabled("scrape_with_actions", {
1056
1052
 
1057
1053
  // Tool: browser_session
1058
1054
  registerToolIfEnabled("browser_session", {
1059
- description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1055
+ description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ..., including elements inside open shadow roots and iframes), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1060
1056
  annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1061
1057
  inputSchema: {
1062
1058
  operation: z.enum(["open", "snapshot", "act", "read", "screenshot", "close", "list"]).describe("open a session, observe it, act on it, read it, or close it"),
@@ -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 };
@@ -10,6 +10,8 @@ import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed } from '../utils/ssrfGuard.js';
11
11
  import { browserPreflight } from '../utils/robotsGate.js';
12
12
  import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
13
+ import { settlePage } from './browser/settle.js';
14
+ import { handleConsent } from './browser/consent.js';
13
15
 
14
16
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
15
17
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -22,11 +24,12 @@ const JS_EXECUTION_TIMEOUT_MS = parseInt(process.env.JS_EXECUTION_TIMEOUT_MS ||
22
24
  // for, the backstop can only say "timed out".
23
25
  const ACTION_TIMEOUT_GRACE_MS = 2000;
24
26
 
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;
27
+ // Total error-recovery budget per action, shared by every strategy it tries
28
+ // (their delay() pauses included). By the time recovery runs the action has
29
+ // already spent its whole timeout failing, so recovery gets one bounded slice —
30
+ // giving each strategy another full deadline made a chain that was never going
31
+ // to work cost several times its stated timeout.
32
+ const RECOVERY_TIMEOUT_MS = 3000;
30
33
 
31
34
  // The only states locator.waitFor()/page.waitForSelector() accept. The rest of
32
35
  // the wait-action enum (enabled/disabled/stable) are ElementHandle states and
@@ -176,6 +179,8 @@ const ActionChainSchema = z.object({
176
179
  continueOnError: z.boolean().default(false),
177
180
  timeout: z.number().min(1000).max(300000).default(30000),
178
181
  retryChain: z.number().min(0).max(3).default(0),
182
+ // Per-chain override of the constructor's enableScreenshotOnError.
183
+ screenshotOnError: z.boolean().optional(),
179
184
  metadata: z.record(z.any()).prefault({})
180
185
  });
181
186
 
@@ -272,6 +277,10 @@ export class ActionExecutor extends EventEmitter {
272
277
  browserOptions,
273
278
  startTime,
274
279
  results: [],
280
+ // One entry per run of the chain (retryChain replays it); `attempt` is
281
+ // the 1-based run whose per-action results are in `results`.
282
+ attempt: 0,
283
+ attempts: [],
275
284
  errors: [],
276
285
  screenshots: [],
277
286
  metadata: {
@@ -296,6 +305,13 @@ export class ActionExecutor extends EventEmitter {
296
305
  page = await this.initializePage(url, browserOptions);
297
306
  executionContext.page = page;
298
307
 
308
+ // Cookie/consent wall on the landing page (browserOptions.consent,
309
+ // off unless asked for). A `navigate` action does the same for its
310
+ // page — see executeNavigateAction.
311
+ if (browserOptions.consent && browserOptions.consent !== 'off') {
312
+ executionContext.consent = await handleConsent(page, browserOptions.consent);
313
+ }
314
+
299
315
  // Execute chain with potential retries
300
316
  chainResult = await this.executeChainWithRetries(executionContext);
301
317
 
@@ -318,7 +334,7 @@ export class ActionExecutor extends EventEmitter {
318
334
  executionContext.error = error.message;
319
335
 
320
336
  // Capture error screenshot if enabled
321
- if (this.enableScreenshotOnError && page) {
337
+ if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
322
338
  try {
323
339
  const errorScreenshot = await this.captureScreenshot(page, {
324
340
  fullPage: true,
@@ -378,6 +394,7 @@ export class ActionExecutor extends EventEmitter {
378
394
  screenshotCount: executionContext.screenshots.length,
379
395
  capturedStates: undefined,
380
396
  capturedStateCount: (executionContext.capturedStates || []).length,
397
+ attempts: undefined, // each carries a copy of a results array
381
398
  results: executionContext.results.map(r => (
382
399
  r?.result?.data !== undefined
383
400
  ? { ...r, result: { ...r.result, data: undefined, dataBytes: typeof r.result.data === 'string' ? r.result.data.length : undefined } }
@@ -400,8 +417,11 @@ export class ActionExecutor extends EventEmitter {
400
417
  finalUrl: executionContext.finalUrl || url,
401
418
  finalHtml: executionContext.finalHtml,
402
419
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
420
+ consent: executionContext.consent,
403
421
  executionTime: Date.now() - startTime,
404
422
  results: executionContext.results,
423
+ attempt: executionContext.attempt,
424
+ attempts: executionContext.attempts,
405
425
  screenshots: executionContext.screenshots,
406
426
  capturedStates: executionContext.capturedStates || [],
407
427
  metadata: executionContext.metadata,
@@ -424,6 +444,8 @@ export class ActionExecutor extends EventEmitter {
424
444
  // results, the error screenshot, and any intermediate-state
425
445
  // captures) instead of discarding them.
426
446
  results: executionContext?.results || [],
447
+ attempt: executionContext?.attempt ?? 0,
448
+ attempts: executionContext?.attempts || [],
427
449
  screenshots: executionContext?.screenshots || [],
428
450
  capturedStates: executionContext?.capturedStates || []
429
451
  };
@@ -440,9 +462,11 @@ export class ActionExecutor extends EventEmitter {
440
462
  let lastError;
441
463
 
442
464
  for (let attempt = 0; attempt <= chain.retryChain; attempt++) {
465
+ executionContext.attempt = attempt + 1;
443
466
  try {
444
467
  if (attempt > 0) {
445
468
  this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
469
+ // The failed run's results are already recorded in attempts[].
446
470
  executionContext.results = []; // Clear previous results on retry
447
471
  executionContext.capturedStates = []; // Clear previous captures on retry
448
472
  // Replaying the chain against whatever the failed attempt left behind
@@ -454,7 +478,14 @@ export class ActionExecutor extends EventEmitter {
454
478
 
455
479
  // Execute actions in sequence
456
480
  for (let i = 0; i < chain.actions.length; i++) {
457
- const action = chain.actions[i];
481
+ // chain.timeout is the deadline for actions that name none and the
482
+ // ceiling for those that do — actionTimeout() reads it off the
483
+ // action, so without this the chain's timeout never reached a
484
+ // Playwright call (executeActionsOnPage applies the same default).
485
+ const action = {
486
+ ...chain.actions[i],
487
+ timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
488
+ };
458
489
  const actionResult = await this.executeActionInternal(page, action, executionContext);
459
490
 
460
491
  executionContext.results.push(actionResult);
@@ -509,10 +540,16 @@ export class ActionExecutor extends EventEmitter {
509
540
  }
510
541
  }
511
542
 
543
+ executionContext.attempts.push({
544
+ attempt: attempt + 1, success: true, results: [...executionContext.results]
545
+ });
512
546
  return { success: true, attempt: attempt + 1 };
513
547
 
514
548
  } catch (error) {
515
549
  lastError = error;
550
+ executionContext.attempts.push({
551
+ attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
552
+ });
516
553
  this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
517
554
 
518
555
  if (attempt < chain.retryChain) {
@@ -754,26 +791,33 @@ export class ActionExecutor extends EventEmitter {
754
791
  }
755
792
 
756
793
  /**
757
- * Deadline for one recovery strategy — bounded, see RECOVERY_TIMEOUT_MS.
794
+ * Deadline for one Playwright call inside a recovery strategy: what is left
795
+ * of the shared budget (see RECOVERY_TIMEOUT_MS), never more than the
796
+ * action's own timeout. Strategies call it after their delay() so the pause
797
+ * is charged to the same budget. Floored at 1 because Playwright reads a
798
+ * timeout of 0 as "wait forever".
758
799
  * @param {Object} action - Action configuration
800
+ * @param {number} [deadline] - Epoch ms at which the recovery budget ends
759
801
  * @returns {number} Timeout in ms
760
802
  */
761
- recoveryTimeout(action) {
762
- return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
803
+ recoveryTimeout(action, deadline) {
804
+ const left = deadline === undefined ? RECOVERY_TIMEOUT_MS : deadline - Date.now();
805
+ return Math.max(1, Math.min(this.actionTimeout(action), left));
763
806
  }
764
807
 
765
808
  /**
766
809
  * Resolve a caller-supplied selector.
767
810
  *
768
811
  * A selector starting with `@` names a ref a prior snapshot action assigned,
769
- * and resolves to the attribute selector that snapshot stamped on the
770
- * element; anything else is already a CSS selector. Every selector a caller
812
+ * and resolves to the Playwright selector that snapshot recorded for it
813
+ * (`aria-ref=…` from the native snapshot, `[data-cf-ref=…]` from the walk
814
+ * fallback); anything else is already a CSS selector. Every selector a caller
771
815
  * writes goes through here, so refs work in every action type without any
772
- * schema change — and, because a ref resolves to an ordinary CSS selector,
773
- * in the stealth human-behaviour paths too.
816
+ * schema change — including the stealth human-behaviour paths, which take
817
+ * Playwright selectors.
774
818
  * @param {Page} page - Playwright page
775
819
  * @param {string} selector - CSS selector, or an `@e1` snapshot ref
776
- * @returns {string} CSS selector
820
+ * @returns {string} Playwright selector
777
821
  */
778
822
  resolveSelector(page, selector) {
779
823
  return isRef(selector) ? resolveRef(page, selector) : selector;
@@ -829,23 +873,25 @@ export class ActionExecutor extends EventEmitter {
829
873
  }
830
874
 
831
875
  /**
832
- * Give a navigation started by the action that just ran a chance to commit.
876
+ * Let the page settle after the action that just ran — a navigation commit
877
+ * and load, or an SPA re-render that fires no load event at all.
833
878
  *
834
879
  * Playwright auto-waits on the element it acts on, but nothing waits on the
835
- * *document* a click or keypress may have replaced, so the next action could
836
- * run against the outgoing page. A page that never navigated is already past
837
- * this state and returns immediately; a page that doesn't settle in time is
838
- * not itself an action failure, hence the catch.
880
+ * *document* a click or keypress may have changed, so the next action could
881
+ * run against the outgoing page. settlePage waits for `load` and then a quiet
882
+ * window (no DOM mutations, no requests in flight), capped and never longer
883
+ * than what is left of the action's `timeout`; a page that doesn't settle in
884
+ * time is not itself an action failure, so it never throws.
839
885
  * @param {Page} page - Playwright page
840
- * @param {number} timeout - Timeout in ms
841
- * @returns {Promise<void>}
886
+ * @param {number} timeout - The action's timeout in ms
887
+ * @param {number} startedAt - When the action started (Date.now()); the settle
888
+ * gets only the remainder, so a slow element wait plus a busy page cannot
889
+ * outrun the backstop and turn a successful action into a timeout
890
+ * @returns {Promise<{waited_ms: number, settled_by: string}>}
842
891
  */
843
- async settleAfterInteraction(page, timeout) {
844
- try {
845
- await page.waitForLoadState('domcontentloaded', { timeout });
846
- } catch {
847
- // Ignored on purpose — see above.
848
- }
892
+ async settleAfterInteraction(page, timeout, startedAt) {
893
+ const remaining = Math.max(1, timeout - (Date.now() - startedAt));
894
+ return await settlePage(page, { timeout: remaining });
849
895
  }
850
896
 
851
897
  /**
@@ -927,6 +973,7 @@ export class ActionExecutor extends EventEmitter {
927
973
  * @returns {Promise<Object>} Click result
928
974
  */
929
975
  async executeClickAction(page, action) {
976
+ const startedAt = Date.now();
930
977
  const timeout = this.actionTimeout(action);
931
978
  const locator = this.elementLocator(page, action.selector);
932
979
 
@@ -944,7 +991,8 @@ export class ActionExecutor extends EventEmitter {
944
991
  button: action.button,
945
992
  clickCount: action.clickCount,
946
993
  delay: action.delay,
947
- force: action.force
994
+ force: action.force,
995
+ timeout
948
996
  });
949
997
  } else {
950
998
  // Standard click behavior
@@ -965,7 +1013,7 @@ export class ActionExecutor extends EventEmitter {
965
1013
 
966
1014
  // A click can follow a link or submit a form; let that navigation commit
967
1015
  // before the next action runs against the outgoing document.
968
- await this.settleAfterInteraction(page, timeout);
1016
+ await this.settleAfterInteraction(page, timeout, startedAt);
969
1017
 
970
1018
  return {
971
1019
  selector: action.selector,
@@ -1017,6 +1065,7 @@ export class ActionExecutor extends EventEmitter {
1017
1065
  * @returns {Promise<Object>} Press result
1018
1066
  */
1019
1067
  async executePressAction(page, action) {
1068
+ const startedAt = Date.now();
1020
1069
  const timeout = this.actionTimeout(action);
1021
1070
  const keyOptions = { timeout };
1022
1071
  if (action.modifiers?.length > 0) {
@@ -1030,7 +1079,7 @@ export class ActionExecutor extends EventEmitter {
1030
1079
  }
1031
1080
 
1032
1081
  // Enter on a form field navigates as often as a click does.
1033
- await this.settleAfterInteraction(page, timeout);
1082
+ await this.settleAfterInteraction(page, timeout, startedAt);
1034
1083
 
1035
1084
  return {
1036
1085
  key: action.key,
@@ -1128,6 +1177,7 @@ export class ActionExecutor extends EventEmitter {
1128
1177
  * @returns {Promise<Object>} Select result
1129
1178
  */
1130
1179
  async executeSelectAction(page, action) {
1180
+ const startedAt = Date.now();
1131
1181
  const timeout = this.actionTimeout(action);
1132
1182
  const values = action.values?.length ? action.values : [action.value];
1133
1183
 
@@ -1136,7 +1186,7 @@ export class ActionExecutor extends EventEmitter {
1136
1186
 
1137
1187
  // Faceted-search dropdowns commonly submit the form on change, so let any
1138
1188
  // navigation commit before the next action runs.
1139
- await this.settleAfterInteraction(page, timeout);
1189
+ await this.settleAfterInteraction(page, timeout, startedAt);
1140
1190
 
1141
1191
  return {
1142
1192
  selector: action.selector,
@@ -1181,6 +1231,7 @@ export class ActionExecutor extends EventEmitter {
1181
1231
  * @returns {Promise<Object>} Navigate result
1182
1232
  */
1183
1233
  async executeNavigateAction(page, action, executionContext) {
1234
+ const startedAt = Date.now();
1184
1235
  const timeout = this.actionTimeout(action);
1185
1236
 
1186
1237
  await assertUrlAllowed(action.url, { resolveDns: true });
@@ -1192,10 +1243,18 @@ export class ActionExecutor extends EventEmitter {
1192
1243
  });
1193
1244
  page.__crawlforgeGateWarnings = gateWarnings;
1194
1245
 
1246
+ // Consent handling gets at most what is left of the action's deadline, so
1247
+ // it cannot push a navigation that succeeded past the backstop.
1248
+ const consentMode = executionContext?.browserOptions?.consent;
1249
+ const consent = consentMode && consentMode !== 'off'
1250
+ ? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
1251
+ : undefined;
1252
+
1195
1253
  return {
1196
1254
  url: action.url,
1197
1255
  finalUrl: page.url(),
1198
- waitUntil: action.waitUntil || 'domcontentloaded'
1256
+ waitUntil: action.waitUntil || 'domcontentloaded',
1257
+ ...(consent ? { consent } : {})
1199
1258
  };
1200
1259
  }
1201
1260
 
@@ -1281,8 +1340,8 @@ export class ActionExecutor extends EventEmitter {
1281
1340
  }
1282
1341
 
1283
1342
  /**
1284
- * Execute snapshot action - the page's interactive elements, each stamped
1285
- * with a ref later actions can target instead of a guessed CSS selector.
1343
+ * Execute snapshot action - the page's interactive elements, each carrying
1344
+ * a ref later actions can target instead of a guessed CSS selector.
1286
1345
  * @param {Page} page - Playwright page
1287
1346
  * @param {Object} action - Snapshot action
1288
1347
  * @returns {Promise<Object>} Snapshot tree with refs
@@ -1290,7 +1349,9 @@ export class ActionExecutor extends EventEmitter {
1290
1349
  async executeSnapshotAction(page, action) {
1291
1350
  return await captureSnapshot(page, {
1292
1351
  interactiveOnly: action.interactiveOnly,
1293
- maxNodes: action.maxNodes
1352
+ maxNodes: action.maxNodes,
1353
+ // The render wait before the walk stays inside the action's deadline.
1354
+ timeout: this.actionTimeout(action)
1294
1355
  });
1295
1356
  }
1296
1357
 
@@ -1500,16 +1561,35 @@ export class ActionExecutor extends EventEmitter {
1500
1561
  return { success: false };
1501
1562
  }
1502
1563
 
1564
+ // An element that is not in the document cannot be force-clicked, scrolled
1565
+ // into view or focused, so every strategy below would only spend the
1566
+ // budget re-waiting for it. count() answers at once without waiting.
1567
+ if (action.selector) {
1568
+ let matches = 1;
1569
+ try {
1570
+ matches = await this.elementLocator(page, action.selector).count();
1571
+ } catch (_) {
1572
+ // A ref that cannot be resolved, or a page stub in tests: let the
1573
+ // strategies decide.
1574
+ }
1575
+ if (matches === 0) return { success: false };
1576
+ }
1577
+
1503
1578
  const strategies = this.errorRecoveryStrategies.get(action.type) || [];
1504
1579
  // `retries` caps how many strategies get a turn. Walking all of them
1505
1580
  // unconditionally would add a second full round of timeouts to every action
1506
1581
  // that was never going to succeed.
1507
1582
  const budget = Math.max(0, action.retries ?? 1);
1583
+ // One deadline shared by every strategy this action tries, so recovery
1584
+ // costs at most RECOVERY_TIMEOUT_MS on top of the action's own timeout
1585
+ // however many strategies are registered.
1586
+ const deadline = Date.now() + RECOVERY_TIMEOUT_MS;
1508
1587
 
1509
1588
  for (const strategy of strategies.slice(0, budget)) {
1589
+ if (Date.now() >= deadline) break;
1510
1590
  try {
1511
1591
  this.log('info', 'Attempting error recovery with strategy: ' + strategy.name);
1512
- const result = await strategy.recover(page, action, error, executionContext);
1592
+ const result = await strategy.recover(page, action, error, executionContext, deadline);
1513
1593
 
1514
1594
  if (result.success) {
1515
1595
  return {
@@ -1534,21 +1614,20 @@ export class ActionExecutor extends EventEmitter {
1534
1614
  this.errorRecoveryStrategies.set('click', [
1535
1615
  {
1536
1616
  name: 'waitAndRetry',
1537
- recover: async (page, action) => {
1617
+ recover: async (page, action, _error, _context, deadline) => {
1538
1618
  await this.delay(1000);
1539
1619
  await this.elementLocator(page, action.selector)
1540
- .click({ force: true, timeout: this.recoveryTimeout(action) });
1620
+ .click({ force: true, timeout: this.recoveryTimeout(action, deadline) });
1541
1621
  return { success: true, data: { recovered: true, strategy: 'waitAndRetry' } };
1542
1622
  }
1543
1623
  },
1544
1624
  {
1545
1625
  name: 'scrollIntoView',
1546
- recover: async (page, action) => {
1547
- const timeout = this.recoveryTimeout(action);
1626
+ recover: async (page, action, _error, _context, deadline) => {
1548
1627
  const locator = this.elementLocator(page, action.selector);
1549
- await locator.scrollIntoViewIfNeeded({ timeout });
1628
+ await locator.scrollIntoViewIfNeeded({ timeout: this.recoveryTimeout(action, deadline) });
1550
1629
  await this.delay(500);
1551
- await locator.click({ timeout });
1630
+ await locator.click({ timeout: this.recoveryTimeout(action, deadline) });
1552
1631
  return { success: true, data: { recovered: true, strategy: 'scrollIntoView' } };
1553
1632
  }
1554
1633
  }
@@ -1558,33 +1637,20 @@ export class ActionExecutor extends EventEmitter {
1558
1637
  this.errorRecoveryStrategies.set('type', [
1559
1638
  {
1560
1639
  name: 'focusAndRetry',
1561
- recover: async (page, action) => {
1562
- const timeout = this.recoveryTimeout(action);
1640
+ recover: async (page, action, _error, _context, deadline) => {
1563
1641
  const locator = this.elementLocator(page, action.selector);
1564
- await locator.focus({ timeout });
1642
+ await locator.focus({ timeout: this.recoveryTimeout(action, deadline) });
1565
1643
  await this.delay(500);
1566
- await locator.pressSequentially(action.text, { delay: action.delay, timeout });
1644
+ await locator.pressSequentially(action.text, {
1645
+ delay: action.delay, timeout: this.recoveryTimeout(action, deadline)
1646
+ });
1567
1647
  return { success: true, data: { recovered: true, strategy: 'focusAndRetry' } };
1568
1648
  }
1569
1649
  }
1570
1650
  ]);
1571
1651
 
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
- ]);
1652
+ // No strategies for `wait`: the wait already was the retry, so a failed
1653
+ // one has nothing to recover and gets no second window.
1588
1654
  }
1589
1655
 
1590
1656
  /**
@@ -3638,8 +3638,10 @@ export class CamoufoxAdapter extends BrowserEngine {
3638
3638
  // `blockWebRTC: false` is the stealthier setting behind a proxy.
3639
3639
  if (config.blockWebRTC) options.block_webrtc = true;
3640
3640
  // Native cursor humanization: camoufox moves the pointer along a plausible
3641
- // path rather than teleporting it.
3642
- if (config.humanize) options.humanize = true;
3641
+ // path rather than teleporting it. The number caps one move in seconds;
3642
+ // `true` means camoufox's 1.5 s default, which made a single stealth click
3643
+ // cost 2 s or more of cursor travel alone.
3644
+ if (config.humanize) options.humanize = 0.5;
3643
3645
  // Pin the persona to the installed binary's Firefox major so the UA's two
3644
3646
  // version tokens agree. Left unpinned, camoufox's own version rewrite
3645
3647
  // reaches only `rv:` and about half of all launches announce a Firefox
@@ -0,0 +1,81 @@
1
+ /**
2
+ * actionQueue — the one process-wide budget of browser slots that
3
+ * `scrape_with_actions` chains and `browser_session` opens share.
4
+ *
5
+ * A caller over the limit WAITS for a slot instead of being refused: the live
6
+ * review's fourth parallel scrape_with_actions call was rejected outright with
7
+ * "Maximum concurrent sessions (3) reached". It now queues for up to
8
+ * CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS and only then fails, with a message that
9
+ * names the queue depth and the limit.
10
+ *
11
+ * A slot covers the slow part of a browser job — context/page creation and
12
+ * navigation — not the life of whatever it produced. browser_session takes one
13
+ * for `open` only (see BrowserSessionTool.openSession); its session caps live
14
+ * in SessionStore and are policy, which still refuses.
15
+ */
16
+
17
+ import PQueue from 'p-queue';
18
+
19
+ const DEFAULT_CONCURRENCY = 3;
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
21
+
22
+ /** A positive integer from the environment, or the default for anything else. */
23
+ function positiveInt(value, fallback) {
24
+ const n = Number.parseInt(value, 10);
25
+ return Number.isInteger(n) && n > 0 ? n : fallback;
26
+ }
27
+
28
+ /**
29
+ * @param {Object} [opts]
30
+ * @param {number} [opts.concurrency] — slots that may run at once
31
+ * @param {number} [opts.timeoutMs] — longest a caller waits for a slot
32
+ */
33
+ export function createActionQueue({
34
+ concurrency = positiveInt(process.env.CRAWLFORGE_MAX_ACTION_SESSIONS, DEFAULT_CONCURRENCY),
35
+ timeoutMs = positiveInt(process.env.CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
36
+ } = {}) {
37
+ const queue = new PQueue({ concurrency });
38
+
39
+ /**
40
+ * Run `fn` once a slot is free, holding the slot until `fn` settles.
41
+ *
42
+ * The timeout bounds the WAIT, never the run: the timer is cleared the moment
43
+ * the job starts. That matters because p-queue races a running job against
44
+ * its signal and frees the slot on abort while `fn` carries on — an abort
45
+ * after start would let a fourth browser job in beside three live ones.
46
+ * Before start, the abort removes the job from the queue, so a caller that
47
+ * timed out never runs later.
48
+ */
49
+ function run(fn, { label = 'browser job' } = {}) {
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => {
52
+ // Read before the abort removes this job: the depth the caller waited behind.
53
+ controller.abort(new Error(
54
+ `${label} timed out after ${timeoutMs / 1000} s waiting for a browser slot: ` +
55
+ `${queue.pending} running, ${queue.size} queued ` +
56
+ `(CRAWLFORGE_MAX_ACTION_SESSIONS=${concurrency}). Retry shortly, or raise ` +
57
+ 'CRAWLFORGE_MAX_ACTION_SESSIONS / CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS if the machine has room.'
58
+ ));
59
+ }, timeoutMs);
60
+ timer.unref?.();
61
+
62
+ return queue.add(() => {
63
+ clearTimeout(timer);
64
+ return fn();
65
+ }, { signal: controller.signal });
66
+ }
67
+
68
+ return {
69
+ run,
70
+ concurrency,
71
+ timeoutMs,
72
+ get running() { return queue.pending; },
73
+ get queued() { return queue.size; }
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The process-wide limiter both tools use. Tools take an `actionQueue` option
79
+ * so a test can hand them a small private one; production never passes it.
80
+ */
81
+ export const actionQueue = createActionQueue();