crawlforge-mcp-server 6.16.0 → 6.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.17.1",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.12.0",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
|
29
29
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
30
30
|
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
31
31
|
import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
|
|
32
|
-
import { StealthBrowserManager, resolveStealthEngine } from "./src/core/StealthBrowserManager.js";
|
|
32
|
+
import { StealthBrowserManager, resolveStealthEngine, assertProxyEngineAllowed } from "./src/core/StealthBrowserManager.js";
|
|
33
33
|
import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
34
34
|
// Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
|
|
35
35
|
import * as cheerio from "cheerio";
|
|
@@ -112,7 +112,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
112
112
|
// Create the server
|
|
113
113
|
const server = new McpServer({
|
|
114
114
|
name: "crawlforge",
|
|
115
|
-
version: "6.
|
|
115
|
+
version: "6.17.1",
|
|
116
116
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
117
117
|
homepage: "https://www.crawlforge.dev",
|
|
118
118
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -962,7 +962,7 @@ registerToolIfEnabled("read_result", {
|
|
|
962
962
|
|
|
963
963
|
// Tool: scrape_with_actions
|
|
964
964
|
registerToolIfEnabled("scrape_with_actions", {
|
|
965
|
-
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
965
|
+
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation, and each navigation is checked: a bot wall is reported as `blocked` with the vendor named, per navigation in `navigations` and for the final page at the top level. browserOptions.proxyRotation routes a stealth chain through your own proxies. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
966
966
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
967
967
|
inputSchema: {
|
|
968
968
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -1036,7 +1036,12 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1036
1036
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
1037
1037
|
stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
|
|
1038
1038
|
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
|
|
1039
|
-
consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged.")
|
|
1039
|
+
consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged."),
|
|
1040
|
+
proxyRotation: z.object({
|
|
1041
|
+
enabled: z.boolean().default(false),
|
|
1042
|
+
proxies: z.array(z.string()).optional(),
|
|
1043
|
+
rotationInterval: z.number().default(300000)
|
|
1044
|
+
}).optional().describe("Route a stealth chain through your own proxies; requires stealth:true and the Chromium engine, and is refused without stealth or on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. A proxy on a loopback, link-local or cloud-metadata address is refused, and credentials are removed from the result. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies.")
|
|
1040
1045
|
}).optional().describe("Browser configuration options"),
|
|
1041
1046
|
extractionOptions: z.object({
|
|
1042
1047
|
selectors: z.record(z.string()).optional(),
|
|
@@ -1426,7 +1431,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1426
1431
|
enabled: z.boolean().default(false),
|
|
1427
1432
|
proxies: z.array(z.string()).optional(),
|
|
1428
1433
|
rotationInterval: z.number().default(300000)
|
|
1429
|
-
}).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies."),
|
|
1434
|
+
}).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. Requires the Chromium engine: refused on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. CrawlForge supplies no proxies."),
|
|
1430
1435
|
antiDetection: z.object({
|
|
1431
1436
|
cloudflareBypass: z.boolean().default(true),
|
|
1432
1437
|
recaptchaHandling: z.boolean().default(true),
|
|
@@ -1466,6 +1471,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1466
1471
|
// is missing; "playwright" is this tool's old public name for chromium.
|
|
1467
1472
|
// The resolver owns both, and reports a fallback the caller can see.
|
|
1468
1473
|
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1474
|
+
assertProxyEngineAllowed(resolvedEngine.engine, stealthConfig);
|
|
1469
1475
|
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1470
1476
|
|
|
1471
1477
|
recordStealthEscalation({
|
|
@@ -1533,6 +1539,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1533
1539
|
// without it, create_context always ran on chromium whatever the caller
|
|
1534
1540
|
// asked for. Resolved, so "auto" reaches the manager as a real engine.
|
|
1535
1541
|
const contextEngine = await resolveStealthEngine(engine);
|
|
1542
|
+
assertProxyEngineAllowed(contextEngine.engine, stealthConfig);
|
|
1536
1543
|
const contextData = await stealthBrowserManager.createStealthContext({
|
|
1537
1544
|
...(stealthConfig || {}),
|
|
1538
1545
|
engine: contextEngine.engine
|
|
@@ -12,6 +12,7 @@ import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '.
|
|
|
12
12
|
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
|
+
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
15
16
|
|
|
16
17
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
17
18
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -31,6 +32,13 @@ const ACTION_TIMEOUT_GRACE_MS = 2000;
|
|
|
31
32
|
// to work cost several times its stated timeout.
|
|
32
33
|
const RECOVERY_TIMEOUT_MS = 3000;
|
|
33
34
|
|
|
35
|
+
// How long a navigation whose document reads as a wall is watched for the wall
|
|
36
|
+
// replacing itself before it is reported, re-read every NAVIGATION_WALL_POLL_MS.
|
|
37
|
+
// AWS WAF's interstitial reloads into amazon.com's homepage about 0.4 s after
|
|
38
|
+
// domcontentloaded (measured 2026-10-03); nothing here acts on the page.
|
|
39
|
+
const NAVIGATION_WALL_GRACE_MS = 3000;
|
|
40
|
+
const NAVIGATION_WALL_POLL_MS = 500;
|
|
41
|
+
|
|
34
42
|
// The only states locator.waitFor()/page.waitForSelector() accept. The rest of
|
|
35
43
|
// the wait-action enum (enabled/disabled/stable) are ElementHandle states and
|
|
36
44
|
// have to go through waitForElementState instead — passing them here is
|
|
@@ -312,6 +320,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
312
320
|
executionContext.consent = await handleConsent(page, browserOptions.consent);
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
// navigations[0] is the initial load; each `navigate` action appends
|
|
324
|
+
// its own (executeNavigateAction).
|
|
325
|
+
executionContext.navigations = [await this.checkNavigation(page, url, browserOptions)];
|
|
326
|
+
|
|
315
327
|
// Execute chain with potential retries
|
|
316
328
|
chainResult = await this.executeChainWithRetries(executionContext);
|
|
317
329
|
|
|
@@ -419,6 +431,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
419
431
|
finalUrl: executionContext.finalUrl || url,
|
|
420
432
|
finalHtml: executionContext.finalHtml,
|
|
421
433
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
434
|
+
navigations: executionContext.navigations || [],
|
|
422
435
|
consent: executionContext.consent,
|
|
423
436
|
executionTime: Date.now() - startTime,
|
|
424
437
|
results: executionContext.results,
|
|
@@ -446,6 +459,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
446
459
|
// results, the error screenshot, and any intermediate-state
|
|
447
460
|
// captures) instead of discarding them.
|
|
448
461
|
results: executionContext?.results || [],
|
|
462
|
+
navigations: executionContext?.navigations || [],
|
|
449
463
|
attempt: executionContext?.attempt ?? 0,
|
|
450
464
|
attempts: executionContext?.attempts || [],
|
|
451
465
|
screenshots: executionContext?.screenshots || [],
|
|
@@ -478,6 +492,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
478
492
|
await this.navigateToUrl(page, executionContext.url, {
|
|
479
493
|
browserOptions: executionContext.browserOptions
|
|
480
494
|
});
|
|
495
|
+
// Only the last attempt's navigations are reported, so the reload
|
|
496
|
+
// that starts this one is navigations[0].
|
|
497
|
+
executionContext.navigations = [
|
|
498
|
+
await this.checkNavigation(page, executionContext.url, executionContext.browserOptions)
|
|
499
|
+
];
|
|
481
500
|
}
|
|
482
501
|
|
|
483
502
|
// Execute actions in sequence
|
|
@@ -1346,14 +1365,73 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1346
1365
|
? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
|
|
1347
1366
|
: undefined;
|
|
1348
1367
|
|
|
1368
|
+
// Reporting only: the navigation happened, so the action succeeds whether
|
|
1369
|
+
// or not the document it reached is a wall. The wait for a wall to clear
|
|
1370
|
+
// gets what is left of the deadline, like consent above.
|
|
1371
|
+
const navigation = await this.checkNavigation(page, action.url, executionContext?.browserOptions, {
|
|
1372
|
+
graceMs: Math.min(NAVIGATION_WALL_GRACE_MS, timeout - (Date.now() - startedAt))
|
|
1373
|
+
});
|
|
1374
|
+
executionContext?.navigations?.push(navigation);
|
|
1375
|
+
|
|
1349
1376
|
return {
|
|
1350
1377
|
url: action.url,
|
|
1351
|
-
finalUrl:
|
|
1378
|
+
finalUrl: navigation.finalUrl,
|
|
1352
1379
|
waitUntil: action.waitUntil || 'domcontentloaded',
|
|
1380
|
+
httpStatus: navigation.httpStatus,
|
|
1381
|
+
...(navigation.blocked ? { blocked: navigation.blocked } : {}),
|
|
1353
1382
|
...(consent ? { consent } : {})
|
|
1354
1383
|
};
|
|
1355
1384
|
}
|
|
1356
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* What the navigation that just finished reached: the page, or a wall. The
|
|
1388
|
+
* same verdict the chain's final document gets, run on this document with
|
|
1389
|
+
* the status of this navigation. A document that reads as a wall is re-read
|
|
1390
|
+
* for up to `graceMs` first, because some walls replace themselves (AWS WAF's
|
|
1391
|
+
* interstitial reloads into the page); one that cannot be read at all is
|
|
1392
|
+
* reported without a verdict rather than as a wall.
|
|
1393
|
+
* @param {Page} page - Playwright page, just navigated
|
|
1394
|
+
* @param {string} url - the URL the navigation was sent to
|
|
1395
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` for the read's gate)
|
|
1396
|
+
* @param {{ graceMs?: number }} [options]
|
|
1397
|
+
* @returns {Promise<{ url: string, finalUrl: string, httpStatus: number|null, blocked?: { vendor: string, evidence: string } }>}
|
|
1398
|
+
*/
|
|
1399
|
+
async checkNavigation(page, url, browserOptions, { graceMs = NAVIGATION_WALL_GRACE_MS } = {}) {
|
|
1400
|
+
const httpStatus = page.__crawlforgeNavigation?.status ?? null;
|
|
1401
|
+
const read = () => this.readGated(page, browserOptions, async () => {
|
|
1402
|
+
try {
|
|
1403
|
+
const [title, html, text] = await Promise.all([
|
|
1404
|
+
page.title(),
|
|
1405
|
+
page.content(),
|
|
1406
|
+
page.evaluate(() => (document.body ? document.body.innerText : ''))
|
|
1407
|
+
]);
|
|
1408
|
+
return stealthDocumentVerdict(
|
|
1409
|
+
{ url: page.url(), title, html, text, status: httpStatus },
|
|
1410
|
+
{ allowEmpty: true, fetcher: browserOptions?.stealthMode?.enabled ? 'the stealth browser' : 'the browser' }
|
|
1411
|
+
);
|
|
1412
|
+
} catch {
|
|
1413
|
+
return null; // mid-navigation, or a page that cannot be read
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
let verdict = await read();
|
|
1418
|
+
const deadline = Date.now() + graceMs;
|
|
1419
|
+
while (verdict?.blocked && Date.now() + NAVIGATION_WALL_POLL_MS <= deadline) {
|
|
1420
|
+
await this.delay(NAVIGATION_WALL_POLL_MS);
|
|
1421
|
+
const next = await read();
|
|
1422
|
+
// A read that fails mid-reload says nothing yet; keep the wall until a
|
|
1423
|
+
// document answers.
|
|
1424
|
+
if (next) verdict = next;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return {
|
|
1428
|
+
url,
|
|
1429
|
+
finalUrl: page.url(),
|
|
1430
|
+
httpStatus,
|
|
1431
|
+
...(verdict?.blocked ? { blocked: verdict.blocked } : {})
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1357
1435
|
/**
|
|
1358
1436
|
* Execute screenshot action
|
|
1359
1437
|
* @param {Page} page - Playwright page
|
|
@@ -17,7 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
19
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
20
|
-
import { safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
20
|
+
import { safeGoto, assertNavigationAllowed, assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
21
21
|
import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
|
|
22
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
@@ -159,6 +159,39 @@ function isChromium(page) {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* A proxy entry with its credentials removed. Everything before the last "@"
|
|
164
|
+
* of the authority goes, with or without a scheme: parseProxyEntry accepts a
|
|
165
|
+
* bare `user:pass@host:port`, which a rule anchored on "//" left whole.
|
|
166
|
+
* @param {string} entry
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function redactProxyEntry(entry) {
|
|
170
|
+
return String(entry).replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/]*@/i, '$1');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Refuse a per-call proxy list on camoufox. camoufox takes its proxy at
|
|
175
|
+
* browser launch and that one browser serves every later camoufox call, so a
|
|
176
|
+
* caller's proxy would carry other callers' traffic, on the caller's
|
|
177
|
+
* credentials, or be ignored when a browser is already up. Chromium sets the
|
|
178
|
+
* proxy per context. Called with the engine that will actually run, so an
|
|
179
|
+
* "auto" that lands on camoufox is refused too. The operator's
|
|
180
|
+
* CRAWLFORGE_STEALTH_PROXIES is not a per-call list and is not refused.
|
|
181
|
+
* @param {string} engine - the resolved engine
|
|
182
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
183
|
+
* @throws {Error}
|
|
184
|
+
*/
|
|
185
|
+
export function assertProxyEngineAllowed(engine, config) {
|
|
186
|
+
const requested = config?.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
187
|
+
if (engine !== 'camoufox' || !requested.length) return;
|
|
188
|
+
throw new Error(
|
|
189
|
+
'proxyRotation is refused on the camoufox engine: camoufox applies its proxy at browser launch ' +
|
|
190
|
+
'and shares that browser across calls, so a per-call proxy cannot be kept to this call. Use ' +
|
|
191
|
+
'engine:"chromium" (proxies are per context there), or set the operator list CRAWLFORGE_STEALTH_PROXIES.'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
162
195
|
/**
|
|
163
196
|
* Resolve a requested engine to the one that will actually launch.
|
|
164
197
|
*
|
|
@@ -422,12 +455,19 @@ export class StealthBrowserManager {
|
|
|
422
455
|
*/
|
|
423
456
|
async launchStealthBrowser(config = {}) {
|
|
424
457
|
const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
|
|
458
|
+
// Every path to a browser comes through here, createStealthContext's
|
|
459
|
+
// included, so this is where a caller's proxy list is held to the SSRF
|
|
460
|
+
// guard before either engine is handed it.
|
|
461
|
+
await this.assertCallerProxiesAllowed(validatedConfig);
|
|
425
462
|
// 'auto' has to become a concrete engine before the comparison below: an
|
|
426
463
|
// unresolved 'auto' never equals _launchedEngine, so every call would park
|
|
427
464
|
// a perfectly good browser and launch a second one beside it. This is the
|
|
428
465
|
// call that decides the engine — every other path reaches a browser
|
|
429
466
|
// through it — so it is the one that records the outcome.
|
|
430
467
|
await this._resolveConfigEngine(validatedConfig, true);
|
|
468
|
+
// The tools refuse this up front; this is the backstop for every other
|
|
469
|
+
// path, decided on the engine 'auto' resolved to, before any launch.
|
|
470
|
+
assertProxyEngineAllowed(validatedConfig.engine, validatedConfig);
|
|
431
471
|
|
|
432
472
|
// A Chromium that was OOM-killed or crashed doesn't error on reuse — its
|
|
433
473
|
// protocol calls hang. Detect the corpse and relaunch instead.
|
|
@@ -528,7 +568,11 @@ export class StealthBrowserManager {
|
|
|
528
568
|
// leave camoufox reporting the first proxy's city behind the second
|
|
529
569
|
// proxy's exit IP, which is a worse signal than not rotating at all.
|
|
530
570
|
// A rotation takes effect on the next launch, after cleanup().
|
|
531
|
-
|
|
571
|
+
//
|
|
572
|
+
// Only the operator's CRAWLFORGE_STEALTH_PROXIES, never a caller's list:
|
|
573
|
+
// this browser outlives the call, and every later camoufox context
|
|
574
|
+
// inherits its proxy (assertProxyEngineAllowed refuses the caller's).
|
|
575
|
+
const proxy = this.resolveProxy({ ...validatedConfig, proxyRotation: undefined });
|
|
532
576
|
// The caller's locale goes to the launcher, not to the context. camoufox
|
|
533
577
|
// sets language, Accept-Language and Intl together below the JS layer,
|
|
534
578
|
// where a Worker reads the same answer as the document; Playwright's
|
|
@@ -2387,18 +2431,23 @@ export class StealthBrowserManager {
|
|
|
2387
2431
|
// Wait for potential challenge page
|
|
2388
2432
|
await page.waitForTimeout(2000);
|
|
2389
2433
|
|
|
2390
|
-
// Check for CloudFlare challenge indicators
|
|
2434
|
+
// Check for CloudFlare challenge indicators. Cloudflare's current
|
|
2435
|
+
// interstitial reads "Performing security verification" under the title
|
|
2436
|
+
// "Just a moment..." (doordash.com, 2026-10-03); none of the older
|
|
2437
|
+
// phrases below appear on it, so the wait further down ended at once.
|
|
2391
2438
|
const challengeDetected = await page.evaluate(() => {
|
|
2392
2439
|
const indicators = [
|
|
2393
2440
|
'cf-browser-verification',
|
|
2394
2441
|
'cf-challenge-running',
|
|
2395
2442
|
'Checking your browser',
|
|
2443
|
+
'Performing security verification',
|
|
2396
2444
|
'DDoS protection by Cloudflare',
|
|
2397
2445
|
'Ray ID'
|
|
2398
2446
|
];
|
|
2399
2447
|
|
|
2400
2448
|
const pageText = document.body.innerText;
|
|
2401
|
-
return
|
|
2449
|
+
return /^just a moment/i.test(document.title.trim()) ||
|
|
2450
|
+
indicators.some(indicator => pageText.includes(indicator));
|
|
2402
2451
|
});
|
|
2403
2452
|
|
|
2404
2453
|
if (challengeDetected) {
|
|
@@ -2420,16 +2469,25 @@ export class StealthBrowserManager {
|
|
|
2420
2469
|
}
|
|
2421
2470
|
}
|
|
2422
2471
|
|
|
2423
|
-
// Wait for challenge to complete
|
|
2472
|
+
// Wait for the challenge to complete, up to 10 s. Only waits for
|
|
2473
|
+
// Cloudflare's own script to finish; nothing is clicked. A challenge
|
|
2474
|
+
// that passes clears in seconds; one that will not pass (an
|
|
2475
|
+
// interactive check, or this IP's reputation) never clears, and 30 s
|
|
2476
|
+
// there put a walled chain past the REST window (doordash.com stealth
|
|
2477
|
+
// Chromium, 3 of 3 runs walled at 39 s with the 30 s cap, 2026-10-03).
|
|
2424
2478
|
await page.waitForFunction(() => {
|
|
2425
2479
|
const indicators = [
|
|
2426
2480
|
'cf-browser-verification',
|
|
2427
2481
|
'cf-challenge-running',
|
|
2428
|
-
'Checking your browser'
|
|
2482
|
+
'Checking your browser',
|
|
2483
|
+
'Performing security verification'
|
|
2429
2484
|
];
|
|
2430
|
-
const pageText = document.body.innerText;
|
|
2431
|
-
return
|
|
2432
|
-
|
|
2485
|
+
const pageText = document.body ? document.body.innerText : '';
|
|
2486
|
+
return !/^just a moment/i.test(document.title.trim()) &&
|
|
2487
|
+
!indicators.some(indicator => pageText.includes(indicator));
|
|
2488
|
+
// Options are the third argument; in the second they were the page
|
|
2489
|
+
// function's argument and the wait ran Playwright's 30 s default.
|
|
2490
|
+
}, undefined, { timeout: 10000 }).catch(() => {});
|
|
2433
2491
|
|
|
2434
2492
|
this.performanceMetrics.successfulBypasses++;
|
|
2435
2493
|
return true;
|
|
@@ -2550,9 +2608,34 @@ export class StealthBrowserManager {
|
|
|
2550
2608
|
return proxy;
|
|
2551
2609
|
}
|
|
2552
2610
|
|
|
2611
|
+
/**
|
|
2612
|
+
* Refuse a caller's proxy whose host the SSRF guard would refuse as a page:
|
|
2613
|
+
* a proxy on loopback, link-local or a metadata address (RFC 1918 too under
|
|
2614
|
+
* SSRF_STRICT, and ALLOWED_DOMAINS exempt, as everywhere else) would have
|
|
2615
|
+
* the browser send the call's traffic into this server's own network. Only
|
|
2616
|
+
* the per-call list is checked — CRAWLFORGE_STEALTH_PROXIES is the
|
|
2617
|
+
* operator's, who may well run a proxy on localhost. The message names the
|
|
2618
|
+
* host, never the entry, which carries the password.
|
|
2619
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
2620
|
+
* @returns {Promise<void>}
|
|
2621
|
+
* @throws {Error} code SSRF_BLOCKED
|
|
2622
|
+
*/
|
|
2623
|
+
async assertCallerProxiesAllowed(config) {
|
|
2624
|
+
const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
2625
|
+
for (const entry of requested) {
|
|
2626
|
+
// url.host of socks4/socks5 parses the same way; the guard only takes http(s).
|
|
2627
|
+
const { host } = new URL(this.parseProxyEntry(entry).server);
|
|
2628
|
+
try {
|
|
2629
|
+
await assertUrlAllowed(`http://${host}/`, { resolveDns: true });
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
throw Object.assign(new Error(`proxyRotation: proxy host ${host} refused — ${err.message}`), { code: err.code });
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2553
2636
|
/** A proxy entry with its credentials removed, for logs and get_stats. */
|
|
2554
2637
|
redactProxy(entry) {
|
|
2555
|
-
return
|
|
2638
|
+
return redactProxyEntry(entry);
|
|
2556
2639
|
}
|
|
2557
2640
|
|
|
2558
2641
|
/**
|
|
@@ -373,6 +373,11 @@ export class BrowserProcessor {
|
|
|
373
373
|
// createStealthContext re-validates the config it is given — so a context
|
|
374
374
|
// asked for without it would relaunch on the default engine underneath.
|
|
375
375
|
const engine = options.stealthMode.engine || 'chromium';
|
|
376
|
+
// The caller's own proxies (scrape_with_actions browserOptions.proxyRotation),
|
|
377
|
+
// to both calls for the same reason as the engine: camoufox takes its proxy
|
|
378
|
+
// at launch, Chromium per context. Absent unless the caller sent one, so
|
|
379
|
+
// CRAWLFORGE_STEALTH_PROXIES still applies when nobody did.
|
|
380
|
+
const proxy = options.proxyRotation ? { proxyRotation: options.proxyRotation } : {};
|
|
376
381
|
|
|
377
382
|
// Launch stealth browser
|
|
378
383
|
await this.stealthManager.launchStealthBrowser({
|
|
@@ -381,12 +386,14 @@ export class BrowserProcessor {
|
|
|
381
386
|
randomizeFingerprint: options.stealthMode.randomizeFingerprint,
|
|
382
387
|
hideWebDriver: options.stealthMode.hideWebDriver,
|
|
383
388
|
blockWebRTC: options.stealthMode.blockWebRTC,
|
|
384
|
-
customUserAgent: options.stealthMode.customUserAgent || options.userAgent
|
|
389
|
+
customUserAgent: options.stealthMode.customUserAgent || options.userAgent,
|
|
390
|
+
...proxy
|
|
385
391
|
});
|
|
386
392
|
|
|
387
393
|
// Create stealth context
|
|
388
394
|
const { context, contextId } = await this.stealthManager.createStealthContext({
|
|
389
395
|
engine,
|
|
396
|
+
...proxy,
|
|
390
397
|
level: options.stealthMode.level,
|
|
391
398
|
customViewport: {
|
|
392
399
|
width: options.viewportWidth || 1280,
|
|
@@ -14,7 +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
|
+
import { resolveStealthEngine, redactProxyEntry, assertProxyEngineAllowed } from '../../core/StealthBrowserManager.js';
|
|
18
18
|
import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
|
|
19
19
|
|
|
20
20
|
// Recording / replay helpers
|
|
@@ -27,6 +27,20 @@ import {
|
|
|
27
27
|
recordedEntryToAction
|
|
28
28
|
} from './scrapeWithActions/recorder.js';
|
|
29
29
|
|
|
30
|
+
// metadata.browserOptions echoes the merged options, and a proxy entry carries
|
|
31
|
+
// its password.
|
|
32
|
+
function redactProxyCredentials(browserOptions) {
|
|
33
|
+
const proxies = browserOptions.proxyRotation?.proxies;
|
|
34
|
+
if (!proxies) return browserOptions;
|
|
35
|
+
return {
|
|
36
|
+
...browserOptions,
|
|
37
|
+
proxyRotation: {
|
|
38
|
+
...browserOptions.proxyRotation,
|
|
39
|
+
proxies: proxies.map(redactProxyEntry)
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
// Action schemas (re-using from ActionExecutor but with tool-specific additions)
|
|
31
45
|
const BaseActionSchema = z.object({
|
|
32
46
|
type: z.string(),
|
|
@@ -203,7 +217,15 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
203
217
|
// Answer a cookie/consent wall (autoconsent) after the initial load and
|
|
204
218
|
// after each navigate action: "reject" opts out, "accept" opts in. Off by
|
|
205
219
|
// default so a chain that clicks the banner itself keeps working.
|
|
206
|
-
consent: z.enum(['off', 'reject', 'accept']).default('off')
|
|
220
|
+
consent: z.enum(['off', 'reject', 'accept']).default('off'),
|
|
221
|
+
// The caller's own proxies for a stealth chain — the shape and parsing of
|
|
222
|
+
// stealth_mode's stealthConfig.proxyRotation. Refused without stealth:true
|
|
223
|
+
// (see executeSession); CrawlForge supplies none.
|
|
224
|
+
proxyRotation: z.object({
|
|
225
|
+
enabled: z.boolean().default(false),
|
|
226
|
+
proxies: z.array(z.string()).optional(),
|
|
227
|
+
rotationInterval: z.number().default(300000)
|
|
228
|
+
}).optional()
|
|
207
229
|
}).optional(),
|
|
208
230
|
|
|
209
231
|
// Content extraction options
|
|
@@ -425,6 +447,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
425
447
|
const warnings = [];
|
|
426
448
|
if (browserOptions.stealth) {
|
|
427
449
|
const resolved = await resolveStealthEngine(browserOptions.engine);
|
|
450
|
+
// Refused on the engine that will run, before the chain starts.
|
|
451
|
+
assertProxyEngineAllowed(resolved.engine, browserOptions);
|
|
428
452
|
browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
|
|
429
453
|
if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
|
|
430
454
|
} else if (browserOptions.engine === 'camoufox') {
|
|
@@ -439,6 +463,15 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
439
463
|
'to use the standard Chromium browser.'
|
|
440
464
|
);
|
|
441
465
|
}
|
|
466
|
+
if (browserOptions.proxyRotation?.enabled && !browserOptions.stealth) {
|
|
467
|
+
// Only the stealth browser takes a proxy; the standard pool would run
|
|
468
|
+
// the chain from this server's own address while the caller believes
|
|
469
|
+
// it is proxied.
|
|
470
|
+
throw new Error(
|
|
471
|
+
'browserOptions.proxyRotation requires browserOptions.stealth:true — only the stealth ' +
|
|
472
|
+
'browser routes through a proxy. Set stealth:true, or drop proxyRotation.'
|
|
473
|
+
);
|
|
474
|
+
}
|
|
442
475
|
|
|
443
476
|
// The robots override rides with the browser options so ActionExecutor's
|
|
444
477
|
// gate sees it on the initial load and on every `navigate` action.
|
|
@@ -534,7 +567,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
534
567
|
text: finalContent?.content?.text || '',
|
|
535
568
|
html: chainResult?.finalHtml || '',
|
|
536
569
|
status: chainResult?.navigationStatus ?? null
|
|
537
|
-
}, { allowEmpty: true });
|
|
570
|
+
}, { allowEmpty: true, fetcher: browserOptions.stealthMode?.enabled ? 'the stealth browser' : 'the browser' });
|
|
538
571
|
|
|
539
572
|
return {
|
|
540
573
|
success: chainResult.success && verdict.success,
|
|
@@ -553,6 +586,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
553
586
|
// The landing page's consent result; each navigate action's own is on
|
|
554
587
|
// its result. Absent when consent handling is off.
|
|
555
588
|
...(chainResult.consent ? { consent: chainResult.consent } : {}),
|
|
589
|
+
// Every navigation of the reported attempt — [0] the initial load, then
|
|
590
|
+
// each navigate action — with its status and any wall it reached.
|
|
591
|
+
navigations: chainResult.navigations || [],
|
|
556
592
|
|
|
557
593
|
actionResults,
|
|
558
594
|
attempt: chainResult.attempt ?? attempts.length,
|
|
@@ -576,7 +612,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
576
612
|
replayedFrom: params.replayRecording || undefined,
|
|
577
613
|
|
|
578
614
|
metadata: {
|
|
579
|
-
browserOptions,
|
|
615
|
+
browserOptions: redactProxyCredentials(browserOptions),
|
|
580
616
|
formAutoFillApplied: !!params.formAutoFill,
|
|
581
617
|
intermediateStatesCount: intermediateStates.length,
|
|
582
618
|
screenshotsCount: sessionContext.screenshots.length,
|
package/src/utils/secretMask.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { redactPii } from 'crawlforge-extractors';
|
|
13
13
|
|
|
14
|
-
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl
|
|
14
|
+
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl|^proxies$|cookie/i;
|
|
15
15
|
|
|
16
16
|
const MASK = '[REDACTED]';
|
|
17
17
|
const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
|