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/CLAUDE.md
CHANGED
|
@@ -141,7 +141,7 @@ npm run docker:prod # Run production container
|
|
|
141
141
|
- **ActionExecutor**: Browser automation engine (Playwright-based)
|
|
142
142
|
- **ResearchOrchestrator**: Multi-stage research with query expansion and synthesis
|
|
143
143
|
- **AgentOrchestrator**: Powers the `agent` tool — NL prompt → autonomous PLAN→GATHER→ACT→DECIDE→SHAPE loop with three orchestrator-enforced hard stops (maxSteps≤10, maxUrls≤20, wall-clock) never delegated to the LLM; degraded no-LLM-key path (D2, v4.6.0)
|
|
144
|
-
- **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0
|
|
144
|
+
- **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0. Engine selection goes through the one resolver `resolveStealthEngine(requested)` → `{engine, fallbackWarning}`; `'auto'` (the default for `scrape.escalate_engine` and `stealth_mode.engine` since the 2026-09 stealth review Phase 2) prefers Camoufox and falls back to Chromium with a caller-visible warning when its binary is absent. `'playwright'` is the tool layer's public name for Chromium. Camoufox personas are pinned to the installed binary's Firefox major so the UA's `rv:` and `Firefox/` tokens agree — see `docs/STEALTH_REVIEW_2026-09.md`
|
|
145
145
|
- **LocalizationManager**: Multi-language content and localization
|
|
146
146
|
- **ChangeTracker**: Content change tracking over time
|
|
147
147
|
- **SnapshotManager**: Website snapshots and version history
|
|
@@ -221,8 +221,24 @@ MAX_PAGES_PER_CRAWL=100
|
|
|
221
221
|
RESPECT_ROBOTS_TXT=true
|
|
222
222
|
ROBOTS_CACHE_TTL_MS=3600000 # how long a parsed robots.txt stays good for
|
|
223
223
|
CRAWLFORGE_BLOCKED_HOSTS= # comma-separated; extends the permanent opt-out blocklist
|
|
224
|
+
CRAWLFORGE_STEALTH_ENGINE= # which engine `auto` resolves to on THIS deployment:
|
|
225
|
+
# chromium|playwright pins it to Chromium; unset or anything
|
|
226
|
+
# else keeps the default preference for Camoufox. A caller
|
|
227
|
+
# naming an engine always wins. There is no correct global
|
|
228
|
+
# value — which engine clears a wall depends on the exit IP
|
|
229
|
+
# (residential favours Camoufox, the hosted datacenter IP
|
|
230
|
+
# favours Chromium, measured twice).
|
|
231
|
+
CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the escalation stage,
|
|
232
|
+
# stealth_mode, browser_session, scrape_with_actions and the
|
|
233
|
+
# deep_research fallback when the caller passes none (a proxy
|
|
234
|
+
# passed on the call wins). CrawlForge supplies no proxies, and
|
|
235
|
+
# a datacenter one does not defeat these detectors. Distinct
|
|
236
|
+
# from the PROXY_ROTATION_* family, which belongs to
|
|
237
|
+
# localization. The agent tool does not browse yet (Phase 3).
|
|
224
238
|
```
|
|
225
239
|
|
|
240
|
+
Camoufox is an **optional** dependency and its transitive `language-tags@2.1.0` requires Node ≥22. npm silently drops an optional subtree that fails an engine check, so on Node 20 it is simply absent — `'auto'` then resolves to Chromium and says so in `fallbackWarning`. `engines.node` stays at `>=20.16.0` deliberately; bumping it is breaking and is a release decision.
|
|
241
|
+
|
|
226
242
|
Web Bot Auth (optional, off by default — requests go out unsigned when unset):
|
|
227
243
|
|
|
228
244
|
```bash
|
|
@@ -260,7 +276,7 @@ Key mechanisms for security-conscious future sessions:
|
|
|
260
276
|
- **endpointGuard** (`src/core/endpointGuard.js`): Hard allow-list of `{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}` for the server's own backend calls; HTTPS required; fail-closed. Localhost only in creator mode (v3.0.18).
|
|
261
277
|
- **Action allowlist** (`src/core/ActionExecutor.js`): `scrape_with_actions` accepts only 7 action types: `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`. `executeJavaScript` throws unless `ALLOW_JAVASCRIPT_EXECUTION=true` is set at deploy time (off by default).
|
|
262
278
|
- **Elicitation** (`src/core/ElicitationHelper.js`): User confirmation requested for `deep_research` (>50 URLs), `batch_scrape` (sync, >25 URLs), `crawl_deep` (projected >500 pages), `extract_structured` (schema has >3 required fields, no LLM configured), and credit-low situations. Fail-open if client does not support elicitation.
|
|
263
|
-
- **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox`
|
|
279
|
+
- **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox` (deliberate fingerprint-spoofing trade-off); `--disable-web-security` was removed from it in Phase 1 of the 2026-09 stealth review — a page reads it in one line — but the non-stealth render browser (`BrowserProcessor`) still carries both flags. Camoufox (Firefox, v4.0.0) is the alternative — see `docs/stealth-engines.md`.
|
|
264
280
|
|
|
265
281
|
See `docs/sandboxing-and-approvals.md` for the full reference.
|
|
266
282
|
|
package/README.md
CHANGED
|
@@ -267,6 +267,12 @@ export CRAWLFORGE_TOOL_GROUPS="basic,search,scrape" # unset = all tools; unkno
|
|
|
267
267
|
export RESEARCH_STEALTH_ENGINE="auto" # auto (default) | camoufox | chromium
|
|
268
268
|
export RESEARCH_STEALTH_FALLBACK="true" # set to "false" to disable entirely
|
|
269
269
|
export RESEARCH_MAX_STEALTH_RETRIES="8" # cap on stealth retries per research run
|
|
270
|
+
|
|
271
|
+
# Optional: your own proxies for the stealth paths that have no caller to ask —
|
|
272
|
+
# the scrape escalation stage, the deep_research retry, the agent, browser_session.
|
|
273
|
+
# Comma-separated; a proxy passed on a call always wins. CrawlForge supplies none.
|
|
274
|
+
export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
|
|
275
|
+
# Unrelated to the PROXY_ROTATION_* variables, which belong to the localization tool.
|
|
270
276
|
```
|
|
271
277
|
|
|
272
278
|
### MCP Spec Features
|
|
@@ -297,6 +303,18 @@ ollama pull llama3.2
|
|
|
297
303
|
# extract_with_llm({ url: "https://example.com", prompt: "…", model: "llama3.2" })
|
|
298
304
|
```
|
|
299
305
|
|
|
306
|
+
### Stealth engines and proxies
|
|
307
|
+
|
|
308
|
+
Every stealth entry point — `scrape` with `escalate: true`, `stealth_mode`, `browser_session`, `scrape_with_actions` and the `deep_research` retry — now defaults to **`auto`**: use Camoufox (Firefox anti-detect) when its binary is installed, fall back to Chromium when it is not, with the reason in the result's `warnings[]`. Every result names the engine that actually ran. Naming an engine explicitly behaves as it always has — `camoufox` fails rather than falling back, and `chromium` and `playwright` are the same engine (on `scrape`'s `escalate_engine`, spell it `playwright`).
|
|
309
|
+
|
|
310
|
+
On `browser_session` and `scrape_with_actions` the engine applies only with `stealth: true` — their ordinary path is the standard Chromium pool.
|
|
311
|
+
|
|
312
|
+
`auto` is not free. Measured once each on an Apple Silicon Mac on 2026-09-21 (launch plus a context and a blank page): Chromium 148 ms and 253 MB, Camoufox 946 ms and 667 MB — roughly **+0.8 s and +400 MB per stealth call**. Pin `engine: "playwright"` for high-volume work on sites that do not block.
|
|
313
|
+
|
|
314
|
+
**CrawlForge supplies no proxies**, and a datacenter proxy does not fix a block: Cloudflare scores the IP's ASN and the TLS/HTTP2 handshake before any JavaScript runs, so no browser-side patch compensates for a datacenter address. Bring your own residential exit with `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs), which the escalation stage, `stealth_mode`, `browser_session`, `scrape_with_actions` and the `deep_research` retry use when the caller passes none; a proxy passed on the call always wins. (The `agent` tool does not browse yet, so it is not in that list — it joins when Phase 3 gives it an escalation path.) With a proxy, Camoufox derives its timezone, locale and geolocation from the exit IP.
|
|
315
|
+
|
|
316
|
+
Full detail, measurements and sources: [docs/stealth-engines.md](docs/stealth-engines.md).
|
|
317
|
+
|
|
300
318
|
### Stealth extraction for `deep_research` (Camoufox)
|
|
301
319
|
|
|
302
320
|
`deep_research` automatically retries sources that block the normal fetch path (Reddit, Quora, forums, and Cloudflare/DataDome-protected pages return HTTP 403) through a **real fingerprinted browser**, then re-extracts from the rendered HTML. It's bounded (`RESEARCH_MAX_STEALTH_RETRIES`, default 8, plus a per-page timeout) and lazy — the browser stack only loads when a source is actually blocked.
|
|
@@ -307,7 +325,7 @@ Engine selection (`RESEARCH_STEALTH_ENGINE`):
|
|
|
307
325
|
- **`camoufox`** — force Camoufox.
|
|
308
326
|
- **`chromium`** — force the Chromium stealth engine.
|
|
309
327
|
|
|
310
|
-
|
|
328
|
+
Camoufox gets through walls that stop headless Chromium: in a benchmark run on 2026-09-21 from a residential IP it was the only engine to clear Cloudflare Turnstile (indeed.com) and Akamai (harrods.com), both of which blocked Chromium stealth. Neither engine cleared DataDome or an interactive Turnstile, so this is a better fetch path, not a bypass. To enable it, install the optional dependency and run its one-time binary fetch:
|
|
311
329
|
|
|
312
330
|
```bash
|
|
313
331
|
# Camoufox is declared as an optional dependency, so a normal install already pulls it.
|
|
@@ -320,7 +338,7 @@ npx camoufox fetch
|
|
|
320
338
|
|
|
321
339
|
Without the Camoufox binary, `deep_research` silently falls back to Chromium stealth and then to plain fetch — no errors, just lower recovery on heavily-protected sites. Disable the whole fallback with `RESEARCH_STEALTH_FALLBACK=false`.
|
|
322
340
|
|
|
323
|
-
> **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide
|
|
341
|
+
> **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide — point the server at your own with `CRAWLFORGE_STEALTH_PROXIES`. See [docs/stealth-engines.md](docs/stealth-engines.md) for details.
|
|
324
342
|
|
|
325
343
|
### Manual Configuration
|
|
326
344
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.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",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"docker:build": "docker build -t crawlforge .",
|
|
29
29
|
"docker:dev": "docker-compose up crawlforge-dev",
|
|
30
30
|
"docker:prod": "docker-compose up crawlforge-prod",
|
|
31
|
+
"bench:stealth": "node scripts/stealth-bench.mjs",
|
|
32
|
+
"bench:stealth:ci": "node scripts/stealth-bench.mjs --ci",
|
|
31
33
|
"eval:selection": "node scripts/tool-selection-eval.mjs",
|
|
32
34
|
"usage:report": "node scripts/tool-usage-report.mjs"
|
|
33
35
|
},
|
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 } from "./src/core/StealthBrowserManager.js";
|
|
32
|
+
import { StealthBrowserManager, resolveStealthEngine } 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";
|
|
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
108
108
|
// Create the server
|
|
109
109
|
const server = new McpServer({
|
|
110
110
|
name: "crawlforge",
|
|
111
|
-
version: "6.
|
|
111
|
+
version: "6.8.0",
|
|
112
112
|
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.",
|
|
113
113
|
homepage: "https://www.crawlforge.dev",
|
|
114
114
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -249,14 +249,23 @@ const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
|
249
249
|
// the tool module never imports StealthBrowserManager (that would pull a
|
|
250
250
|
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
251
251
|
// and same browser the stealth_mode tool drives — no new evasion, and the
|
|
252
|
-
//
|
|
252
|
+
// engine name is resolved here, beside its sibling, by the one resolver
|
|
253
|
+
// every stealth entry point shares.
|
|
253
254
|
escalateScrape: async ({ url, engine, respectRobots }) => {
|
|
254
255
|
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
256
|
+
// "auto" prefers camoufox and falls back to chromium when its binary is
|
|
257
|
+
// missing. Mapping here instead ("camoufox" or else chromium) collapsed
|
|
258
|
+
// auto to chromium and hid the fallback; the resolver says which engine it
|
|
259
|
+
// landed on and why, and the caller is told.
|
|
260
|
+
const resolved = await resolveStealthEngine(engine);
|
|
261
|
+
if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
|
|
255
262
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
256
263
|
url,
|
|
257
|
-
engine: engine
|
|
264
|
+
engine: resolved.engine
|
|
258
265
|
});
|
|
259
|
-
|
|
266
|
+
// The RESOLVED engine: `scrape` reports it as stealth.engine, where the
|
|
267
|
+
// requested "auto" would tell the caller nothing about what ran.
|
|
268
|
+
return { ...scraped, engine: resolved.engine, warnings };
|
|
260
269
|
}
|
|
261
270
|
});
|
|
262
271
|
const agentTool = new AgentTool(); // D4 D2
|
|
@@ -913,7 +922,7 @@ registerToolIfEnabled("read_result", {
|
|
|
913
922
|
|
|
914
923
|
// Tool: scrape_with_actions
|
|
915
924
|
registerToolIfEnabled("scrape_with_actions", {
|
|
916
|
-
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, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser. 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\"}]})",
|
|
925
|
+
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, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. 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\"}]})",
|
|
917
926
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
918
927
|
inputSchema: {
|
|
919
928
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -985,7 +994,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
985
994
|
viewportWidth: z.number().min(800).max(1920).default(1280),
|
|
986
995
|
viewportHeight: z.number().min(600).max(1080).default(720),
|
|
987
996
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
988
|
-
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.")
|
|
997
|
+
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."),
|
|
998
|
+
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.")
|
|
989
999
|
}).optional().describe("Browser configuration options"),
|
|
990
1000
|
extractionOptions: z.object({
|
|
991
1001
|
selectors: z.record(z.string()).optional(),
|
|
@@ -1028,13 +1038,14 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1028
1038
|
|
|
1029
1039
|
// Tool: browser_session
|
|
1030
1040
|
registerToolIfEnabled("browser_session", {
|
|
1031
|
-
description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
|
|
1041
|
+
description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
|
|
1032
1042
|
annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1033
1043
|
inputSchema: {
|
|
1034
1044
|
operation: z.enum(["open", "snapshot", "act", "read", "screenshot", "close", "list"]).describe("open a session, observe it, act on it, read it, or close it"),
|
|
1035
1045
|
session_id: z.string().optional().describe("The id returned by operation:\"open\". Required by every operation except open and list"),
|
|
1036
1046
|
url: z.string().url().optional().describe("open: the URL to load the session on"),
|
|
1037
1047
|
stealth: z.boolean().default(false).describe("open: run the session in the stealth browser"),
|
|
1048
|
+
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("open: stealth engine for the session, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; every operation echoes the `engine` that actually 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."),
|
|
1038
1049
|
ttl: z.number().min(30).max(3600).optional().describe("open: seconds the session may live at most (default 600)"),
|
|
1039
1050
|
activity_ttl: z.number().min(10).max(3600).optional().describe("open: seconds the session may sit idle (default 300)"),
|
|
1040
1051
|
viewport: z.object({
|
|
@@ -1337,7 +1348,7 @@ function stealthScrapeFormats(formats, scraped) {
|
|
|
1337
1348
|
|
|
1338
1349
|
// Tool: stealth_mode
|
|
1339
1350
|
registerToolIfEnabled("stealth_mode", {
|
|
1340
|
-
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a
|
|
1351
|
+
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context -> create_page -> cleanup operations remain for multi-step work. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, enable, disable, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
|
|
1341
1352
|
annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1342
1353
|
inputSchema: {
|
|
1343
1354
|
operation: z.enum(['scrape', 'configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
|
|
@@ -1379,7 +1390,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1379
1390
|
hardwareSpoofing: z.boolean().default(true)
|
|
1380
1391
|
}).optional()
|
|
1381
1392
|
}).optional().describe("Stealth browser configuration with anti-detection settings"),
|
|
1382
|
-
engine: z.enum(["
|
|
1393
|
+
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).optional().default("auto").describe("Browser engine: \"auto\" (default — camoufox when it is installed, Chromium otherwise, and the result says which), \"camoufox\" (Firefox-based, higher anti-detect score; fails if not installed), or \"chromium\" (\"playwright\" is the same engine under its old name)"),
|
|
1383
1394
|
contextId: z.string().optional().describe("Browser context ID for page operations"),
|
|
1384
1395
|
urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
|
|
1385
1396
|
url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
|
|
@@ -1399,12 +1410,16 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1399
1410
|
// Gate first: a disallowed URL must never launch a browser.
|
|
1400
1411
|
const warnings = await stealthComplianceGate(url, respect_robots);
|
|
1401
1412
|
|
|
1413
|
+
// "auto" prefers camoufox and falls back to chromium when its binary
|
|
1414
|
+
// is missing; "playwright" is this tool's old public name for chromium.
|
|
1415
|
+
// The resolver owns both, and reports a fallback the caller can see.
|
|
1416
|
+
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1417
|
+
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1418
|
+
|
|
1402
1419
|
const wantsScreenshot = formats.includes('screenshot');
|
|
1403
1420
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
1404
1421
|
url,
|
|
1405
|
-
|
|
1406
|
-
// (the manager and the CLI both call it chromium).
|
|
1407
|
-
engine: engine === 'camoufox' ? 'camoufox' : 'chromium',
|
|
1422
|
+
engine: resolvedEngine.engine,
|
|
1408
1423
|
wait_for: wait_for || 0,
|
|
1409
1424
|
screenshot: wantsScreenshot,
|
|
1410
1425
|
stealthConfig
|
|
@@ -1421,6 +1436,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1421
1436
|
url: scraped.url,
|
|
1422
1437
|
title: scraped.title,
|
|
1423
1438
|
status: verdict.status,
|
|
1439
|
+
engine: resolvedEngine.engine,
|
|
1424
1440
|
content: stealthScrapeFormats(formats, scraped)
|
|
1425
1441
|
};
|
|
1426
1442
|
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
@@ -1457,10 +1473,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1457
1473
|
case 'create_context': {
|
|
1458
1474
|
// Forward the tool-level engine the same way operation:"scrape" does —
|
|
1459
1475
|
// without it, create_context always ran on chromium whatever the caller
|
|
1460
|
-
// asked for
|
|
1476
|
+
// asked for. Resolved, so "auto" reaches the manager as a real engine.
|
|
1477
|
+
const contextEngine = await resolveStealthEngine(engine);
|
|
1461
1478
|
const contextData = await stealthBrowserManager.createStealthContext({
|
|
1462
1479
|
...(stealthConfig || {}),
|
|
1463
|
-
engine: engine
|
|
1480
|
+
engine: contextEngine.engine
|
|
1464
1481
|
});
|
|
1465
1482
|
// The full fingerprint is ~4 KB of canvas noise arrays and WebGL
|
|
1466
1483
|
// extension lists no caller acts on. Summarise by default; verbose:true
|
|
@@ -1468,6 +1485,10 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1468
1485
|
result = {
|
|
1469
1486
|
contextId: contextData.contextId,
|
|
1470
1487
|
created: true,
|
|
1488
|
+
// Which engine the context is on, and why, when "auto" had to fall
|
|
1489
|
+
// back: every page made from it inherits that engine.
|
|
1490
|
+
engine: contextEngine.engine,
|
|
1491
|
+
...(contextEngine.fallbackWarning ? { warnings: [contextEngine.fallbackWarning] } : {}),
|
|
1471
1492
|
fingerprint: verbose
|
|
1472
1493
|
? contextData.fingerprint
|
|
1473
1494
|
: stealthBrowserManager.summarizeFingerprint(contextData.fingerprint)
|
package/src/constants/config.js
CHANGED
|
@@ -454,6 +454,28 @@ export function isStealthConfigured() {
|
|
|
454
454
|
);
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
// The server operator's own exit IPs for every stealth path, from
|
|
458
|
+
// CRAWLFORGE_STEALTH_PROXIES — a comma-separated list in proxyRotation's URL
|
|
459
|
+
// format (http/https/socks4/socks5, credentials percent-encoded in the URL).
|
|
460
|
+
//
|
|
461
|
+
// Deliberately a function reading process.env rather than a field on `config`
|
|
462
|
+
// above: a value frozen at import time cannot be changed for a single run, and
|
|
463
|
+
// every consumer (the escalation stage, the deep_research fallback, the agent,
|
|
464
|
+
// browser_session) resolves its proxy mid-process.
|
|
465
|
+
//
|
|
466
|
+
// Separate from the PROXY_ROTATION_* family under localization.proxy on
|
|
467
|
+
// purpose. Those answer "which country should this page be fetched from", which
|
|
468
|
+
// is the localization tool's whole job; this one answers "which address does
|
|
469
|
+
// this server go out from", which is about not being the same datacentre IP a
|
|
470
|
+
// wall has already seen. Overloading one on the other would make either
|
|
471
|
+
// setting silently change the other's behaviour.
|
|
472
|
+
export function serverStealthProxies() {
|
|
473
|
+
return (process.env.CRAWLFORGE_STEALTH_PROXIES || '')
|
|
474
|
+
.split(',')
|
|
475
|
+
.map((entry) => entry.trim())
|
|
476
|
+
.filter(Boolean);
|
|
477
|
+
}
|
|
478
|
+
|
|
457
479
|
// Get localization configuration
|
|
458
480
|
export function getLocalizationConfig() {
|
|
459
481
|
return config.localization;
|
|
@@ -9,6 +9,7 @@ import { CacheManager } from './cache/CacheManager.js';
|
|
|
9
9
|
import { Logger } from '../utils/Logger.js';
|
|
10
10
|
import { LLMManager } from './llm/LLMManager.js';
|
|
11
11
|
import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
|
|
12
|
+
import { serverStealthProxies } from '../constants/config.js';
|
|
12
13
|
import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
|
|
13
14
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
14
15
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
@@ -143,6 +144,12 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
143
144
|
stealthEngine = process.env.RESEARCH_STEALTH_ENGINE || 'auto',
|
|
144
145
|
stealthLevel = 'medium',
|
|
145
146
|
stealthTimeoutMs = 20000,
|
|
147
|
+
// Proxy list for the stealth fallback, in proxyRotation's URL format. The
|
|
148
|
+
// caller's list always wins; with none, the server-level
|
|
149
|
+
// CRAWLFORGE_STEALTH_PROXIES applies — this path launches Camoufox itself
|
|
150
|
+
// rather than through StealthBrowserManager, so it does not inherit that
|
|
151
|
+
// manager's proxy fallback and has to resolve one here.
|
|
152
|
+
stealthProxies = serverStealthProxies(),
|
|
146
153
|
searchConfig = {},
|
|
147
154
|
crawlConfig = {},
|
|
148
155
|
extractConfig = {},
|
|
@@ -164,6 +171,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
164
171
|
this.stealthEngine = stealthEngine;
|
|
165
172
|
this.stealthLevel = stealthLevel;
|
|
166
173
|
this.stealthTimeoutMs = stealthTimeoutMs;
|
|
174
|
+
this.stealthProxies = Array.isArray(stealthProxies) ? stealthProxies : [];
|
|
167
175
|
this._stealthManager = null; // Chromium StealthBrowserManager (fallback engine)
|
|
168
176
|
this._stealthBrowser = null; // Camoufox browser handle (preferred engine)
|
|
169
177
|
this._stealthEngineActive = null;
|
|
@@ -952,6 +960,11 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
952
960
|
if (!this._stealthInit) {
|
|
953
961
|
this._stealthInit = (async () => {
|
|
954
962
|
if (this.stealthEngine === 'camoufox' || this.stealthEngine === 'auto') {
|
|
963
|
+
// Resolved outside the try: a malformed proxy must fail loudly, not be
|
|
964
|
+
// swallowed by the 'auto' fallback into a proxyless Chromium run —
|
|
965
|
+
// "the caller believes their traffic is proxied and it is not" is the
|
|
966
|
+
// failure mode this plumbing exists to prevent.
|
|
967
|
+
const proxy = this._resolveStealthProxy();
|
|
955
968
|
try {
|
|
956
969
|
const { createRequire } = await import('module');
|
|
957
970
|
const require = createRequire(import.meta.url);
|
|
@@ -960,15 +973,35 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
960
973
|
// A page whose own JavaScript throws must not take the process with
|
|
961
974
|
// it — see src/utils/firefoxPageErrorGuard.js.
|
|
962
975
|
guardFirefoxPageErrors();
|
|
963
|
-
|
|
976
|
+
const launchOptions = { headless: true };
|
|
977
|
+
if (proxy) {
|
|
978
|
+
// camoufox takes the URL string and splits the credentials out
|
|
979
|
+
// itself (getProxyUrl → Playwright's {server,username,password}).
|
|
980
|
+
launchOptions.proxy = proxy.url;
|
|
981
|
+
// Same rule as StealthBrowserManager (`geoip: !!proxy`): behind a
|
|
982
|
+
// proxy, geoip derives timezone, locale and geolocation from the
|
|
983
|
+
// exit IP, which is the only thing that makes a proxied browser
|
|
984
|
+
// coherent. Without a proxy it would look up this machine's own
|
|
985
|
+
// public IP — a network call, and an external service learning
|
|
986
|
+
// our address, to confirm a location the browser is already in.
|
|
987
|
+
// No locale is passed either way here, so geoip's derived one
|
|
988
|
+
// has nothing to contradict.
|
|
989
|
+
launchOptions.geoip = true;
|
|
990
|
+
}
|
|
991
|
+
this._stealthBrowser = await camoufox.Camoufox(launchOptions);
|
|
964
992
|
this._stealthEngineActive = 'camoufox';
|
|
965
|
-
this.logger.info('Stealth fallback using Camoufox (Firefox) engine'
|
|
993
|
+
this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
|
|
994
|
+
proxy: proxy ? proxy.redacted : null
|
|
995
|
+
});
|
|
966
996
|
return;
|
|
967
997
|
} catch (e) {
|
|
968
998
|
if (this.stealthEngine === 'camoufox') throw e; // explicit request → surface
|
|
969
999
|
this.logger.warn('Camoufox unavailable, falling back to Chromium stealth', { error: e.message });
|
|
970
1000
|
}
|
|
971
1001
|
}
|
|
1002
|
+
// No proxy wiring on this branch: StealthBrowserManager resolves its own
|
|
1003
|
+
// (resolveProxy), server-level list included, so passing one here would
|
|
1004
|
+
// be a second path to the same setting.
|
|
972
1005
|
const { StealthBrowserManager } = await import('./StealthBrowserManager.js');
|
|
973
1006
|
this._stealthManager = new StealthBrowserManager();
|
|
974
1007
|
await this._stealthManager.launchStealthBrowser({ level: this.stealthLevel });
|
|
@@ -978,6 +1011,43 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
978
1011
|
await this._stealthInit;
|
|
979
1012
|
}
|
|
980
1013
|
|
|
1014
|
+
/**
|
|
1015
|
+
* The proxy this stealth fallback launches behind: the caller's list when they
|
|
1016
|
+
* configured one, otherwise the server-level CRAWLFORGE_STEALTH_PROXIES.
|
|
1017
|
+
* Returns null when neither is set, and `{ url, redacted }` otherwise — only
|
|
1018
|
+
* the redacted form (scheme + host, no credentials) is ever logged.
|
|
1019
|
+
*
|
|
1020
|
+
* Resolved once per browser, taking the first entry: camoufox fixes its
|
|
1021
|
+
* fingerprint — and with geoip its timezone, locale and geolocation — at
|
|
1022
|
+
* launch, so rotating underneath it would leave it reporting the first
|
|
1023
|
+
* proxy's city behind the second proxy's exit IP, which is a worse signal
|
|
1024
|
+
* than not rotating at all (see StealthBrowserManager._doLaunchStealthBrowser).
|
|
1025
|
+
*/
|
|
1026
|
+
_resolveStealthProxy() {
|
|
1027
|
+
const raw = (this.stealthProxies[0] || '').trim();
|
|
1028
|
+
if (!raw) return null;
|
|
1029
|
+
|
|
1030
|
+
// `host:port` with no scheme parses as protocol "host:" and an empty host,
|
|
1031
|
+
// so the bare form gets the http:// every proxy list assumes.
|
|
1032
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
1033
|
+
let parsed;
|
|
1034
|
+
try {
|
|
1035
|
+
parsed = new URL(url);
|
|
1036
|
+
} catch {
|
|
1037
|
+
// Never echo the entry: it carries the password, and camoufox's own
|
|
1038
|
+
// `new URL(proxy)` would quote the whole string in its TypeError.
|
|
1039
|
+
throw new Error('Invalid stealth proxy: expected host:port or scheme://user:pass@host:port');
|
|
1040
|
+
}
|
|
1041
|
+
const scheme = parsed.protocol.replace(':', '').toLowerCase();
|
|
1042
|
+
if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) {
|
|
1043
|
+
throw new Error(`Invalid stealth proxy scheme "${scheme}": expected http, https, socks4 or socks5`);
|
|
1044
|
+
}
|
|
1045
|
+
if (!parsed.hostname) {
|
|
1046
|
+
throw new Error('Invalid stealth proxy: no host');
|
|
1047
|
+
}
|
|
1048
|
+
return { url, redacted: `${parsed.protocol}//${parsed.host}` };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
981
1051
|
/**
|
|
982
1052
|
* macOS packaging fix for camoufox-js: it expects properties.json in
|
|
983
1053
|
* Camoufox.app/Contents/MacOS/, but the .app bundle ships it under
|