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 +2 -1
- package/server.js +14 -18
- package/src/cli/commands/actions.js +1 -0
- package/src/core/ActionExecutor.js +129 -63
- package/src/core/StealthBrowserManager.js +4 -2
- package/src/core/browser/actionQueue.js +81 -0
- package/src/core/browser/consent.js +182 -0
- package/src/core/browser/settle.js +141 -0
- package/src/core/browser/snapshot.js +241 -51
- package/src/server/screenshotResources.js +50 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +6 -0
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +84 -54
- package/src/tools/extract/extractEmbeddedState.js +11 -1
- package/src/utils/HumanBehaviorSimulator.js +35 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
770
|
-
*
|
|
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 —
|
|
773
|
-
*
|
|
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}
|
|
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
|
-
*
|
|
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
|
|
836
|
-
* run against the outgoing page.
|
|
837
|
-
*
|
|
838
|
-
*
|
|
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 -
|
|
841
|
-
* @
|
|
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
|
-
|
|
845
|
-
|
|
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
|
|
1285
|
-
*
|
|
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, {
|
|
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
|
-
//
|
|
1573
|
-
|
|
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
|
-
|
|
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();
|