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 +1 -1
- package/server.js +10 -15
- package/src/cli/commands/actions.js +1 -0
- package/src/core/ActionExecutor.js +75 -37
- 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/ScrapeWithActionsTool.js +10 -1
- package/src/tools/extract/extractEmbeddedState.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.12.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
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
|
-
]);
|
|
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` | `
|
|
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(
|
|
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: [{
|
|
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
|
}
|