crawlforge-mcp-server 6.7.0 → 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.
- package/CLAUDE.md +18 -2
- package/README.md +20 -2
- package/package.json +3 -1
- package/server.js +36 -15
- package/src/constants/config.js +22 -0
- package/src/core/ResearchOrchestrator.js +72 -2
- package/src/core/StealthBrowserManager.js +852 -250
- package/src/core/creatorMode.js +1 -1
- package/src/core/processing/BrowserProcessor.js +30 -4
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +35 -11
- package/src/skills/agent-skills/crawlforge-stealth-browsing/references/engine-selection.md +64 -19
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +12 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -3
- package/src/tools/advanced/ScrapeWithActionsTool.js +30 -2
- package/src/tools/scrape/escalation.js +6 -1
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +86 -3
- package/src/utils/stealthVerdict.js +36 -2
package/src/core/creatorMode.js
CHANGED
|
@@ -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 = '
|
|
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
|
-
|
|
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 (
|
|
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,21 +57,45 @@ 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
|
|
60
|
+
### Engine: auto, playwright, camoufox
|
|
62
61
|
|
|
63
|
-
- `engine:"
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
|
71
87
|
### Proxies
|
|
72
88
|
|
|
73
89
|
A block that survives both engines is usually the IP, not the fingerprint:
|
|
74
|
-
Cloudflare scores the address and its ASN before it serves a challenge
|
|
75
|
-
|
|
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:
|
|
76
99
|
|
|
77
100
|
```json
|
|
78
101
|
{
|
|
@@ -100,7 +123,8 @@ crawlforge stealth https://protected-site.com --engine camoufox --wait 3000 --sc
|
|
|
100
123
|
```
|
|
101
124
|
|
|
102
125
|
The CLI exposes a one-shot form (`--engine`, `--wait <ms>`, `--screenshot`).
|
|
103
|
-
|
|
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.
|
|
104
128
|
|
|
105
129
|
## localization (cost: 2)
|
|
106
130
|
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
# Stealth Engine Selection
|
|
2
2
|
|
|
3
|
-
`stealth_mode`
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 —
|
|
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
|
|
10
|
-
export { detectChallengePage as default } from 'crawlforge-extractors';
|
|
93
|
+
export default detectChallengePage;
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* stealthVerdict.js —
|
|
2
|
+
* stealthVerdict.js — the document verdict, with the widget refinement.
|
|
3
3
|
*
|
|
4
4
|
* `stealthDocumentVerdict` moved to crawlforge-extractors 1.7.0 as
|
|
5
5
|
* `documentVerdict` — the same behaviour and messages for the stealth callers,
|
|
6
6
|
* plus a `fetcher` option so the plain `scrape` path on both surfaces reaches
|
|
7
7
|
* the same verdict (Phase 0, 0.1). Callers keep importing from here.
|
|
8
|
+
*
|
|
9
|
+
* Upstream decides the block before every other rule it owns, so the widget
|
|
10
|
+
* refinement in challengeDetection.js would never reach the verdict the tools
|
|
11
|
+
* and the benchmark actually print. The wrapper below re-runs upstream on the
|
|
12
|
+
* same document with the widget marker neutralised whenever the refinement
|
|
13
|
+
* clears the block, so an HTTP error page, an empty shell or a short
|
|
14
|
+
* error-titled placeholder that happens to embed a widget is still named by
|
|
15
|
+
* upstream and still decides (Phase 1, finding 6).
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
import { documentVerdict, SOFT_ERROR_MAX_CHARS } from 'crawlforge-extractors';
|
|
19
|
+
import { detectChallengePage } from './challengeDetection.js';
|
|
20
|
+
|
|
21
|
+
export { SOFT_ERROR_MAX_CHARS };
|
|
22
|
+
|
|
23
|
+
// The two Turnstile markers challengeDetection.js clears: the widget's script
|
|
24
|
+
// host, and the hidden response input the widget names itself
|
|
25
|
+
// (id="cf-chl-widget-<id>_response" — quora.com, measured 2026-09-21).
|
|
26
|
+
// Upstream's Cloudflare pattern matches a bare cf-chl-, so neutralising the
|
|
27
|
+
// host alone left the re-run blocking the same document a second time. Both
|
|
28
|
+
// are swapped for inert text; the bootstrap markers are never touched, so a
|
|
29
|
+
// document that carries one is still upstream's block to make.
|
|
30
|
+
const TURNSTILE_WIDGET = /challenges\.cloudflare\.com|cf-chl-widget/gi;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
34
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean, fetcher?: string, rendered?: boolean, contentReturned?: boolean }} [options]
|
|
35
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
36
|
+
*/
|
|
37
|
+
export function stealthDocumentVerdict(scraped, options) {
|
|
38
|
+
const verdict = documentVerdict(scraped, options);
|
|
39
|
+
if (!verdict.blocked || detectChallengePage(scraped || {})) return verdict;
|
|
40
|
+
// The neutralised copy is read by upstream and discarded; the caller's own
|
|
41
|
+
// document is never rewritten.
|
|
42
|
+
const html = String(scraped?.html || '').replace(TURNSTILE_WIDGET, 'turnstile-widget');
|
|
43
|
+
return documentVerdict({ ...scraped, html }, options);
|
|
44
|
+
}
|