crawlforge-mcp-server 6.6.2 → 6.8.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.
@@ -29,7 +29,7 @@ for (const [key, value] of Object.entries(cwdEnv)) {
29
29
  // SECURITY: Clear any externally-set creator mode env var to prevent bypass
30
30
  delete process.env.CRAWLFORGE_CREATOR_MODE;
31
31
 
32
- const CREATOR_SECRET_HASH = 'cfef62e5068d48e7dd6a39c9e16f0be2615510c6b68274fc8abe3156feb5050b';
32
+ const CREATOR_SECRET_HASH = '52645de07b540d6a42e21fffe20dc77961d144d8dc388d82aa22d20ac4ba31a9';
33
33
 
34
34
  // Module-scoped flag — cannot be set externally
35
35
  let _creatorModeVerified = false;
@@ -47,7 +47,11 @@ const BrowserProcessorSchema = z.object({
47
47
  simulateHumanBehavior: z.boolean().default(true),
48
48
  customUserAgent: z.string().optional(),
49
49
  hideWebDriver: z.boolean().default(true),
50
- blockWebRTC: z.boolean().default(true)
50
+ blockWebRTC: z.boolean().default(true),
51
+ // Already resolved to a concrete engine by the caller (Phase 2): the
52
+ // tools call resolveStealthEngine() so they can report a camoufox→
53
+ // chromium fallback in their own result. Nothing 'auto' reaches here.
54
+ engine: z.enum(['chromium', 'camoufox']).optional()
51
55
  }).optional(),
52
56
 
53
57
  // Human behavior simulation options
@@ -307,7 +311,12 @@ export class BrowserProcessor {
307
311
  } else {
308
312
  // Standard browser initialization
309
313
  await this.initBrowser();
310
- return await this.createPage(processedOptions);
314
+ const page = await this.createPage(processedOptions);
315
+ // Stamped for the same reason __crawlforgeGateWarnings is: the caller's
316
+ // result is assembled a layer up, and only this layer knows which engine
317
+ // actually ran. The standard pool is Chromium; camoufox is stealth-only.
318
+ page.__crawlforgeEngine = 'chromium';
319
+ return page;
311
320
  }
312
321
  }
313
322
 
@@ -356,8 +365,15 @@ export class BrowserProcessor {
356
365
  });
357
366
  }
358
367
 
368
+ // The engine the caller resolved (Phase 2). It has to reach BOTH calls:
369
+ // launchStealthBrowser parks the running browser on a mismatch, and
370
+ // createStealthContext re-validates the config it is given — so a context
371
+ // asked for without it would relaunch on the default engine underneath.
372
+ const engine = options.stealthMode.engine || 'chromium';
373
+
359
374
  // Launch stealth browser
360
375
  await this.stealthManager.launchStealthBrowser({
376
+ engine,
361
377
  level: options.stealthMode.level,
362
378
  randomizeFingerprint: options.stealthMode.randomizeFingerprint,
363
379
  hideWebDriver: options.stealthMode.hideWebDriver,
@@ -367,6 +383,7 @@ export class BrowserProcessor {
367
383
 
368
384
  // Create stealth context
369
385
  const { context, contextId } = await this.stealthManager.createStealthContext({
386
+ engine,
370
387
  level: options.stealthMode.level,
371
388
  customViewport: {
372
389
  width: options.viewportWidth || 1280,
@@ -376,7 +393,8 @@ export class BrowserProcessor {
376
393
 
377
394
  // Create stealth page
378
395
  const page = await this.stealthManager.createStealthPage(contextId);
379
-
396
+ page.__crawlforgeEngine = engine;
397
+
380
398
  // Store context for cleanup
381
399
  this.activeContexts.set(contextId, { context, page });
382
400
 
@@ -458,7 +476,15 @@ export class BrowserProcessor {
458
476
  });
459
477
  }
460
478
 
461
- // Add extra stealth protections
479
+ // Add extra stealth protections.
480
+ //
481
+ // Chromium only. This script hands the page a `window.chrome` object and a
482
+ // Chrome-shaped navigator; on camoufox's Firefox that is a tell no real
483
+ // Firefox has, and camoufox already spoofs all of it below the JS layer
484
+ // where a page cannot see the seam — the same reason StealthBrowserManager
485
+ // injects nothing into camoufox.
486
+ if (page.__crawlforgeEngine === 'camoufox') return;
487
+
462
488
  await page.addInitScript(() => {
463
489
  // Additional webdriver detection removal
464
490
  delete window.navigator.__proto__.webdriver;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: crawlforge-stealth-browsing
3
- description: "Bypasses bot detection and geo-restrictions with CrawlForge's stealth_mode and localization tools. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs region-specific pricing, geo-blocked content, or a specific locale, timezone, or currency. stealth_mode runs a stealth browser (playwright by default, camoufox for advanced fingerprinting) and can screenshot; localization emulates a country and language. Explains when to escalate from a normal scrape to stealth."
3
+ description: "Bypasses bot detection and geo-restrictions with CrawlForge's stealth_mode and localization tools. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs region-specific pricing, geo-blocked content, or a specific locale, timezone, or currency. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization emulates a country and language. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
4
4
  metadata:
5
5
  version: 5.6.6
6
6
  source: crawlforge-mcp-server
@@ -39,8 +39,7 @@ page that navigates to the target URL.
39
39
  "tool": "stealth_mode",
40
40
  "params": {
41
41
  "operation": "create_context",
42
- "stealthConfig": { "level": "advanced", "simulateHumanBehavior": true },
43
- "engine": "playwright"
42
+ "stealthConfig": { "level": "advanced", "simulateHumanBehavior": true }
44
43
  }
45
44
  }
46
45
  ```
@@ -58,16 +57,64 @@ Operations: `configure`, `enable`, `disable`, `create_context`, `create_page`,
58
57
  `get_stats`, `cleanup`. `stealthConfig.level` is `basic` / `medium` (default) /
59
58
  `advanced`. Always run `cleanup` when done to release the browser.
60
59
 
61
- ### Engine: playwright vs camoufox
60
+ ### Engine: auto, playwright, camoufox
62
61
 
63
- - `engine:"playwright"` (default) — Chromium with stealth patches. Fast, good
64
- for most basic bot detection.
62
+ - `engine:"auto"` (default) — Camoufox when its binary is installed, Chromium
63
+ with a warning when it is not. Leave it alone unless you have a reason.
65
64
  - `engine:"camoufox"` — Firefox-based with native anti-detection (no patches).
66
- Scores higher against DataDome / Cloudflare / PerimeterX and on CreepJS. Use
67
- for heavily protected, financial, or e-commerce sites.
65
+ The only engine that cleared Cloudflare Turnstile (indeed.com) and Akamai
66
+ (harrods.com) in the 2026-09-21 benchmark. Pin it to require that engine: it
67
+ errors rather than falling back. Costs roughly +0.8 s and +400 MB per call
68
+ against Chromium.
69
+ - `engine:"playwright"` — Chromium with stealth patches. Pin it for speed on
70
+ sites that need rendering rather than evasion.
71
+
72
+ `stealth_mode` also accepts `"chromium"` as a synonym for `"playwright"`.
73
+ `browser_session` takes the same `engine` on `operation:"open"` and
74
+ `scrape_with_actions` takes it as `browserOptions.engine` — both only with
75
+ `stealth: true`, and both default to `"auto"`. `scrape`'s `escalate_engine` is
76
+ the one parameter that does not accept `"chromium"`; say `"playwright"` there.
77
+
78
+ The result always names the engine that ran (`stealth.engine` on `scrape`,
79
+ `engine` elsewhere), and an `auto` run that fell back to Chromium says so in
80
+ `warnings[]` — read that rather than assuming you got Camoufox.
81
+
82
+ Neither engine defeats DataDome or an interactive Turnstile. If both are
83
+ blocked, the address is usually the problem, not the browser.
68
84
 
69
85
  Full decision table: [engine selection](references/engine-selection.md).
70
86
 
87
+ ### Proxies
88
+
89
+ A block that survives both engines is usually the IP, not the fingerprint:
90
+ Cloudflare scores the address and its ASN before it serves a challenge, so a
91
+ datacenter proxy changes the address without changing the class of address being
92
+ scored. Route through your own residential proxy — CrawlForge supplies none.
93
+
94
+ Server-wide, set `CRAWLFORGE_STEALTH_PROXIES` (comma-separated proxy URLs): the
95
+ `scrape` escalation stage, `stealth_mode`, `browser_session`,
96
+ `scrape_with_actions` and the `deep_research` retry use it when no proxy is
97
+ passed on the call. (`agent` does not browse yet, so it is not a reader.) Per call, pass
98
+ `stealthConfig.proxyRotation`, which always wins:
99
+
100
+ ```json
101
+ {
102
+ "tool": "stealth_mode",
103
+ "params": {
104
+ "operation": "scrape", "url": "https://protected-site.com", "engine": "camoufox",
105
+ "stealthConfig": {
106
+ "proxyRotation": { "enabled": true, "proxies": ["http://user:p%40ss@gw.provider.net:8080"] }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ Credentials go in the URL, percent-encoded if the password contains `@`, `:` or
113
+ `/`. `http`, `https`, `socks4` and `socks5` are accepted. With a proxy, camoufox
114
+ derives its timezone, locale and geolocation from the exit IP, so the browser
115
+ agrees with the address the site sees — that lookup happens at launch, so a
116
+ camoufox browser keeps one proxy until `cleanup`.
117
+
71
118
  ### CLI
72
119
 
73
120
  ```bash
@@ -76,7 +123,8 @@ crawlforge stealth https://protected-site.com --engine camoufox --wait 3000 --sc
76
123
  ```
77
124
 
78
125
  The CLI exposes a one-shot form (`--engine`, `--wait <ms>`, `--screenshot`).
79
- Force the engine globally with `export CRAWLFORGE_STEALTH_ENGINE=camoufox`.
126
+ `--engine` takes `chromium` (the default here — the CLI has no `auto`) or
127
+ `camoufox`, so name `camoufox` on the command line when you want it.
80
128
 
81
129
  ## localization (cost: 2)
82
130
 
@@ -1,11 +1,29 @@
1
1
  # Stealth Engine Selection
2
2
 
3
- `stealth_mode` supports two browser engines via the `engine` parameter.
4
-
5
- ## playwright (default)
3
+ `stealth_mode` picks a browser engine with an `engine` parameter;
4
+ `browser_session` takes the same `engine` on `operation:"open"`,
5
+ `scrape_with_actions` takes it as `browserOptions.engine` (both only with
6
+ `stealth: true`), and `scrape` calls the same choice `escalate_engine`. All of
7
+ them default to `auto`.
8
+
9
+ ## auto (default)
10
+
11
+ - Camoufox when its binary is installed; Chromium otherwise, with the reason in
12
+ the result's `warnings[]`, so a machine that never ran `npx camoufox fetch`
13
+ still works. The result names the engine that ran — `stealth.engine` on
14
+ `scrape`, `engine` on the others — so check it instead of assuming.
15
+ - Naming an engine explicitly turns the fallback off: `camoufox` fails with its
16
+ install instruction instead of degrading, and `playwright` / `chromium` go
17
+ straight to Chromium.
18
+ - `chromium` and `playwright` are the same engine. Everything accepts both
19
+ spellings except `scrape`'s `escalate_engine`, which takes only `playwright`.
20
+
21
+ ## playwright
6
22
 
7
23
  - Chromium-based with stealth patches applied.
8
- - Masks `webdriver`, User-Agent, and navigator properties.
24
+ - Reports `navigator.webdriver` as `false` (not deleted — a missing property is
25
+ itself a marker), and sets the User-Agent, platform, languages and core count
26
+ through CDP, so a Web Worker answers the same as the page.
9
27
  - Lower resource usage, faster startup.
10
28
  - Good for most sites with basic bot detection.
11
29
 
@@ -13,21 +31,27 @@
13
31
 
14
32
  - Firefox-based with **native** anti-detection (no runtime patches — uses
15
33
  Firefox's genuine properties).
16
- - Scores higher on CreepJS and against DataDome than patched Chromium.
17
- - Heavier; install with `npm install camoufox`.
18
- - Use for advanced fingerprinting: financial, trading, and e-commerce sites.
34
+ - Scores higher on CreepJS and the other detector pages than patched Chromium,
35
+ and was the only engine to clear Cloudflare Turnstile (indeed.com) and Akamai
36
+ (harrods.com) in the 2026-09-21 benchmark.
37
+ - Heavier: measured once each on an Apple Silicon Mac, 946 ms and 667 MB to
38
+ launch a browser, a context and a blank page, against Chromium's 148 ms and
39
+ 253 MB.
40
+ - Install with `npm install camoufox`, then `npx camoufox fetch` for the binary.
41
+ - It does not defeat DataDome or an interactive Turnstile; in the same run
42
+ Chromium passed leboncoin.fr where Camoufox did not. One run per cell.
19
43
 
20
44
  ## Decision table
21
45
 
22
46
  | Scenario | Recommended engine |
23
47
  |----------|--------------------|
48
+ | You do not know whether the site blocks | auto |
24
49
  | General JS-rendered sites | playwright |
25
- | Basic bot-detection bypass | playwright |
26
- | Speed-critical scraping | playwright |
27
- | Cloudflare-protected sites | camoufox |
28
- | Sites with DataDome | camoufox |
50
+ | Speed-critical or high-volume scraping | playwright |
51
+ | Cloudflare Turnstile pages, Akamai | camoufox |
52
+ | Detector-score-sensitive work (CreepJS) | camoufox |
29
53
  | Sites with PerimeterX | camoufox |
30
- | Financial / trading sites | camoufox |
54
+ | Sites with DataDome | either — neither cleared it; try a residential proxy |
31
55
 
32
56
  ## stealthConfig levels
33
57
 
@@ -42,22 +66,43 @@
42
66
  - `randomizeFingerprint`, `hideWebDriver`, `blockWebRTC`, `spoofTimezone`,
43
67
  `randomizeHeaders`, `useRandomUserAgent`, `simulateHumanBehavior`.
44
68
  - `customUserAgent`, `customViewport {width,height}`, `locale`, `timezone`.
45
- - `proxyRotation { enabled, proxies[], rotationInterval }`.
69
+ - `proxyRotation { enabled, proxies[], rotationInterval }` — your own proxies,
70
+ per call. Server-wide, `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs of
71
+ the same form) covers the paths with no caller to ask: the `scrape` escalation
72
+ stage, `stealth_mode`, `browser_session`, `scrape_with_actions` and the
73
+ `deep_research` retry — not `agent`, which has no browsing path yet. A call's
74
+ own `proxyRotation` always wins. CrawlForge supplies no proxies, and a datacenter
75
+ proxy does not help — Cloudflare scores the ASN before it serves a challenge.
76
+ Unrelated to the `PROXY_ROTATION_*` variables, which belong to `localization`.
46
77
  - `antiDetection { cloudflareBypass, recaptchaHandling, hideAutomation,
47
78
  spoofMediaDevices, spoofBatteryAPI }`.
48
79
  - `fingerprinting { canvasNoise, webglSpoofing, audioContextSpoofing,
49
80
  fontSpoofing, hardwareSpoofing }`.
50
81
 
51
- ## Global override
82
+ On `camoufox`, `customUserAgent` and `customViewport` are not applied: that engine
83
+ brings its own Firefox identity, and overriding it from here put a Chrome identity
84
+ on a Gecko engine. `locale` is applied, but at browser launch rather than per
85
+ call — Camoufox sets language, Accept-Language and `Intl` below the JS layer,
86
+ where a Worker matches the document — so the first locale a Camoufox browser is
87
+ launched with holds until the browser is cleaned up, and behind a proxy the exit
88
+ IP decides it instead. The persona's OS is the host's and is not configurable
89
+ (on Camoufox that is a request its current client does not honour).
90
+
91
+ ## Environment
92
+
93
+ There is no global engine override — the engine is a per-call parameter. Two
94
+ things come close, and neither is global:
52
95
 
53
96
  ```bash
54
- export CRAWLFORGE_STEALTH_ENGINE=camoufox
97
+ export RESEARCH_STEALTH_ENGINE=camoufox # deep_research's blocked-source retry only
98
+ crawlforge stealth <url> --engine camoufox # the CLI, which defaults to chromium and has no auto
55
99
  ```
56
100
 
57
- Forces the engine for all stealth calls regardless of the `engine` parameter.
101
+ The one server-level stealth variable is `CRAWLFORGE_STEALTH_PROXIES` (above).
58
102
 
59
103
  ## Sandboxing note
60
104
 
61
- Stealth Chromium runs with `--no-sandbox` + `--disable-web-security` (a
62
- deliberate fingerprint-spoofing trade-off). Camoufox (Firefox) is the
63
- alternative when that trade-off is unacceptable.
105
+ Stealth Chromium runs with `--no-sandbox` (a deliberate fingerprint-spoofing
106
+ trade-off). It no longer runs with `--disable-web-security`, which a page could
107
+ read in one line; the non-stealth render browser still does. Camoufox (Firefox)
108
+ is the alternative when the `--no-sandbox` trade-off is unacceptable.
@@ -74,8 +74,7 @@ shell). The escalated page goes through the same formats, so `markdown`,
74
74
  "params": {
75
75
  "url": "https://www.producthunt.com/",
76
76
  "formats": ["markdown"],
77
- "escalate": true,
78
- "escalate_engine": "playwright"
77
+ "escalate": true
79
78
  }
80
79
  }
81
80
  ```
@@ -83,10 +82,17 @@ shell). The escalated page goes through the same formats, so `markdown`,
83
82
  The result adds `escalated: true|false`, and `stealth: { engine,
84
83
  vendor_detected }` when the browser ran. The projection is `2 + 5`; the charge
85
84
  falls back to the base when the plain fetch succeeded and nothing escalated.
86
- `escalate_engine` is `"playwright"` (default) or `"camoufox"`. robots.txt is
87
- respected on the escalated path too, and a second call to a host that blocked
88
- within the last 24 hours skips the doomed plain fetch and says so in
89
- `warnings[]`.
85
+ robots.txt is respected on the escalated path too, and a second call to a host
86
+ that blocked within the last 24 hours skips the doomed plain fetch and says so
87
+ in `warnings[]`.
88
+
89
+ `escalate_engine` defaults to `"auto"`: Camoufox (Firefox, the engine that gets
90
+ through Cloudflare Turnstile and Akamai) when its binary is installed, Chromium
91
+ with a warning when it is not. Pin `"playwright"` (the only spelling for
92
+ Chromium here — `"chromium"` is rejected) when the page just needs rendering and
93
+ you want the faster, lighter browser; pin `"camoufox"` to require the stronger
94
+ engine — it errors rather than falling back. `stealth.engine` in the result says
95
+ which one ran, and a fallback is reported in `warnings[]`.
90
96
 
91
97
  Query-scoped formats return only the parts of the page that match, verbatim,
92
98
  with offsets into the `markdown` of the same call:
@@ -41,6 +41,7 @@ import { internalOwnerToken, isInternalRequest } from '../../server/requestConte
41
41
  import { isRemoteTransport } from '../../utils/remoteMode.js';
42
42
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
43
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
44
+ import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
44
45
 
45
46
  const SECOND = 1000;
46
47
 
@@ -95,6 +96,11 @@ const BrowserSessionSchema = z.object({
95
96
  // from their docs reads the same numbers; the store works in milliseconds.
96
97
  url: z.string().url().optional(),
97
98
  stealth: z.boolean().default(false),
99
+ // Which stealth engine the session runs on. Only meaningful with stealth:true
100
+ // — the standard pool is Chromium and camoufox exists only on the stealth
101
+ // path — so "camoufox" without stealth is refused rather than quietly
102
+ // downgraded (see openSession).
103
+ engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto'),
98
104
  ttl: z.number().min(TTL_MIN_MS / SECOND).max(TTL_MAX_MS / SECOND).optional(),
99
105
  activity_ttl: z.number().min(ACTIVITY_TTL_MIN_MS / SECOND).max(ACTIVITY_TTL_MAX_MS / SECOND).optional(),
100
106
  viewport: z.object({
@@ -143,6 +149,11 @@ function sessionInfo(session) {
143
149
  sessionId: session.id,
144
150
  url: session.url,
145
151
  stealth: session.stealth,
152
+ // Which engine actually ran, read off the page BrowserProcessor stamped —
153
+ // not the engine that was asked for. With engine:"auto" those differ
154
+ // whenever camoufox is not installed, and the whole point of a resolved
155
+ // engine is that the caller can see which one it got.
156
+ ...(session.page?.__crawlforgeEngine ? { engine: session.page.__crawlforgeEngine } : {}),
146
157
  expiresAt: session.createdAt + session.ttlMs,
147
158
  idleExpiresAt: session.lastUsedAt + session.activityTtlMs
148
159
  };
@@ -181,7 +192,11 @@ function withJsResult(result) {
181
192
  * warnings describe the hop the caller just made, not every hop of the session.
182
193
  */
183
194
  function gateWarningFields(page) {
184
- const warnings = page?.__crawlforgeGateWarnings;
195
+ return warningFields(page?.__crawlforgeGateWarnings);
196
+ }
197
+
198
+ /** The same `warnings` field, for a list that is not only the gate's. */
199
+ function warningFields(warnings) {
185
200
  return warnings?.length ? { warnings } : {};
186
201
  }
187
202
 
@@ -288,6 +303,19 @@ export class BrowserSessionTool {
288
303
  throw new Error('operation "open" requires a url to load the session on.');
289
304
  }
290
305
 
306
+ // camoufox is the stealth browser's Firefox engine; the plain session runs
307
+ // on the shared Chromium pool, where there is nothing to switch. Refusing
308
+ // beats opening a session on the engine the caller did not ask for and
309
+ // saying so in a warning nobody reads. "auto"/"chromium"/"playwright" need
310
+ // no refusal: Chromium is what they would have got.
311
+ if (!params.stealth && params.engine === 'camoufox') {
312
+ throw refuse(
313
+ 'ENGINE_NEEDS_STEALTH',
314
+ 'engine:"camoufox" requires stealth:true — the Firefox anti-detect engine exists only on ' +
315
+ 'the stealth path. Open with stealth:true, or drop engine to use the standard Chromium browser.'
316
+ );
317
+ }
318
+
291
319
  const browserOptions = {
292
320
  headless: true,
293
321
  viewportWidth: params.viewport?.width,
@@ -296,8 +324,13 @@ export class BrowserSessionTool {
296
324
  respectRobots: params.respect_robots,
297
325
  tool: 'browser_session'
298
326
  };
327
+ // Resolved here rather than downstream so an "auto" that lands on Chromium
328
+ // because camoufox is absent can be said out loud in this call's result.
329
+ let engineWarning = null;
299
330
  if (params.stealth) {
300
- browserOptions.stealthMode = { enabled: true };
331
+ const resolved = await resolveStealthEngine(params.engine);
332
+ engineWarning = resolved.fallbackWarning;
333
+ browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
301
334
  }
302
335
 
303
336
  // initializePage runs the SSRF guard, then the blocklist/robots gate, and
@@ -341,7 +374,9 @@ export class BrowserSessionTool {
341
374
  operation: 'open',
342
375
  ...sessionInfo(session),
343
376
  ...verdictFields(verdict),
344
- ...gateWarningFields(page),
377
+ // One warnings array: the engine fallback rides with the gate's, or it
378
+ // would overwrite them (both spread a `warnings` key).
379
+ ...warningFields([...(engineWarning ? [engineWarning] : []), ...(page?.__crawlforgeGateWarnings || [])]),
345
380
  // The page is a wall, but the session behind it is real and holds a
346
381
  // browser context — say so, or a caller reading only `success` abandons
347
382
  // it to its TTL instead of closing it or acting through the challenge.
@@ -14,6 +14,7 @@ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
14
14
  import { elementText } from '../../utils/elementText.js';
15
15
  import { pageTitle } from '../../utils/pageTitle.js';
16
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
+ import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
17
18
 
18
19
  // Recording / replay helpers
19
20
  import {
@@ -192,7 +193,12 @@ const ScrapeWithActionsSchema = z.object({
192
193
  // Run the chain in the stealth browser (StealthBrowserManager) instead of
193
194
  // the standard pool. executeSession turns this into the stealthMode object
194
195
  // ActionExecutor/BrowserProcessor read.
195
- stealth: z.boolean().default(false)
196
+ stealth: z.boolean().default(false),
197
+ // Which stealth engine the chain runs on. Only meaningful with stealth:true
198
+ // — the standard pool is Chromium and camoufox exists only on the stealth
199
+ // path — so "camoufox" without stealth is refused rather than quietly
200
+ // downgraded (see executeSession).
201
+ engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto')
196
202
  }).optional(),
197
203
 
198
204
  // Content extraction options
@@ -394,8 +400,26 @@ export class ScrapeWithActionsTool extends EventEmitter {
394
400
 
395
401
  // `stealth: true` is the caller-facing switch; everything downstream reads
396
402
  // browserOptions.stealthMode.
403
+ //
404
+ // The engine is resolved here, not downstream, so an "auto" that lands on
405
+ // Chromium because camoufox is absent can be reported in this call's
406
+ // result instead of disappearing.
407
+ const warnings = [];
397
408
  if (browserOptions.stealth) {
398
- browserOptions.stealthMode = { enabled: true };
409
+ const resolved = await resolveStealthEngine(browserOptions.engine);
410
+ browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
411
+ if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
412
+ } else if (browserOptions.engine === 'camoufox') {
413
+ // camoufox is the stealth browser's Firefox engine; a non-stealth chain
414
+ // runs on the shared Chromium pool, where there is nothing to switch.
415
+ // Refusing beats running the chain on the engine the caller did not ask
416
+ // for. "auto"/"chromium"/"playwright" need no refusal: Chromium is what
417
+ // they would have got.
418
+ throw new Error(
419
+ 'browserOptions.engine:"camoufox" requires browserOptions.stealth:true — the Firefox ' +
420
+ 'anti-detect engine exists only on the stealth path. Set stealth:true, or drop engine ' +
421
+ 'to use the standard Chromium browser.'
422
+ );
399
423
  }
400
424
 
401
425
  // The robots override rides with the browser options so ActionExecutor's
@@ -497,6 +521,10 @@ export class ScrapeWithActionsTool extends EventEmitter {
497
521
  error: chainResult.error || verdict.error || undefined,
498
522
  ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
499
523
  ...(verdict.status !== null ? { httpStatus: verdict.status } : {}),
524
+ // The engine the chain actually ran on, and anything to say about how it
525
+ // was chosen (an "auto" that fell back to Chromium says so here).
526
+ engine: browserOptions.stealthMode?.engine || 'chromium',
527
+ ...(warnings.length ? { warnings } : {}),
500
528
 
501
529
  actionResults,
502
530
  totalActions: params.actions.length,
@@ -21,7 +21,12 @@ export const SCRAPE_ESCALATION_CREDITS = 5;
21
21
  /** The two public fields, in tools/list order, with the text the client sees. */
22
22
  export const SCRAPE_ESCALATION_SHAPE = {
23
23
  escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
24
- escalate_engine: z.enum(['playwright', 'camoufox']).optional().default('playwright').describe('Stealth engine for the escalated retry (default: "playwright")')
24
+ // "auto" is the default because the engine that gets past the walls this
25
+ // stage exists for is camoufox, and asking for it by name used to be the
26
+ // caller's problem. It resolves to camoufox when the package is installed
27
+ // and to playwright (Chromium) when it is not, saying so in `warnings`.
28
+ // Naming an engine still pins it exactly.
29
+ escalate_engine: z.enum(['auto', 'playwright', 'camoufox']).optional().default('auto').describe('Stealth engine for the escalated retry: "auto" (default — Camoufox when it is installed, Chromium otherwise, reported in warnings), "playwright" (Chromium) or "camoufox"')
25
30
  };
26
31
 
27
32
  /**
@@ -15,7 +15,8 @@
15
15
 
16
16
  import { z } from 'zod';
17
17
  import { load } from 'cheerio';
18
- import { documentVerdict, segmentUnits, rankUnits } from 'crawlforge-extractors';
18
+ import { segmentUnits, rankUnits } from 'crawlforge-extractors';
19
+ import { stealthDocumentVerdict as documentVerdict } from '../../utils/stealthVerdict.js';
19
20
  import {
20
21
  SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
21
22
  scrapeFormatSurcharge
@@ -1,10 +1,93 @@
1
1
  /**
2
- * challengeDetection.js — re-export of the challenge-vendor tables.
2
+ * challengeDetection.js — the challenge-vendor tables, refined for widgets.
3
3
  *
4
4
  * The tables moved to crawlforge-extractors 1.7.0 so the REST `scrape` route
5
5
  * reaches the same verdict as the MCP server (Phase 0, 0.1). Callers keep
6
6
  * importing from here.
7
+ *
8
+ * Upstream treats a vendor marker on a page under 4000 visible characters as
9
+ * definitive, which reports a block on a real page that merely embeds a
10
+ * Turnstile widget: quora.com rendered its real 397-character login page on
11
+ * both engines and the verdict layer still called it blocked (2026-09-21
12
+ * benchmark, finding 6). A widget embed and an interstitial are separable by
13
+ * what else is in the document, not by length — the interstitial carries
14
+ * Cloudflare's challenge bootstrap or the prose it shows while it holds the
15
+ * request, and a page that merely embeds the widget carries neither. So the
16
+ * refinement below suppresses that one case and leaves every other path
17
+ * upstream owns (a challenge title, Amazon's validateCaptcha form, DataDome,
18
+ * PerimeterX, Akamai) exactly as it is. Worth upstreaming.
19
+ */
20
+
21
+ import { detectChallengePage as detectUpstream } from 'crawlforge-extractors';
22
+
23
+ // The bootstrap a real interstitial ships and a page embedding a widget never
24
+ // does: Cloudflare's challenge options blob and its cf-chl- element ids,
25
+ // Vercel's checkpoint script and token. Two Cloudflare tokens are deliberately
26
+ // NOT here, because a real page legitimately carries both:
27
+ // - challenges.cloudflare.com, the widget's own script host;
28
+ // - cf-chl-widget-*, the hidden response input Turnstile names itself.
29
+ // quora.com's login page carries exactly one cf-chl- token and it is
30
+ // "cf-chl-widget-gaztz_response" (measured 2026-09-21), so a bare cf-chl- test
31
+ // matches every widget embed there is. Hence the lookahead — do not put
32
+ // cf-chl- back.
33
+ const INTERSTITIAL_MARKERS = [
34
+ { vendor: 'cloudflare', markers: /_cf_chl_opt|window\._cf_chl|cf-chl-(?!widget)|cf_chl_rc_/i, evidence: 'the Cloudflare challenge bootstrap' },
35
+ { vendor: 'vercel', markers: /_vercel\/challenge|vercel\.link\/security-checkpoint|x-vercel-challenge-token/i, evidence: 'the Vercel Security Checkpoint bootstrap' }
36
+ ];
37
+
38
+ // What an interstitial says to the reader while it holds the request.
39
+ const INTERSTITIAL_PROSE = /verifying you are human|checking your browser|needs to review the security of your connection|enable javascript and cookies to continue/i;
40
+
41
+ // The floor for "this document has a real body". quora.com's real login page
42
+ // is 397 visible characters, so the floor has to sit below that; Cloudflare's
43
+ // own interstitial renders well under 200 characters of prose ("Verifying you
44
+ // are human…", "Enable JavaScript and cookies to continue"), so a genuinely
45
+ // empty wall carrying only a widget marker is still short enough to be caught.
46
+ const REAL_BODY_MIN_CHARS = 200;
47
+
48
+ /**
49
+ * Whether a document is a challenge wall rather than the page: a known
50
+ * challenge title OR a challenge bootstrap marker, regardless of the title and
51
+ * regardless of page length, so a custom-titled interstitial (nowsecure.nl,
52
+ * whose title is just "nowsecure.nl") is recognised too.
53
+ *
54
+ * @param {{ title?: string, html?: string, text?: string }} page
55
+ * @returns {{ vendor: string, evidence: string } | null}
56
+ */
57
+ export function looksLikeInterstitial(page = {}) {
58
+ const { title = '', html = '' } = page;
59
+ // A title on its own reaches only upstream's title branch — its marker
60
+ // branch needs html and its definitive branch a form — so the challenge
61
+ // titles stay in one place rather than being copied here.
62
+ const byTitle = detectUpstream({ title });
63
+ if (byTitle) return byTitle;
64
+ for (const { vendor, markers, evidence } of INTERSTITIAL_MARKERS) {
65
+ if (markers.test(html)) return { vendor, evidence };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** A title, a body a reader could read, and none of the interstitial's prose. */
71
+ function looksLikeRealPage({ title = '', text = '' }) {
72
+ if (!String(title).trim()) return false;
73
+ const visible = String(text).replace(/\s+/g, ' ').trim();
74
+ if (visible.length < REAL_BODY_MIN_CHARS) return false;
75
+ return !INTERSTITIAL_PROSE.test(`${title} ${visible}`);
76
+ }
77
+
78
+ /**
79
+ * @param {{ title?: string, html?: string, text?: string }} page
80
+ * @returns {{ vendor: string, evidence: string } | null}
7
81
  */
82
+ export function detectChallengePage(page = {}) {
83
+ const hit = detectUpstream(page);
84
+ // Cloudflare is the only vendor whose markers a real page can carry, so it is
85
+ // the only one refined. Reaching here with a cloudflare hit that is not an
86
+ // interstitial means the evidence was the widget's own script host or
87
+ // response input on a short page.
88
+ if (!hit || hit.vendor !== 'cloudflare') return hit;
89
+ if (looksLikeInterstitial(page)) return hit;
90
+ return looksLikeRealPage(page) ? null : hit;
91
+ }
8
92
 
9
- export { detectChallengePage } from 'crawlforge-extractors';
10
- export { detectChallengePage as default } from 'crawlforge-extractors';
93
+ export default detectChallengePage;