crawlforge-mcp-server 6.16.0 → 6.18.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/README.md CHANGED
@@ -178,8 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
178
178
  | Tool | Credits | What it does |
179
179
  |------|---------|--------------|
180
180
  | `fetch_url` | 1 | Fetch content from any URL |
181
- | `extract_text` | 1 | Extract clean text from web pages |
182
- | `extract_links` | 1 | Get all links from a page |
181
+ | `extract_text` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Extract clean text from web pages |
182
+ | `extract_links` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Get all links from a page |
183
183
  | `extract_metadata` | 1 | Extract page metadata (title, OG tags, schema.org) |
184
184
  | `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
185
185
  | `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.16.0",
3
+ "version": "6.18.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -121,7 +121,7 @@
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.11.0",
124
+ "crawlforge-extractors": "^1.13.0",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -29,7 +29,7 @@ import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
29
29
  import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
30
30
  import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
31
31
  import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
32
- import { StealthBrowserManager, resolveStealthEngine } from "./src/core/StealthBrowserManager.js";
32
+ import { StealthBrowserManager, resolveStealthEngine, assertProxyEngineAllowed } from "./src/core/StealthBrowserManager.js";
33
33
  import { LocalizationManager } from "./src/core/LocalizationManager.js";
34
34
  // Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
35
35
  import * as cheerio from "cheerio";
@@ -52,8 +52,9 @@ import { createOAuthProvider } from "./src/server/auth/oauth.js";
52
52
  import { createMetricsRegistry } from "./src/observability/metrics.js";
53
53
  // Basic tool handlers (extracted from server.js)
54
54
  import { fetchUrlHandler } from "./src/tools/basic/fetchUrl.js";
55
- import { extractTextHandler } from "./src/tools/basic/extractText.js";
56
- import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
55
+ import { createExtractTextHandler } from "./src/tools/basic/extractText.js";
56
+ import { createExtractLinksHandler } from "./src/tools/basic/extractLinks.js";
57
+ import { EXTRACT_ESCALATION_SHAPE } from "./src/utils/fetchLadder.js";
57
58
  import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
58
59
  import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
59
60
  import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
@@ -112,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
112
113
  // Create the server
113
114
  const server = new McpServer({
114
115
  name: "crawlforge",
115
- version: "6.16.0",
116
+ version: "6.18.0",
116
117
  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.",
117
118
  homepage: "https://www.crawlforge.dev",
118
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -439,29 +440,37 @@ registerToolIfEnabled("fetch_url", {
439
440
 
440
441
  // Tool: extract_text
441
442
  registerToolIfEnabled("extract_text", {
442
- description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
443
+ description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
443
444
  annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
444
445
  inputSchema: {
445
446
  url: z.string().url().describe("The URL to extract text from"),
446
447
  remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
447
448
  remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
448
449
  output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
450
+ selector: z.string().optional().describe("CSS selector: read only the matched elements (nav/header/footer are then kept). No match is an error"),
451
+ max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut and ends with \"...\""),
452
+ ...EXTRACT_ESCALATION_SHAPE,
449
453
  ...COMPLIANCE_PARAMS,
450
454
  ...REDACT_PII_PARAM
451
455
  }
452
- }, withAuth("extract_text", extractTextHandler));
456
+ }, withAuth("extract_text", createExtractTextHandler({
457
+ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_text' })
458
+ })));
453
459
 
454
460
  // Tool: extract_links
455
461
  registerToolIfEnabled("extract_links", {
456
- description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true returns only outbound links. Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
462
+ description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
457
463
  annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
458
464
  inputSchema: {
459
465
  url: z.string().url().describe("The URL to extract links from"),
460
- filter_external: z.boolean().optional().default(false).describe("Only return external links"),
466
+ filter_external: z.boolean().optional().default(false).describe("Drop internal (same-host) links"),
461
467
  base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
468
+ ...EXTRACT_ESCALATION_SHAPE,
462
469
  ...COMPLIANCE_PARAMS
463
470
  }
464
- }, withAuth("extract_links", extractLinksHandler));
471
+ }, withAuth("extract_links", createExtractLinksHandler({
472
+ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_links' })
473
+ })));
465
474
 
466
475
  // Tool: extract_metadata
467
476
  registerToolIfEnabled("extract_metadata", {
@@ -962,7 +971,7 @@ registerToolIfEnabled("read_result", {
962
971
 
963
972
  // Tool: scrape_with_actions
964
973
  registerToolIfEnabled("scrape_with_actions", {
965
- description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
974
+ description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation, and each navigation is checked: a bot wall is reported as `blocked` with the vendor named, per navigation in `navigations` and for the final page at the top level. browserOptions.proxyRotation routes a stealth chain through your own proxies. 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\"}]})",
966
975
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
967
976
  inputSchema: {
968
977
  url: z.string().url().describe("The URL to scrape"),
@@ -1036,7 +1045,12 @@ registerToolIfEnabled("scrape_with_actions", {
1036
1045
  timeout: z.number().min(10000).max(120000).default(30000),
1037
1046
  stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
1038
1047
  engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
1039
- consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged.")
1048
+ consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged."),
1049
+ proxyRotation: z.object({
1050
+ enabled: z.boolean().default(false),
1051
+ proxies: z.array(z.string()).optional(),
1052
+ rotationInterval: z.number().default(300000)
1053
+ }).optional().describe("Route a stealth chain through your own proxies; requires stealth:true and the Chromium engine, and is refused without stealth or on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. A proxy on a loopback, link-local or cloud-metadata address is refused, and credentials are removed from the result. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies.")
1040
1054
  }).optional().describe("Browser configuration options"),
1041
1055
  extractionOptions: z.object({
1042
1056
  selectors: z.record(z.string()).optional(),
@@ -1426,7 +1440,7 @@ registerToolIfEnabled("stealth_mode", {
1426
1440
  enabled: z.boolean().default(false),
1427
1441
  proxies: z.array(z.string()).optional(),
1428
1442
  rotationInterval: z.number().default(300000)
1429
- }).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies."),
1443
+ }).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. Requires the Chromium engine: refused on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. CrawlForge supplies no proxies."),
1430
1444
  antiDetection: z.object({
1431
1445
  cloudflareBypass: z.boolean().default(true),
1432
1446
  recaptchaHandling: z.boolean().default(true),
@@ -1466,6 +1480,7 @@ registerToolIfEnabled("stealth_mode", {
1466
1480
  // is missing; "playwright" is this tool's old public name for chromium.
1467
1481
  // The resolver owns both, and reports a fallback the caller can see.
1468
1482
  const resolvedEngine = await resolveStealthEngine(engine);
1483
+ assertProxyEngineAllowed(resolvedEngine.engine, stealthConfig);
1469
1484
  if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
1470
1485
 
1471
1486
  recordStealthEscalation({
@@ -1533,6 +1548,7 @@ registerToolIfEnabled("stealth_mode", {
1533
1548
  // without it, create_context always ran on chromium whatever the caller
1534
1549
  // asked for. Resolved, so "auto" reaches the manager as a real engine.
1535
1550
  const contextEngine = await resolveStealthEngine(engine);
1551
+ assertProxyEngineAllowed(contextEngine.engine, stealthConfig);
1536
1552
  const contextData = await stealthBrowserManager.createStealthContext({
1537
1553
  ...(stealthConfig || {}),
1538
1554
  engine: contextEngine.engine
@@ -12,6 +12,7 @@ import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '.
12
12
  import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
13
13
  import { settlePage } from './browser/settle.js';
14
14
  import { handleConsent } from './browser/consent.js';
15
+ import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
15
16
 
16
17
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
17
18
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -31,6 +32,13 @@ const ACTION_TIMEOUT_GRACE_MS = 2000;
31
32
  // to work cost several times its stated timeout.
32
33
  const RECOVERY_TIMEOUT_MS = 3000;
33
34
 
35
+ // How long a navigation whose document reads as a wall is watched for the wall
36
+ // replacing itself before it is reported, re-read every NAVIGATION_WALL_POLL_MS.
37
+ // AWS WAF's interstitial reloads into amazon.com's homepage about 0.4 s after
38
+ // domcontentloaded (measured 2026-10-03); nothing here acts on the page.
39
+ const NAVIGATION_WALL_GRACE_MS = 3000;
40
+ const NAVIGATION_WALL_POLL_MS = 500;
41
+
34
42
  // The only states locator.waitFor()/page.waitForSelector() accept. The rest of
35
43
  // the wait-action enum (enabled/disabled/stable) are ElementHandle states and
36
44
  // have to go through waitForElementState instead — passing them here is
@@ -312,6 +320,10 @@ export class ActionExecutor extends EventEmitter {
312
320
  executionContext.consent = await handleConsent(page, browserOptions.consent);
313
321
  }
314
322
 
323
+ // navigations[0] is the initial load; each `navigate` action appends
324
+ // its own (executeNavigateAction).
325
+ executionContext.navigations = [await this.checkNavigation(page, url, browserOptions)];
326
+
315
327
  // Execute chain with potential retries
316
328
  chainResult = await this.executeChainWithRetries(executionContext);
317
329
 
@@ -419,6 +431,7 @@ export class ActionExecutor extends EventEmitter {
419
431
  finalUrl: executionContext.finalUrl || url,
420
432
  finalHtml: executionContext.finalHtml,
421
433
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
434
+ navigations: executionContext.navigations || [],
422
435
  consent: executionContext.consent,
423
436
  executionTime: Date.now() - startTime,
424
437
  results: executionContext.results,
@@ -446,6 +459,7 @@ export class ActionExecutor extends EventEmitter {
446
459
  // results, the error screenshot, and any intermediate-state
447
460
  // captures) instead of discarding them.
448
461
  results: executionContext?.results || [],
462
+ navigations: executionContext?.navigations || [],
449
463
  attempt: executionContext?.attempt ?? 0,
450
464
  attempts: executionContext?.attempts || [],
451
465
  screenshots: executionContext?.screenshots || [],
@@ -478,6 +492,11 @@ export class ActionExecutor extends EventEmitter {
478
492
  await this.navigateToUrl(page, executionContext.url, {
479
493
  browserOptions: executionContext.browserOptions
480
494
  });
495
+ // Only the last attempt's navigations are reported, so the reload
496
+ // that starts this one is navigations[0].
497
+ executionContext.navigations = [
498
+ await this.checkNavigation(page, executionContext.url, executionContext.browserOptions)
499
+ ];
481
500
  }
482
501
 
483
502
  // Execute actions in sequence
@@ -1346,14 +1365,73 @@ export class ActionExecutor extends EventEmitter {
1346
1365
  ? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
1347
1366
  : undefined;
1348
1367
 
1368
+ // Reporting only: the navigation happened, so the action succeeds whether
1369
+ // or not the document it reached is a wall. The wait for a wall to clear
1370
+ // gets what is left of the deadline, like consent above.
1371
+ const navigation = await this.checkNavigation(page, action.url, executionContext?.browserOptions, {
1372
+ graceMs: Math.min(NAVIGATION_WALL_GRACE_MS, timeout - (Date.now() - startedAt))
1373
+ });
1374
+ executionContext?.navigations?.push(navigation);
1375
+
1349
1376
  return {
1350
1377
  url: action.url,
1351
- finalUrl: page.url(),
1378
+ finalUrl: navigation.finalUrl,
1352
1379
  waitUntil: action.waitUntil || 'domcontentloaded',
1380
+ httpStatus: navigation.httpStatus,
1381
+ ...(navigation.blocked ? { blocked: navigation.blocked } : {}),
1353
1382
  ...(consent ? { consent } : {})
1354
1383
  };
1355
1384
  }
1356
1385
 
1386
+ /**
1387
+ * What the navigation that just finished reached: the page, or a wall. The
1388
+ * same verdict the chain's final document gets, run on this document with
1389
+ * the status of this navigation. A document that reads as a wall is re-read
1390
+ * for up to `graceMs` first, because some walls replace themselves (AWS WAF's
1391
+ * interstitial reloads into the page); one that cannot be read at all is
1392
+ * reported without a verdict rather than as a wall.
1393
+ * @param {Page} page - Playwright page, just navigated
1394
+ * @param {string} url - the URL the navigation was sent to
1395
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` for the read's gate)
1396
+ * @param {{ graceMs?: number }} [options]
1397
+ * @returns {Promise<{ url: string, finalUrl: string, httpStatus: number|null, blocked?: { vendor: string, evidence: string } }>}
1398
+ */
1399
+ async checkNavigation(page, url, browserOptions, { graceMs = NAVIGATION_WALL_GRACE_MS } = {}) {
1400
+ const httpStatus = page.__crawlforgeNavigation?.status ?? null;
1401
+ const read = () => this.readGated(page, browserOptions, async () => {
1402
+ try {
1403
+ const [title, html, text] = await Promise.all([
1404
+ page.title(),
1405
+ page.content(),
1406
+ page.evaluate(() => (document.body ? document.body.innerText : ''))
1407
+ ]);
1408
+ return stealthDocumentVerdict(
1409
+ { url: page.url(), title, html, text, status: httpStatus },
1410
+ { allowEmpty: true, fetcher: browserOptions?.stealthMode?.enabled ? 'the stealth browser' : 'the browser' }
1411
+ );
1412
+ } catch {
1413
+ return null; // mid-navigation, or a page that cannot be read
1414
+ }
1415
+ });
1416
+
1417
+ let verdict = await read();
1418
+ const deadline = Date.now() + graceMs;
1419
+ while (verdict?.blocked && Date.now() + NAVIGATION_WALL_POLL_MS <= deadline) {
1420
+ await this.delay(NAVIGATION_WALL_POLL_MS);
1421
+ const next = await read();
1422
+ // A read that fails mid-reload says nothing yet; keep the wall until a
1423
+ // document answers.
1424
+ if (next) verdict = next;
1425
+ }
1426
+
1427
+ return {
1428
+ url,
1429
+ finalUrl: page.url(),
1430
+ httpStatus,
1431
+ ...(verdict?.blocked ? { blocked: verdict.blocked } : {})
1432
+ };
1433
+ }
1434
+
1357
1435
  /**
1358
1436
  * Execute screenshot action
1359
1437
  * @param {Page} page - Playwright page
@@ -1503,9 +1581,12 @@ export class ActionExecutor extends EventEmitter {
1503
1581
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1504
1582
  await assertUrlAllowed(url, { resolveDns: true });
1505
1583
 
1584
+ // The initial load and a retry's reload pass no timeout of their own, so
1585
+ // browserOptions.timeout bounds them as it bounds the actions (0.2); a
1586
+ // navigate action's own timeout comes first.
1506
1587
  const response = await page.goto(url, {
1507
1588
  waitUntil: options.waitUntil || 'domcontentloaded',
1508
- timeout: options.timeout || 30000
1589
+ timeout: options.timeout || options.browserOptions?.timeout || 30000
1509
1590
  });
1510
1591
 
1511
1592
  // Re-validate where the navigation went: a redirect could have taken us
@@ -687,6 +687,12 @@ class AuthManager {
687
687
  return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
688
688
  }
689
689
 
690
+ // extract_text and extract_links (fix plan Phase E2) run the same ladder:
691
+ // projected 1+5 when escalating, charged 1 when the plain fetch sufficed.
692
+ if (tool === 'extract_text' || tool === 'extract_links') {
693
+ return costs[tool] + scrapeEscalationSurcharge(params?.escalate) + redaction;
694
+ }
695
+
690
696
  // search_web's batch form (5.1) runs one backend search per query, so it
691
697
  // is priced as that many search_web calls. The count rule lives with the
692
698
  // schema that declares `queries` (src/tools/search/batchSearch.js).
@@ -778,6 +784,12 @@ class AuthManager {
778
784
  ? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
779
785
  : 'Fixed cost per invocation.';
780
786
  break;
787
+ case 'extract_text':
788
+ case 'extract_links':
789
+ note = params?.escalate === true
790
+ ? 'Base 1; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran.'
791
+ : 'Fixed cost per invocation.';
792
+ break;
781
793
  case 'agent': {
782
794
  const agentUrls = params?.maxUrls || 10;
783
795
  const isPro = params?.model === 'pro';
@@ -17,7 +17,7 @@ import path from 'path';
17
17
  import { createRequire } from 'module';
18
18
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
19
19
  import { BrowserContextPool } from './BrowserContextPool.js';
20
- import { safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
20
+ import { safeGoto, assertNavigationAllowed, assertUrlAllowed } from '../utils/ssrfGuard.js';
21
21
  import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
22
22
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
23
23
  import { serverStealthProxies } from '../constants/config.js';
@@ -159,6 +159,39 @@ function isChromium(page) {
159
159
  }
160
160
  }
161
161
 
162
+ /**
163
+ * A proxy entry with its credentials removed. Everything before the last "@"
164
+ * of the authority goes, with or without a scheme: parseProxyEntry accepts a
165
+ * bare `user:pass@host:port`, which a rule anchored on "//" left whole.
166
+ * @param {string} entry
167
+ * @returns {string}
168
+ */
169
+ export function redactProxyEntry(entry) {
170
+ return String(entry).replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/]*@/i, '$1');
171
+ }
172
+
173
+ /**
174
+ * Refuse a per-call proxy list on camoufox. camoufox takes its proxy at
175
+ * browser launch and that one browser serves every later camoufox call, so a
176
+ * caller's proxy would carry other callers' traffic, on the caller's
177
+ * credentials, or be ignored when a browser is already up. Chromium sets the
178
+ * proxy per context. Called with the engine that will actually run, so an
179
+ * "auto" that lands on camoufox is refused too. The operator's
180
+ * CRAWLFORGE_STEALTH_PROXIES is not a per-call list and is not refused.
181
+ * @param {string} engine - the resolved engine
182
+ * @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
183
+ * @throws {Error}
184
+ */
185
+ export function assertProxyEngineAllowed(engine, config) {
186
+ const requested = config?.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
187
+ if (engine !== 'camoufox' || !requested.length) return;
188
+ throw new Error(
189
+ 'proxyRotation is refused on the camoufox engine: camoufox applies its proxy at browser launch ' +
190
+ 'and shares that browser across calls, so a per-call proxy cannot be kept to this call. Use ' +
191
+ 'engine:"chromium" (proxies are per context there), or set the operator list CRAWLFORGE_STEALTH_PROXIES.'
192
+ );
193
+ }
194
+
162
195
  /**
163
196
  * Resolve a requested engine to the one that will actually launch.
164
197
  *
@@ -422,12 +455,19 @@ export class StealthBrowserManager {
422
455
  */
423
456
  async launchStealthBrowser(config = {}) {
424
457
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
458
+ // Every path to a browser comes through here, createStealthContext's
459
+ // included, so this is where a caller's proxy list is held to the SSRF
460
+ // guard before either engine is handed it.
461
+ await this.assertCallerProxiesAllowed(validatedConfig);
425
462
  // 'auto' has to become a concrete engine before the comparison below: an
426
463
  // unresolved 'auto' never equals _launchedEngine, so every call would park
427
464
  // a perfectly good browser and launch a second one beside it. This is the
428
465
  // call that decides the engine — every other path reaches a browser
429
466
  // through it — so it is the one that records the outcome.
430
467
  await this._resolveConfigEngine(validatedConfig, true);
468
+ // The tools refuse this up front; this is the backstop for every other
469
+ // path, decided on the engine 'auto' resolved to, before any launch.
470
+ assertProxyEngineAllowed(validatedConfig.engine, validatedConfig);
431
471
 
432
472
  // A Chromium that was OOM-killed or crashed doesn't error on reuse — its
433
473
  // protocol calls hang. Detect the corpse and relaunch instead.
@@ -528,7 +568,11 @@ export class StealthBrowserManager {
528
568
  // leave camoufox reporting the first proxy's city behind the second
529
569
  // proxy's exit IP, which is a worse signal than not rotating at all.
530
570
  // A rotation takes effect on the next launch, after cleanup().
531
- const proxy = this.resolveProxy(validatedConfig);
571
+ //
572
+ // Only the operator's CRAWLFORGE_STEALTH_PROXIES, never a caller's list:
573
+ // this browser outlives the call, and every later camoufox context
574
+ // inherits its proxy (assertProxyEngineAllowed refuses the caller's).
575
+ const proxy = this.resolveProxy({ ...validatedConfig, proxyRotation: undefined });
532
576
  // The caller's locale goes to the launcher, not to the context. camoufox
533
577
  // sets language, Accept-Language and Intl together below the JS layer,
534
578
  // where a Worker reads the same answer as the document; Playwright's
@@ -2387,18 +2431,23 @@ export class StealthBrowserManager {
2387
2431
  // Wait for potential challenge page
2388
2432
  await page.waitForTimeout(2000);
2389
2433
 
2390
- // Check for CloudFlare challenge indicators
2434
+ // Check for CloudFlare challenge indicators. Cloudflare's current
2435
+ // interstitial reads "Performing security verification" under the title
2436
+ // "Just a moment..." (doordash.com, 2026-10-03); none of the older
2437
+ // phrases below appear on it, so the wait further down ended at once.
2391
2438
  const challengeDetected = await page.evaluate(() => {
2392
2439
  const indicators = [
2393
2440
  'cf-browser-verification',
2394
2441
  'cf-challenge-running',
2395
2442
  'Checking your browser',
2443
+ 'Performing security verification',
2396
2444
  'DDoS protection by Cloudflare',
2397
2445
  'Ray ID'
2398
2446
  ];
2399
2447
 
2400
2448
  const pageText = document.body.innerText;
2401
- return indicators.some(indicator => pageText.includes(indicator));
2449
+ return /^just a moment/i.test(document.title.trim()) ||
2450
+ indicators.some(indicator => pageText.includes(indicator));
2402
2451
  });
2403
2452
 
2404
2453
  if (challengeDetected) {
@@ -2420,16 +2469,25 @@ export class StealthBrowserManager {
2420
2469
  }
2421
2470
  }
2422
2471
 
2423
- // Wait for challenge to complete (up to 30 seconds)
2472
+ // Wait for the challenge to complete, up to 10 s. Only waits for
2473
+ // Cloudflare's own script to finish; nothing is clicked. A challenge
2474
+ // that passes clears in seconds; one that will not pass (an
2475
+ // interactive check, or this IP's reputation) never clears, and 30 s
2476
+ // there put a walled chain past the REST window (doordash.com stealth
2477
+ // Chromium, 3 of 3 runs walled at 39 s with the 30 s cap, 2026-10-03).
2424
2478
  await page.waitForFunction(() => {
2425
2479
  const indicators = [
2426
2480
  'cf-browser-verification',
2427
2481
  'cf-challenge-running',
2428
- 'Checking your browser'
2482
+ 'Checking your browser',
2483
+ 'Performing security verification'
2429
2484
  ];
2430
- const pageText = document.body.innerText;
2431
- return !indicators.some(indicator => pageText.includes(indicator));
2432
- }, { timeout: 30000 }).catch(() => {});
2485
+ const pageText = document.body ? document.body.innerText : '';
2486
+ return !/^just a moment/i.test(document.title.trim()) &&
2487
+ !indicators.some(indicator => pageText.includes(indicator));
2488
+ // Options are the third argument; in the second they were the page
2489
+ // function's argument and the wait ran Playwright's 30 s default.
2490
+ }, undefined, { timeout: 10000 }).catch(() => {});
2433
2491
 
2434
2492
  this.performanceMetrics.successfulBypasses++;
2435
2493
  return true;
@@ -2550,9 +2608,34 @@ export class StealthBrowserManager {
2550
2608
  return proxy;
2551
2609
  }
2552
2610
 
2611
+ /**
2612
+ * Refuse a caller's proxy whose host the SSRF guard would refuse as a page:
2613
+ * a proxy on loopback, link-local or a metadata address (RFC 1918 too under
2614
+ * SSRF_STRICT, and ALLOWED_DOMAINS exempt, as everywhere else) would have
2615
+ * the browser send the call's traffic into this server's own network. Only
2616
+ * the per-call list is checked — CRAWLFORGE_STEALTH_PROXIES is the
2617
+ * operator's, who may well run a proxy on localhost. The message names the
2618
+ * host, never the entry, which carries the password.
2619
+ * @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
2620
+ * @returns {Promise<void>}
2621
+ * @throws {Error} code SSRF_BLOCKED
2622
+ */
2623
+ async assertCallerProxiesAllowed(config) {
2624
+ const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
2625
+ for (const entry of requested) {
2626
+ // url.host of socks4/socks5 parses the same way; the guard only takes http(s).
2627
+ const { host } = new URL(this.parseProxyEntry(entry).server);
2628
+ try {
2629
+ await assertUrlAllowed(`http://${host}/`, { resolveDns: true });
2630
+ } catch (err) {
2631
+ throw Object.assign(new Error(`proxyRotation: proxy host ${host} refused — ${err.message}`), { code: err.code });
2632
+ }
2633
+ }
2634
+ }
2635
+
2553
2636
  /** A proxy entry with its credentials removed, for logs and get_stats. */
2554
2637
  redactProxy(entry) {
2555
- return String(entry).replace(/\/\/[^/@]*@/, '//');
2638
+ return redactProxyEntry(entry);
2556
2639
  }
2557
2640
 
2558
2641
  /**
@@ -373,6 +373,11 @@ export class BrowserProcessor {
373
373
  // createStealthContext re-validates the config it is given — so a context
374
374
  // asked for without it would relaunch on the default engine underneath.
375
375
  const engine = options.stealthMode.engine || 'chromium';
376
+ // The caller's own proxies (scrape_with_actions browserOptions.proxyRotation),
377
+ // to both calls for the same reason as the engine: camoufox takes its proxy
378
+ // at launch, Chromium per context. Absent unless the caller sent one, so
379
+ // CRAWLFORGE_STEALTH_PROXIES still applies when nobody did.
380
+ const proxy = options.proxyRotation ? { proxyRotation: options.proxyRotation } : {};
376
381
 
377
382
  // Launch stealth browser
378
383
  await this.stealthManager.launchStealthBrowser({
@@ -381,12 +386,14 @@ export class BrowserProcessor {
381
386
  randomizeFingerprint: options.stealthMode.randomizeFingerprint,
382
387
  hideWebDriver: options.stealthMode.hideWebDriver,
383
388
  blockWebRTC: options.stealthMode.blockWebRTC,
384
- customUserAgent: options.stealthMode.customUserAgent || options.userAgent
389
+ customUserAgent: options.stealthMode.customUserAgent || options.userAgent,
390
+ ...proxy
385
391
  });
386
392
 
387
393
  // Create stealth context
388
394
  const { context, contextId } = await this.stealthManager.createStealthContext({
389
395
  engine,
396
+ ...proxy,
390
397
  level: options.stealthMode.level,
391
398
  customViewport: {
392
399
  width: options.viewportWidth || 1280,
@@ -11,8 +11,8 @@
11
11
 
12
12
  export const FALLBACK_HINTS = Object.freeze({
13
13
  fetch_url: 'For a JS-rendered page or an empty shell use scrape; after a 403/429/CAPTCHA/challenge page use stealth_mode operation:"scrape". Do not repeat the same fetch_url call.',
14
- extract_text: 'Use scrape formats:["markdown"] (renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
15
- extract_links: 'Use scrape formats:["links"]; for a whole site use map_site.',
14
+ extract_text: 'After a 403/429/444/challenge page call extract_text again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["markdown"]; for a PDF use process_document.',
15
+ extract_links: 'After a 403/429/444/challenge page call extract_links again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["links"]; for a whole site use map_site.',
16
16
  extract_metadata: 'Use scrape formats:["metadata"]; after a 403/429/challenge page use stealth_mode operation:"scrape".',
17
17
  extract_content: 'Use scrape formats:["markdown"] (same clean output, renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
18
18
  extract_embedded_state: 'Call again without `path` to see the top-level keys, or use scrape formats:["markdown"] if the page has no framework payload.',
@@ -56,8 +56,8 @@ export const SCRAPE_ESCALATED_HINT =
56
56
  'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
57
57
  'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
58
58
 
59
- /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
60
- const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
59
+ /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2; extract_text and extract_links since Phase E2). */
60
+ const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state', 'extract_text', 'extract_links']);
61
61
 
62
62
  /** The hint for this result: the tool's, unless the call already escalated. */
63
63
  function hintFor(toolName, parsed) {