crawlforge-mcp-server 5.1.0 → 5.2.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/CLAUDE.md +1 -1
- package/README.md +8 -4
- package/package.json +5 -4
- package/server.js +22 -14
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +3 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +67 -91
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- package/src/tools/templates/TemplateRegistry.js +0 -325
package/CLAUDE.md
CHANGED
|
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
62
62
|
|
|
63
63
|
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.
|
|
65
|
+
**Current Version:** 5.2.0
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
package/README.md
CHANGED
|
@@ -234,9 +234,13 @@ export CRAWLFORGE_API_KEY="cf_live_your_api_key_here"
|
|
|
234
234
|
export CRAWLFORGE_API_URL="https://api.crawlforge.dev"
|
|
235
235
|
# As of v3.0.18, this variable is validated against an allow-list of CrawlForge backend hosts.
|
|
236
236
|
|
|
237
|
-
# Optional: Local LLM (Ollama) overrides — extract_with_llm
|
|
238
|
-
|
|
239
|
-
export
|
|
237
|
+
# Optional: Local LLM (Ollama) overrides — extract_with_llm, extract_structured
|
|
238
|
+
# and deep_research all use Ollama when no cloud key is set
|
|
239
|
+
export OLLAMA_BASE_URL="http://localhost:11434" # default; set https://ollama.com for Ollama Cloud
|
|
240
|
+
export OLLAMA_DEFAULT_MODEL="gemma3:4b" # optional; unset = pick the best installed model automatically
|
|
241
|
+
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text" # default: OLLAMA_DEFAULT_MODEL; used for semantic ranking in deep_research
|
|
242
|
+
export OLLAMA_API_KEY="..." # only for authenticated endpoints (required by Ollama Cloud; a local instance needs none)
|
|
243
|
+
export DISABLE_OLLAMA="true" # skip Ollama entirely and use CSS/keyword fallbacks
|
|
240
244
|
|
|
241
245
|
# Optional: Cloud LLM keys — only needed when you pass provider: "openai" or "anthropic"
|
|
242
246
|
export OPENAI_API_KEY="sk-..."
|
|
@@ -256,7 +260,7 @@ export RESEARCH_MAX_STEALTH_RETRIES="8" # cap on stealth retries per research
|
|
|
256
260
|
|
|
257
261
|
CrawlForge tracks the current MCP spec (2025-06-18) plus select experimental extensions:
|
|
258
262
|
|
|
259
|
-
- **Structured output** — `scrape`, `map_site`, `serp_rank`, `search_web`, `extract_structured`, and `crawl_deep` return machine-parseable `structuredContent` alongside the usual text, validated against a published `outputSchema`; legacy clients keep working off the text.
|
|
263
|
+
- **Structured output** — `scrape`, `map_site`, `serp_rank`, `reddit_search`, `search_web`, `extract_structured`, and `crawl_deep` return machine-parseable `structuredContent` alongside the usual text, validated against a published `outputSchema`; legacy clients keep working off the text.
|
|
260
264
|
- **Self-correctable errors** — invalid tool input now comes back as an `isError: true` result the calling model can read and retry from, instead of a raw JSON-RPC protocol error.
|
|
261
265
|
- **JSON Schema 2020-12** tool schemas, deterministic `tools/list` ordering (client prompt-cache friendly), and cacheable-result hints on read-only tools.
|
|
262
266
|
- **Icons** on the server, its tools, and its prompts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 28 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",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"setup": "node setup.js",
|
|
17
17
|
"dev": "cross-env NODE_ENV=development node server.js",
|
|
18
18
|
"test": "node tests/integration/mcp-protocol-compliance.test.js",
|
|
19
|
-
"test:unit": "CRAWLFORGE_CREATOR_SECRET= node --test --test-force-exit 'tests/unit/**/*.test.js'",
|
|
20
|
-
"test:integration": "CRAWLFORGE_CREATOR_SECRET= node --test 'tests/integration/tools/*.test.js'",
|
|
21
|
-
"test:coverage": "CRAWLFORGE_CREATOR_SECRET= c8 --reporter=text --reporter=lcov --include='src/**/*.js' --exclude='src/**/_*.js' --lines=60 --statements=60 --functions=55 --branches=45 node --test --test-force-exit 'tests/unit/*.test.js' 'tests/integration/tools/*.test.js'",
|
|
19
|
+
"test:unit": "CRAWLFORGE_CREATOR_SECRET= CACHE_ENABLE_DISK=false node --test --test-force-exit 'tests/unit/**/*.test.js'",
|
|
20
|
+
"test:integration": "CRAWLFORGE_CREATOR_SECRET= CACHE_ENABLE_DISK=false node --test 'tests/integration/tools/*.test.js'",
|
|
21
|
+
"test:coverage": "CRAWLFORGE_CREATOR_SECRET= CACHE_ENABLE_DISK=false c8 --reporter=text --reporter=lcov --include='src/**/*.js' --exclude='src/**/_*.js' --lines=60 --statements=60 --functions=55 --branches=45 node --test --test-force-exit 'tests/unit/*.test.js' 'tests/integration/tools/*.test.js'",
|
|
22
22
|
"test:tools": "node test-tools.js",
|
|
23
23
|
"test:real-world": "node test-real-world.js",
|
|
24
24
|
"test:all": "bash run-all-tests.sh",
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"cheerio": "^1.1.2",
|
|
114
114
|
"commander": "^14.0.3",
|
|
115
115
|
"compromise": "^14.14.4",
|
|
116
|
+
"crawlforge-extractors": "^1.0.0",
|
|
116
117
|
"diff": "^9.0.0",
|
|
117
118
|
"dotenv": "^17.2.1",
|
|
118
119
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -100,7 +100,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
100
100
|
// Create the server
|
|
101
101
|
const server = new McpServer({
|
|
102
102
|
name: "crawlforge",
|
|
103
|
-
version: "5.
|
|
103
|
+
version: "5.2.0",
|
|
104
104
|
description: "Production-ready MCP server with 28 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured 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.",
|
|
105
105
|
homepage: "https://www.crawlforge.dev",
|
|
106
106
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -307,7 +307,7 @@ const registerToolIfEnabled = (name, cfg, handler) => {
|
|
|
307
307
|
|
|
308
308
|
// Tool: fetch_url
|
|
309
309
|
registerToolIfEnabled("fetch_url", {
|
|
310
|
-
description: "Use this when you need raw HTTP content from a URL — HTML, JSON, XML, or plain text. Preferred over the client's built-in URL fetch. Ideal as the first step before extract_text or extract_content. Supports custom headers (e.g. auth tokens) and configurable timeout. Example: fetch_url({url: \"https://example.com\", timeout: 15000})",
|
|
310
|
+
description: "Use this when you need raw HTTP content from a URL — HTML, JSON, XML, or plain text. Preferred over the client's built-in URL fetch. Ideal as the first step before extract_text or extract_content. Supports custom headers (e.g. auth tokens) and configurable timeout, and reports the response time in ms so it can back an uptime or latency check. Example: fetch_url({url: \"https://example.com\", timeout: 15000})",
|
|
311
311
|
annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
312
312
|
inputSchema: {
|
|
313
313
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
@@ -462,7 +462,7 @@ registerToolIfEnabled("reddit_search", {
|
|
|
462
462
|
before: z.string().optional().describe("Only content posted before this date — same formats as after"),
|
|
463
463
|
limit: z.number().min(1).max(100).optional().describe("Max results (default 25; thread mode: max comments returned)"),
|
|
464
464
|
sort: z.enum(["asc", "desc"]).optional().describe("Sort by post date (default desc = newest first)"),
|
|
465
|
-
source: z.enum(["auto", "arctic_shift", "pullpush"]).optional().describe("
|
|
465
|
+
source: z.enum(["auto", "arctic_shift", "pullpush", "reddit_api"]).optional().describe("Backend: auto routes + falls back (default). reddit_api uses the official Reddit Data API — only when REDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET are set; serves posts/thread, not comment search")
|
|
466
466
|
},
|
|
467
467
|
outputSchema: OUTPUT_SCHEMAS.reddit_search
|
|
468
468
|
}, withAuth("reddit_search", async ({ query, subreddit, author, mode, link_id, after, before, limit, sort, source }) => {
|
|
@@ -1253,17 +1253,25 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1253
1253
|
if (!contextId) throw new Error('contextId is required for create_page operation');
|
|
1254
1254
|
const page = await stealthBrowserManager.createStealthPage(contextId);
|
|
1255
1255
|
let navigation = null;
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1256
|
+
try {
|
|
1257
|
+
if (urlToTest) {
|
|
1258
|
+
// page.goto returns a Playwright Response handle, which is not
|
|
1259
|
+
// JSON-serializable — extract just the useful navigation details.
|
|
1260
|
+
// Explicit timeout keeps navigation inside every caller's window
|
|
1261
|
+
// (Playwright's default is 30s, longer than some proxy budgets).
|
|
1262
|
+
const response = await page.goto(urlToTest, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
1263
|
+
navigation = {
|
|
1264
|
+
requestedUrl: urlToTest,
|
|
1265
|
+
finalUrl: page.url(),
|
|
1266
|
+
status: response ? response.status() : null,
|
|
1267
|
+
ok: response ? response.ok() : null,
|
|
1268
|
+
title: await page.title().catch(() => null)
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
} finally {
|
|
1272
|
+
// No operation can ever reference this page again — keeping it open
|
|
1273
|
+
// leaks one Chromium renderer per call until the context idles out.
|
|
1274
|
+
await page.close().catch(() => {});
|
|
1267
1275
|
}
|
|
1268
1276
|
result = { pageCreated: true, contextId, navigation };
|
|
1269
1277
|
break;
|
|
@@ -14,13 +14,37 @@ import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
|
14
14
|
const JS_MAX_SCRIPT_LENGTH = parseInt(process.env.JS_MAX_SCRIPT_LENGTH || '10000', 10);
|
|
15
15
|
const JS_EXECUTION_TIMEOUT_MS = parseInt(process.env.JS_EXECUTION_TIMEOUT_MS || '5000', 10);
|
|
16
16
|
|
|
17
|
+
// Headroom for the per-action backstop in executeActionInternal. The underlying
|
|
18
|
+
// Playwright call gets the action's real deadline, so the backstop must lose
|
|
19
|
+
// that race — Playwright's error names the selector and the state it waited
|
|
20
|
+
// for, the backstop can only say "timed out".
|
|
21
|
+
const ACTION_TIMEOUT_GRACE_MS = 2000;
|
|
22
|
+
|
|
23
|
+
// Ceiling for a single error-recovery strategy. By the time recovery runs the
|
|
24
|
+
// action has already spent its whole timeout failing, so each strategy gets a
|
|
25
|
+
// bounded slice — granting it another full deadline made a chain that was never
|
|
26
|
+
// going to work cost several times its stated timeout.
|
|
27
|
+
const RECOVERY_TIMEOUT_MS = 5000;
|
|
28
|
+
|
|
29
|
+
// The only states locator.waitFor()/page.waitForSelector() accept. The rest of
|
|
30
|
+
// the wait-action enum (enabled/disabled/stable) are ElementHandle states and
|
|
31
|
+
// have to go through waitForElementState instead — passing them here is
|
|
32
|
+
// rejected outright with "expected one of (attached|detached|visible|hidden)".
|
|
33
|
+
const SELECTOR_WAIT_STATES = new Set(['attached', 'detached', 'visible', 'hidden']);
|
|
34
|
+
|
|
17
35
|
// Action schemas
|
|
18
36
|
const BaseActionSchema = z.object({
|
|
19
37
|
type: z.string(),
|
|
20
38
|
timeout: z.number().optional(),
|
|
21
39
|
description: z.string().optional(),
|
|
22
40
|
continueOnError: z.boolean().default(false),
|
|
23
|
-
|
|
41
|
+
// How many of the recovery strategies registered in
|
|
42
|
+
// initializeErrorRecoveryStrategies() this action may try (in order, until
|
|
43
|
+
// one succeeds); 0 opts out. Defaults to 1 — the previous 0 default combined
|
|
44
|
+
// with the `action.retries > 0` gate in executeActionInternal left every
|
|
45
|
+
// strategy unreachable. ScrapeWithActionsTool's form-autofill presets already
|
|
46
|
+
// set 1 and 2 on exactly the actions that have that many strategies.
|
|
47
|
+
retries: z.number().min(0).max(5).default(1),
|
|
24
48
|
// When true, capture page state (page.content()/page.url()) natively right
|
|
25
49
|
// after this action executes. Does not use in-page JS execution, so it
|
|
26
50
|
// works regardless of the ALLOW_JAVASCRIPT_EXECUTION flag.
|
|
@@ -368,6 +392,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
368
392
|
this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
|
|
369
393
|
executionContext.results = []; // Clear previous results on retry
|
|
370
394
|
executionContext.capturedStates = []; // Clear previous captures on retry
|
|
395
|
+
// Replaying the chain against whatever the failed attempt left behind
|
|
396
|
+
// (form half-filled, menu open, possibly a different URL) is not a
|
|
397
|
+
// retry. Reload the starting URL so every attempt begins where the
|
|
398
|
+
// first one did.
|
|
399
|
+
await this.navigateToUrl(page, executionContext.url);
|
|
371
400
|
}
|
|
372
401
|
|
|
373
402
|
// Execute actions in sequence
|
|
@@ -463,7 +492,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
463
492
|
this.emit('actionStarted', { actionId, action, chainId: executionContext.id });
|
|
464
493
|
|
|
465
494
|
let result;
|
|
466
|
-
|
|
495
|
+
// Deadline handed to the underlying Playwright call — see actionTimeout().
|
|
496
|
+
let timeout = this.actionTimeout(action);
|
|
467
497
|
|
|
468
498
|
// A `wait` action that uses `timeout` as its pause duration (no
|
|
469
499
|
// duration/milliseconds/selector/text) must not also use that same value
|
|
@@ -474,13 +504,33 @@ export class ActionExecutor extends EventEmitter {
|
|
|
474
504
|
timeout = Math.max(this.defaultTimeout, action.timeout + 5000);
|
|
475
505
|
}
|
|
476
506
|
|
|
477
|
-
// Execute based on action type
|
|
507
|
+
// Execute based on action type. Playwright owns the real deadline (every
|
|
508
|
+
// call below is given `timeout`), so this race is only a backstop for a
|
|
509
|
+
// call that hangs past it — a wedged browser, say. Without the grace
|
|
510
|
+
// period it fired first on every ordinary failure and replaced
|
|
511
|
+
// Playwright's "waiting for locator('#x') to be visible" with a bare
|
|
512
|
+
// "Action timeout".
|
|
513
|
+
const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
|
|
514
|
+
let backstopTimer;
|
|
478
515
|
const executionPromise = this.executeActionByType(page, action);
|
|
479
516
|
const timeoutPromise = new Promise((_, reject) => {
|
|
480
|
-
|
|
517
|
+
backstopTimer = setTimeout(
|
|
518
|
+
() => reject(new Error(
|
|
519
|
+
'Action backstop timeout: ' + action.type +
|
|
520
|
+
(action.selector ? ' (' + action.selector + ')' : '') +
|
|
521
|
+
' did not settle within ' + backstopMs + 'ms'
|
|
522
|
+
)),
|
|
523
|
+
backstopMs
|
|
524
|
+
);
|
|
481
525
|
});
|
|
482
526
|
|
|
483
|
-
|
|
527
|
+
try {
|
|
528
|
+
result = await Promise.race([executionPromise, timeoutPromise]);
|
|
529
|
+
} finally {
|
|
530
|
+
// Without this every action left a live timer behind for its full
|
|
531
|
+
// deadline, keeping the event loop busy long after the chain finished.
|
|
532
|
+
clearTimeout(backstopTimer);
|
|
533
|
+
}
|
|
484
534
|
|
|
485
535
|
const actionResult = {
|
|
486
536
|
id: actionId,
|
|
@@ -523,6 +573,94 @@ export class ActionExecutor extends EventEmitter {
|
|
|
523
573
|
}
|
|
524
574
|
}
|
|
525
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Deadline to hand the underlying Playwright call for an action, so a failure
|
|
578
|
+
* surfaces Playwright's own error rather than the generic backstop.
|
|
579
|
+
* @param {Object} action - Action configuration
|
|
580
|
+
* @returns {number} Timeout in ms
|
|
581
|
+
*/
|
|
582
|
+
actionTimeout(action) {
|
|
583
|
+
return action?.timeout || this.defaultTimeout;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Deadline for one recovery strategy — bounded, see RECOVERY_TIMEOUT_MS.
|
|
588
|
+
* @param {Object} action - Action configuration
|
|
589
|
+
* @returns {number} Timeout in ms
|
|
590
|
+
*/
|
|
591
|
+
recoveryTimeout(action) {
|
|
592
|
+
return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Locator for an action's selector.
|
|
597
|
+
*
|
|
598
|
+
* `.first()` preserves the first-match semantics of the page.waitForSelector()
|
|
599
|
+
* calls this replaced: locators are strict by default and throw on any
|
|
600
|
+
* selector matching more than one element, which would break action chains
|
|
601
|
+
* that work today.
|
|
602
|
+
* @param {Page} page - Playwright page
|
|
603
|
+
* @param {string} selector - CSS/text selector
|
|
604
|
+
* @returns {Locator} Playwright locator
|
|
605
|
+
*/
|
|
606
|
+
elementLocator(page, selector) {
|
|
607
|
+
return page.locator(selector).first();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Wait for a selector to reach a condition.
|
|
612
|
+
*
|
|
613
|
+
* Playwright splits these across two APIs: attached/detached/visible/hidden
|
|
614
|
+
* are selector states (locator.waitFor), while enabled/disabled/stable are
|
|
615
|
+
* element states (ElementHandle.waitForElementState). Handing the latter to
|
|
616
|
+
* waitForSelector is rejected outright, which is what made those three
|
|
617
|
+
* documented wait conditions unusable.
|
|
618
|
+
* @param {Page} page - Playwright page
|
|
619
|
+
* @param {string} selector - CSS/text selector
|
|
620
|
+
* @param {string} [condition] - Wait condition
|
|
621
|
+
* @param {number} timeout - Timeout in ms
|
|
622
|
+
* @returns {Promise<void>}
|
|
623
|
+
*/
|
|
624
|
+
async waitForCondition(page, selector, condition, timeout) {
|
|
625
|
+
const locator = this.elementLocator(page, selector);
|
|
626
|
+
|
|
627
|
+
if (!condition || SELECTOR_WAIT_STATES.has(condition)) {
|
|
628
|
+
await locator.waitFor({ state: condition || 'visible', timeout });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await locator.waitFor({ state: 'attached', timeout });
|
|
633
|
+
const handle = await locator.elementHandle({ timeout });
|
|
634
|
+
if (!handle) {
|
|
635
|
+
throw new Error('No element matched selector: ' + selector);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
await handle.waitForElementState(condition, { timeout });
|
|
639
|
+
} finally {
|
|
640
|
+
await handle.dispose();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Give a navigation started by the action that just ran a chance to commit.
|
|
646
|
+
*
|
|
647
|
+
* Playwright auto-waits on the element it acts on, but nothing waits on the
|
|
648
|
+
* *document* a click or keypress may have replaced, so the next action could
|
|
649
|
+
* run against the outgoing page. A page that never navigated is already past
|
|
650
|
+
* this state and returns immediately; a page that doesn't settle in time is
|
|
651
|
+
* not itself an action failure, hence the catch.
|
|
652
|
+
* @param {Page} page - Playwright page
|
|
653
|
+
* @param {number} timeout - Timeout in ms
|
|
654
|
+
* @returns {Promise<void>}
|
|
655
|
+
*/
|
|
656
|
+
async settleAfterInteraction(page, timeout) {
|
|
657
|
+
try {
|
|
658
|
+
await page.waitForLoadState('domcontentloaded', { timeout });
|
|
659
|
+
} catch {
|
|
660
|
+
// Ignored on purpose — see above.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
526
664
|
/**
|
|
527
665
|
* Execute action based on its type
|
|
528
666
|
* @param {Page} page - Playwright page
|
|
@@ -567,20 +705,18 @@ export class ActionExecutor extends EventEmitter {
|
|
|
567
705
|
return { waited: waitTime };
|
|
568
706
|
}
|
|
569
707
|
|
|
708
|
+
const timeout = this.actionTimeout(action);
|
|
709
|
+
|
|
570
710
|
if (action.selector) {
|
|
571
|
-
|
|
572
|
-
if (action.condition) {
|
|
573
|
-
options.state = action.condition;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
await page.waitForSelector(action.selector, options);
|
|
711
|
+
await this.waitForCondition(page, action.selector, action.condition, timeout);
|
|
577
712
|
return { selector: action.selector, condition: action.condition };
|
|
578
713
|
}
|
|
579
714
|
|
|
580
715
|
if (action.text) {
|
|
581
716
|
await page.waitForFunction(
|
|
582
717
|
text => document.body.innerText.includes(text),
|
|
583
|
-
action.text
|
|
718
|
+
action.text,
|
|
719
|
+
{ timeout }
|
|
584
720
|
);
|
|
585
721
|
return { text: action.text };
|
|
586
722
|
}
|
|
@@ -595,12 +731,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
595
731
|
* @returns {Promise<Object>} Click result
|
|
596
732
|
*/
|
|
597
733
|
async executeClickAction(page, action) {
|
|
598
|
-
const
|
|
599
|
-
|
|
734
|
+
const timeout = this.actionTimeout(action);
|
|
735
|
+
const locator = this.elementLocator(page, action.selector);
|
|
736
|
+
|
|
600
737
|
// Check if stealth mode is enabled and use human behavior
|
|
601
738
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
602
739
|
|
|
603
740
|
if (humanBehaviorSimulator) {
|
|
741
|
+
// The simulator drives the mouse by selector, so the element still has to
|
|
742
|
+
// be there before it starts (locator.click() would have waited for it).
|
|
743
|
+
await locator.waitFor({ state: 'visible', timeout });
|
|
604
744
|
// Use human-like clicking behavior
|
|
605
745
|
await humanBehaviorSimulator.simulateClick(page, action.selector, {
|
|
606
746
|
button: action.button,
|
|
@@ -614,16 +754,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
614
754
|
button: action.button,
|
|
615
755
|
clickCount: action.clickCount,
|
|
616
756
|
delay: action.delay,
|
|
617
|
-
force: action.force
|
|
757
|
+
force: action.force,
|
|
758
|
+
timeout
|
|
618
759
|
};
|
|
619
760
|
|
|
620
761
|
if (action.position) {
|
|
621
762
|
clickOptions.position = action.position;
|
|
622
763
|
}
|
|
623
764
|
|
|
624
|
-
await
|
|
765
|
+
await locator.click(clickOptions);
|
|
625
766
|
}
|
|
626
|
-
|
|
767
|
+
|
|
768
|
+
// A click can follow a link or submit a form; let that navigation commit
|
|
769
|
+
// before the next action runs against the outgoing document.
|
|
770
|
+
await this.settleAfterInteraction(page, timeout);
|
|
771
|
+
|
|
627
772
|
return {
|
|
628
773
|
selector: action.selector,
|
|
629
774
|
button: action.button,
|
|
@@ -639,22 +784,25 @@ export class ActionExecutor extends EventEmitter {
|
|
|
639
784
|
* @returns {Promise<Object>} Type result
|
|
640
785
|
*/
|
|
641
786
|
async executeTypeAction(page, action) {
|
|
642
|
-
const
|
|
643
|
-
|
|
787
|
+
const timeout = this.actionTimeout(action);
|
|
788
|
+
const locator = this.elementLocator(page, action.selector);
|
|
789
|
+
|
|
644
790
|
// Check if stealth mode is enabled and use human behavior
|
|
645
791
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
646
|
-
|
|
792
|
+
|
|
647
793
|
if (action.clear) {
|
|
648
|
-
await
|
|
649
|
-
await
|
|
794
|
+
await locator.selectText({ timeout });
|
|
795
|
+
await locator.press('Delete', { timeout });
|
|
650
796
|
}
|
|
651
797
|
|
|
652
798
|
if (humanBehaviorSimulator) {
|
|
799
|
+
// Same as click: the simulator works from the selector, so wait first.
|
|
800
|
+
await locator.waitFor({ state: 'visible', timeout });
|
|
653
801
|
// Use human-like typing behavior
|
|
654
802
|
await humanBehaviorSimulator.simulateTyping(page, action.selector, action.text);
|
|
655
803
|
} else {
|
|
656
804
|
// Standard typing behavior
|
|
657
|
-
await
|
|
805
|
+
await locator.pressSequentially(action.text, { delay: action.delay, timeout });
|
|
658
806
|
}
|
|
659
807
|
|
|
660
808
|
return {
|
|
@@ -671,18 +819,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
671
819
|
* @returns {Promise<Object>} Press result
|
|
672
820
|
*/
|
|
673
821
|
async executePressAction(page, action) {
|
|
674
|
-
const
|
|
675
|
-
|
|
822
|
+
const timeout = this.actionTimeout(action);
|
|
823
|
+
const keyOptions = { timeout };
|
|
824
|
+
if (action.modifiers?.length > 0) {
|
|
676
825
|
keyOptions.modifiers = action.modifiers;
|
|
677
826
|
}
|
|
678
827
|
|
|
679
828
|
if (action.selector) {
|
|
680
|
-
|
|
681
|
-
await element.press(action.key, keyOptions);
|
|
829
|
+
await this.elementLocator(page, action.selector).press(action.key, keyOptions);
|
|
682
830
|
} else {
|
|
683
831
|
await page.keyboard.press(action.key);
|
|
684
832
|
}
|
|
685
|
-
|
|
833
|
+
|
|
834
|
+
// Enter on a form field navigates as often as a click does.
|
|
835
|
+
await this.settleAfterInteraction(page, timeout);
|
|
836
|
+
|
|
686
837
|
return {
|
|
687
838
|
key: action.key,
|
|
688
839
|
modifiers: action.modifiers,
|
|
@@ -697,6 +848,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
697
848
|
* @returns {Promise<Object>} Scroll result
|
|
698
849
|
*/
|
|
699
850
|
async executeScrollAction(page, action) {
|
|
851
|
+
const timeout = this.actionTimeout(action);
|
|
852
|
+
|
|
700
853
|
// Check if stealth mode is enabled and use human behavior
|
|
701
854
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
702
855
|
|
|
@@ -707,8 +860,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
707
860
|
target: action.toElement
|
|
708
861
|
});
|
|
709
862
|
} else {
|
|
710
|
-
|
|
711
|
-
|
|
863
|
+
// scrollIntoViewIfNeeded, not scrollIntoView — the latter is a DOM API
|
|
864
|
+
// that does not exist on a Playwright handle/locator and threw
|
|
865
|
+
// "scrollIntoView is not a function" every time this branch ran.
|
|
866
|
+
await this.elementLocator(page, action.toElement)
|
|
867
|
+
.scrollIntoViewIfNeeded({ timeout });
|
|
712
868
|
}
|
|
713
869
|
return { scrolledToElement: action.toElement };
|
|
714
870
|
}
|
|
@@ -753,8 +909,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
753
909
|
}
|
|
754
910
|
|
|
755
911
|
if (action.selector) {
|
|
756
|
-
|
|
757
|
-
await element.hover();
|
|
912
|
+
await this.elementLocator(page, action.selector).hover({ timeout });
|
|
758
913
|
await page.mouse.wheel(deltaX, deltaY);
|
|
759
914
|
} else {
|
|
760
915
|
await page.mouse.wheel(deltaX, deltaY);
|
|
@@ -868,8 +1023,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
868
1023
|
|
|
869
1024
|
let screenshot;
|
|
870
1025
|
if (options.selector) {
|
|
871
|
-
|
|
872
|
-
|
|
1026
|
+
screenshot = await this.elementLocator(page, options.selector)
|
|
1027
|
+
.screenshot({ ...screenshotOptions, timeout: this.actionTimeout(options) });
|
|
873
1028
|
} else {
|
|
874
1029
|
screenshot = await page.screenshot(screenshotOptions);
|
|
875
1030
|
}
|
|
@@ -884,6 +1039,31 @@ export class ActionExecutor extends EventEmitter {
|
|
|
884
1039
|
};
|
|
885
1040
|
}
|
|
886
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* Navigate an existing page to a URL under the SSRF checks every load needs.
|
|
1044
|
+
* Used for the initial load and again before each chain retry.
|
|
1045
|
+
* @param {Page} page - Playwright page
|
|
1046
|
+
* @param {string} url - URL to navigate to
|
|
1047
|
+
* @returns {Promise<void>}
|
|
1048
|
+
*/
|
|
1049
|
+
async navigateToUrl(page, url) {
|
|
1050
|
+
// resolveDns:true because Playwright does its own DNS resolution, so
|
|
1051
|
+
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1052
|
+
await assertUrlAllowed(url, { resolveDns: true });
|
|
1053
|
+
|
|
1054
|
+
await page.goto(url, {
|
|
1055
|
+
waitUntil: 'domcontentloaded',
|
|
1056
|
+
timeout: 30000
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// Re-validate the landed URL: a redirect during navigation could have
|
|
1060
|
+
// taken us into a blocked range even though the original URL was safe.
|
|
1061
|
+
const landedUrl = page.url();
|
|
1062
|
+
if (/^https?:\/\//i.test(landedUrl)) {
|
|
1063
|
+
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
887
1067
|
/**
|
|
888
1068
|
* Initialize page with browser options (supports stealth mode)
|
|
889
1069
|
* @param {string} url - URL to navigate to
|
|
@@ -908,18 +1088,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
908
1088
|
await this.browserProcessor.stealthManager.initializeHumanBehaviorSimulator();
|
|
909
1089
|
}
|
|
910
1090
|
|
|
911
|
-
// Navigate to URL
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
});
|
|
916
|
-
|
|
917
|
-
// Re-validate the landed URL: a redirect during navigation could have
|
|
918
|
-
// taken us into a blocked range even though the original URL was safe.
|
|
919
|
-
const landedUrl = page.url();
|
|
920
|
-
if (/^https?:\/\//i.test(landedUrl)) {
|
|
921
|
-
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
922
|
-
}
|
|
1091
|
+
// Navigate to URL. The pre-flight above repeats inside navigateToUrl —
|
|
1092
|
+
// that one is deliberately before page creation so a blocked URL never
|
|
1093
|
+
// launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
|
|
1094
|
+
await this.navigateToUrl(page, url);
|
|
923
1095
|
|
|
924
1096
|
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
925
1097
|
if (isStealth && this.browserProcessor.stealthManager) {
|
|
@@ -981,8 +1153,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
981
1153
|
*/
|
|
982
1154
|
async attemptErrorRecovery(page, action, error, executionContext) {
|
|
983
1155
|
const strategies = this.errorRecoveryStrategies.get(action.type) || [];
|
|
984
|
-
|
|
985
|
-
|
|
1156
|
+
// `retries` caps how many strategies get a turn. Walking all of them
|
|
1157
|
+
// unconditionally would add a second full round of timeouts to every action
|
|
1158
|
+
// that was never going to succeed.
|
|
1159
|
+
const budget = Math.max(0, action.retries ?? 1);
|
|
1160
|
+
|
|
1161
|
+
for (const strategy of strategies.slice(0, budget)) {
|
|
986
1162
|
try {
|
|
987
1163
|
this.log('info', 'Attempting error recovery with strategy: ' + strategy.name);
|
|
988
1164
|
const result = await strategy.recover(page, action, error, executionContext);
|
|
@@ -1010,20 +1186,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1010
1186
|
this.errorRecoveryStrategies.set('click', [
|
|
1011
1187
|
{
|
|
1012
1188
|
name: 'waitAndRetry',
|
|
1013
|
-
recover: async (page, action
|
|
1189
|
+
recover: async (page, action) => {
|
|
1014
1190
|
await this.delay(1000);
|
|
1015
|
-
|
|
1016
|
-
|
|
1191
|
+
await this.elementLocator(page, action.selector)
|
|
1192
|
+
.click({ force: true, timeout: this.recoveryTimeout(action) });
|
|
1017
1193
|
return { success: true, data: { recovered: true, strategy: 'waitAndRetry' } };
|
|
1018
1194
|
}
|
|
1019
1195
|
},
|
|
1020
1196
|
{
|
|
1021
1197
|
name: 'scrollIntoView',
|
|
1022
|
-
recover: async (page, action
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1198
|
+
recover: async (page, action) => {
|
|
1199
|
+
const timeout = this.recoveryTimeout(action);
|
|
1200
|
+
const locator = this.elementLocator(page, action.selector);
|
|
1201
|
+
await locator.scrollIntoViewIfNeeded({ timeout });
|
|
1025
1202
|
await this.delay(500);
|
|
1026
|
-
await
|
|
1203
|
+
await locator.click({ timeout });
|
|
1027
1204
|
return { success: true, data: { recovered: true, strategy: 'scrollIntoView' } };
|
|
1028
1205
|
}
|
|
1029
1206
|
}
|
|
@@ -1033,11 +1210,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1033
1210
|
this.errorRecoveryStrategies.set('type', [
|
|
1034
1211
|
{
|
|
1035
1212
|
name: 'focusAndRetry',
|
|
1036
|
-
recover: async (page, action
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1213
|
+
recover: async (page, action) => {
|
|
1214
|
+
const timeout = this.recoveryTimeout(action);
|
|
1215
|
+
const locator = this.elementLocator(page, action.selector);
|
|
1216
|
+
await locator.focus({ timeout });
|
|
1039
1217
|
await this.delay(500);
|
|
1040
|
-
await
|
|
1218
|
+
await locator.pressSequentially(action.text, { delay: action.delay, timeout });
|
|
1041
1219
|
return { success: true, data: { recovered: true, strategy: 'focusAndRetry' } };
|
|
1042
1220
|
}
|
|
1043
1221
|
}
|
|
@@ -1047,13 +1225,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1047
1225
|
this.errorRecoveryStrategies.set('wait', [
|
|
1048
1226
|
{
|
|
1049
1227
|
name: 'extendTimeout',
|
|
1050
|
-
recover: async (page, action
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1228
|
+
recover: async (page, action) => {
|
|
1229
|
+
if (!action.selector) return { success: false };
|
|
1230
|
+
// One more bounded window, not a doubled one: the action has already
|
|
1231
|
+
// waited its full timeout, so doubling made a wait that could never
|
|
1232
|
+
// resolve cost 3x what the caller asked for.
|
|
1233
|
+
await this.waitForCondition(
|
|
1234
|
+
page, action.selector, action.condition, this.recoveryTimeout(action)
|
|
1235
|
+
);
|
|
1236
|
+
return { success: true, data: { recovered: true, strategy: 'extendTimeout' } };
|
|
1057
1237
|
}
|
|
1058
1238
|
}
|
|
1059
1239
|
]);
|