crawlforge-mcp-server 5.1.0 → 5.2.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.
Files changed (40) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +8 -4
  3. package/package.json +5 -4
  4. package/server.js +23 -15
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/ChangeTracker.js +226 -54
  7. package/src/core/ResearchOrchestrator.js +9 -3
  8. package/src/core/SamplingClient.js +4 -5
  9. package/src/core/StealthBrowserManager.js +64 -18
  10. package/src/core/cache/CacheManager.js +7 -2
  11. package/src/core/crawlers/BFSCrawler.js +14 -6
  12. package/src/core/llm/LLMManager.js +61 -11
  13. package/src/core/llm/OllamaProvider.js +139 -0
  14. package/src/core/processing/BrowserProcessor.js +28 -2
  15. package/src/schemas/toolOutputSchemas.js +3 -1
  16. package/src/server/requestContext.js +26 -0
  17. package/src/server/transports/streamableHttp.js +54 -11
  18. package/src/server/withAuth.js +24 -6
  19. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  20. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  21. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  22. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  24. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  25. package/src/tools/basic/_fetch.js +12 -91
  26. package/src/tools/basic/fetchUrl.js +4 -1
  27. package/src/tools/crawl/crawlDeep.js +19 -5
  28. package/src/tools/extract/extractStructured.js +16 -4
  29. package/src/tools/extract/extractWithLlm.js +80 -10
  30. package/src/tools/extract/listOllamaModels.js +4 -6
  31. package/src/tools/scrape/_brandingExtractor.js +1 -1
  32. package/src/tools/scrape/unifiedScrape.js +71 -5
  33. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  34. package/src/tools/search/redditNormalize.js +95 -0
  35. package/src/tools/search/redditSearch.js +67 -91
  36. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  37. package/src/utils/hiddenContent.js +330 -0
  38. package/src/utils/htmlToMarkdown.js +12 -2
  39. package/src/utils/ollamaConfig.js +121 -0
  40. 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.1.0
65
+ **Current Version:** 5.2.1
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 defaults to Ollama
238
- export OLLAMA_BASE_URL="http://localhost:11434" # default
239
- export OLLAMA_DEFAULT_MODEL="llama3.2" # default; any locally-pulled model name works
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.1.0",
3
+ "version": "5.2.1",
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.1.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.1.0",
103
+ version: "5.2.1",
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("Force a specific archive backend (default auto routes + falls back)")
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
- if (urlToTest) {
1257
- // page.goto returns a Playwright Response handle, which is not
1258
- // JSON-serializable extract just the useful navigation details.
1259
- const response = await page.goto(urlToTest);
1260
- navigation = {
1261
- requestedUrl: urlToTest,
1262
- finalUrl: page.url(),
1263
- status: response ? response.status() : null,
1264
- ok: response ? response.ok() : null,
1265
- title: await page.title().catch(() => null)
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;
@@ -1394,7 +1402,7 @@ registerToolIfEnabled("localization", {
1394
1402
 
1395
1403
  // Tool: scrape_template (D3.3 — pre-built site templates)
1396
1404
  registerToolIfEnabled("scrape_template", {
1397
- description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1405
+ description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: shopify-product (any Shopify storefront, read from the store's own /products/<handle>.json rather than the rendered page), amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1398
1406
  annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1399
1407
  inputSchema: {
1400
1408
  template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
@@ -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
- retries: z.number().min(0).max(5).default(0),
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
- let timeout = action.timeout || this.defaultTimeout;
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 with timeout
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
- setTimeout(() => reject(new Error('Action timeout')), timeout);
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
- result = await Promise.race([executionPromise, timeoutPromise]);
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
- const options = {};
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 element = await page.waitForSelector(action.selector);
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 element.click(clickOptions);
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 element = await page.waitForSelector(action.selector);
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 element.selectText();
649
- await element.press('Delete');
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 element.type(action.text, { delay: action.delay });
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 keyOptions = {};
675
- if (action.modifiers.length > 0) {
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
- const element = await page.waitForSelector(action.selector);
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
- const element = await page.waitForSelector(action.toElement);
711
- await element.scrollIntoView();
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
- const element = await page.waitForSelector(action.selector);
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
- const element = await page.waitForSelector(options.selector);
872
- screenshot = await element.screenshot(screenshotOptions);
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
- await page.goto(url, {
913
- waitUntil: 'domcontentloaded',
914
- timeout: 30000
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
- for (const strategy of strategies) {
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, error) => {
1189
+ recover: async (page, action) => {
1014
1190
  await this.delay(1000);
1015
- const element = await page.waitForSelector(action.selector, { timeout: 5000 });
1016
- await element.click({ force: true });
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, error) => {
1023
- const element = await page.waitForSelector(action.selector);
1024
- await element.scrollIntoView();
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 element.click();
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, error) => {
1037
- const element = await page.waitForSelector(action.selector);
1038
- await element.focus();
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 element.type(action.text, { delay: action.delay });
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, error) => {
1051
- const extendedTimeout = (action.timeout || this.defaultTimeout) * 2;
1052
- if (action.selector) {
1053
- await page.waitForSelector(action.selector, { timeout: extendedTimeout });
1054
- return { success: true, data: { recovered: true, strategy: 'extendTimeout' } };
1055
- }
1056
- return { success: false };
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
  ]);