crawlforge-mcp-server 6.6.1 → 6.7.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/package.json +2 -2
- package/server.js +20 -5
- package/src/core/ActionExecutor.js +13 -4
- package/src/core/ResearchOrchestrator.js +4 -0
- package/src/core/StealthBrowserManager.js +343 -33
- package/src/resources/ResourceRegistry.js +35 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +24 -0
- package/src/tools/advanced/BrowserSessionTool.js +20 -2
- package/src/utils/firefoxPageErrorGuard.js +93 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
|
-
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"crawlforge": "src/cli/index.js",
|
package/server.js
CHANGED
|
@@ -61,7 +61,7 @@ import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
|
61
61
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
62
62
|
import { markPreflightRefusal } from "./src/server/requestContext.js";
|
|
63
63
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
64
|
-
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
64
|
+
import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
|
|
65
65
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
66
66
|
import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
|
|
67
67
|
// Phase 6: MCP-spec adoption — structured output, tool filtering, spec hygiene
|
|
@@ -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.7.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",
|
|
@@ -1063,9 +1063,24 @@ registerToolIfEnabled("browser_session", {
|
|
|
1063
1063
|
// is megabytes beside a few lines of JSON (R21, 2026-09-09).
|
|
1064
1064
|
const publish = (shot) => {
|
|
1065
1065
|
if (!shot?.actionId || !shot?.data) return shot;
|
|
1066
|
-
resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
1066
|
+
const { bytes, withinInlineBudget } = resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
1067
1067
|
const { data, ...rest } = shot;
|
|
1068
|
-
return {
|
|
1068
|
+
return {
|
|
1069
|
+
...rest,
|
|
1070
|
+
resourceUri: `crawlforge://screenshot/${shot.actionId}`,
|
|
1071
|
+
bytes,
|
|
1072
|
+
// full_page is a knob this tool hands the caller, and on a long page it
|
|
1073
|
+
// produces an image no MCP message can carry. Saying so here costs one
|
|
1074
|
+
// field; learning it from the read costs a wasted call (R23).
|
|
1075
|
+
...(withinInlineBudget
|
|
1076
|
+
? {}
|
|
1077
|
+
: {
|
|
1078
|
+
warning: `This image is ${bytes} bytes, too large to read back over MCP ` +
|
|
1079
|
+
`(limit ${MAX_RESOURCE_BLOB_BYTES} bytes) — reading the resource will be refused. ` +
|
|
1080
|
+
`Take it again without full_page, or as format:"jpeg" with a lower quality, or ` +
|
|
1081
|
+
`scoped to one element with selector.`
|
|
1082
|
+
})
|
|
1083
|
+
};
|
|
1069
1084
|
};
|
|
1070
1085
|
if (result.screenshot) result.screenshot = publish(result.screenshot);
|
|
1071
1086
|
if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
|
|
@@ -1348,7 +1363,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1348
1363
|
enabled: z.boolean().default(false),
|
|
1349
1364
|
proxies: z.array(z.string()).optional(),
|
|
1350
1365
|
rotationInterval: z.number().default(300000)
|
|
1351
|
-
}).optional(),
|
|
1366
|
+
}).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."),
|
|
1352
1367
|
antiDetection: z.object({
|
|
1353
1368
|
cloudflareBypass: z.boolean().default(true),
|
|
1354
1369
|
recaptchaHandling: z.boolean().default(true),
|
|
@@ -1184,12 +1184,13 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1184
1184
|
const timeout = this.actionTimeout(action);
|
|
1185
1185
|
|
|
1186
1186
|
await assertUrlAllowed(action.url, { resolveDns: true });
|
|
1187
|
-
await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1187
|
+
const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1188
1188
|
|
|
1189
1189
|
await this.navigateToUrl(page, action.url, {
|
|
1190
1190
|
waitUntil: action.waitUntil,
|
|
1191
1191
|
timeout
|
|
1192
1192
|
});
|
|
1193
|
+
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1193
1194
|
|
|
1194
1195
|
return {
|
|
1195
1196
|
url: action.url,
|
|
@@ -1380,9 +1381,13 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1380
1381
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
1381
1382
|
*/
|
|
1382
1383
|
async assertRobotsAllowed(url, browserOptions = {}) {
|
|
1383
|
-
await browserPreflight(url, {
|
|
1384
|
+
return await browserPreflight(url, {
|
|
1384
1385
|
respectRobots: browserOptions?.respectRobots,
|
|
1385
|
-
|
|
1386
|
+
// The audit row is the record of the CUSTOMER's decision (G5), so it has
|
|
1387
|
+
// to name the tool that actually made it. browser_session borrows this
|
|
1388
|
+
// executor, and until R23 every session's override was filed against
|
|
1389
|
+
// scrape_with_actions.
|
|
1390
|
+
tool: browserOptions?.tool || 'scrape_with_actions'
|
|
1386
1391
|
});
|
|
1387
1392
|
}
|
|
1388
1393
|
|
|
@@ -1402,13 +1407,17 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1402
1407
|
// or a disallowed path never costs a Chromium process. preflightFetch is
|
|
1403
1408
|
// deliberately not used here: its identity/signature headers belong on an
|
|
1404
1409
|
// HTTP fetch, not on a browser context.
|
|
1405
|
-
await this.assertRobotsAllowed(url, browserOptions);
|
|
1410
|
+
const gateWarnings = await this.assertRobotsAllowed(url, browserOptions);
|
|
1406
1411
|
|
|
1407
1412
|
const isStealth = !!browserOptions.stealthMode?.enabled;
|
|
1408
1413
|
|
|
1409
1414
|
// Use the enhanced BrowserProcessor initialization that supports stealth mode
|
|
1410
1415
|
const page = await this.browserProcessor.initializePage(browserOptions);
|
|
1411
1416
|
|
|
1417
|
+
// Stamped on the page for the same reason __crawlforgeNavigation is: the
|
|
1418
|
+
// caller's result is assembled a layer up, and the gate ran a layer down.
|
|
1419
|
+
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1420
|
+
|
|
1412
1421
|
try {
|
|
1413
1422
|
// Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
|
|
1414
1423
|
if (isStealth && this.browserProcessor.stealthManager) {
|
|
@@ -10,6 +10,7 @@ import { Logger } from '../utils/Logger.js';
|
|
|
10
10
|
import { LLMManager } from './llm/LLMManager.js';
|
|
11
11
|
import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
|
|
12
12
|
import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
|
|
13
|
+
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
13
14
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
14
15
|
import {
|
|
15
16
|
isAdmissibleClaim,
|
|
@@ -956,6 +957,9 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
956
957
|
const require = createRequire(import.meta.url);
|
|
957
958
|
const camoufox = require('camoufox'); // CJS build — ESM build is broken
|
|
958
959
|
await this._ensureCamoufoxLayout(camoufox);
|
|
960
|
+
// A page whose own JavaScript throws must not take the process with
|
|
961
|
+
// it — see src/utils/firefoxPageErrorGuard.js.
|
|
962
|
+
guardFirefoxPageErrors();
|
|
959
963
|
this._stealthBrowser = await camoufox.Camoufox({ headless: true });
|
|
960
964
|
this._stealthEngineActive = 'camoufox';
|
|
961
965
|
this.logger.info('Stealth fallback using Camoufox (Firefox) engine');
|
|
@@ -15,10 +15,15 @@ import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
|
15
15
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
16
16
|
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
17
17
|
import { detectChallengePage } from '../utils/challengeDetection.js';
|
|
18
|
+
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
18
19
|
|
|
19
20
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
20
21
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
21
22
|
|
|
23
|
+
// The proxy a camoufox browser was launched with, kept on the browser itself so
|
|
24
|
+
// it survives being parked and restored by an engine switch.
|
|
25
|
+
const CAMOUFOX_PROXY = Symbol('crawlforge.camoufoxProxy');
|
|
26
|
+
|
|
22
27
|
const StealthConfigSchema = z.object({
|
|
23
28
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
24
29
|
randomizeFingerprint: z.boolean().default(true),
|
|
@@ -342,10 +347,28 @@ export class StealthBrowserManager {
|
|
|
342
347
|
'camoufox is not installed. Run: npm install camoufox to use the Firefox-based stealth engine.'
|
|
343
348
|
);
|
|
344
349
|
}
|
|
345
|
-
|
|
350
|
+
// camoufox fixes its fingerprint — and, with geoip, its geolocation,
|
|
351
|
+
// timezone and locale — at launch, from the proxy it is launched with.
|
|
352
|
+
// So the proxy is resolved once here and reused for every context this
|
|
353
|
+
// browser serves (see createStealthContext): rotating underneath it would
|
|
354
|
+
// leave camoufox reporting the first proxy's city behind the second
|
|
355
|
+
// proxy's exit IP, which is a worse signal than not rotating at all.
|
|
356
|
+
// A rotation takes effect on the next launch, after cleanup().
|
|
357
|
+
const proxy = this.resolveProxy(validatedConfig);
|
|
358
|
+
const browser = await adapter.launch({
|
|
346
359
|
headless: true,
|
|
360
|
+
proxy,
|
|
361
|
+
// Only ask for geoip when there is a proxy to derive it from. Without
|
|
362
|
+
// one it would look up this machine's own public IP — a network call,
|
|
363
|
+
// and an external service learning our address, to confirm a location
|
|
364
|
+
// the browser is already in.
|
|
365
|
+
geoip: !!proxy,
|
|
366
|
+
blockWebRTC: validatedConfig.blockWebRTC,
|
|
367
|
+
humanize: validatedConfig.simulateHumanBehavior,
|
|
347
368
|
launchOptions: {}
|
|
348
369
|
});
|
|
370
|
+
browser[CAMOUFOX_PROXY] = proxy;
|
|
371
|
+
this.browser = browser;
|
|
349
372
|
this._launchedEngine = 'camoufox';
|
|
350
373
|
return this.browser;
|
|
351
374
|
}
|
|
@@ -424,11 +447,11 @@ export class StealthBrowserManager {
|
|
|
424
447
|
);
|
|
425
448
|
}
|
|
426
449
|
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
450
|
+
// No proxy argument here. Chromium's --proxy-server= has no field for the
|
|
451
|
+
// user:pass every residential proxy requires, and a proxy fixed at launch
|
|
452
|
+
// could never rotate: the browser it was baked into is cached for the life
|
|
453
|
+
// of the process. Both engines take the proxy per context instead — see
|
|
454
|
+
// createStealthContext.
|
|
432
455
|
|
|
433
456
|
const browser = await chromium.launch({
|
|
434
457
|
headless: true,
|
|
@@ -507,19 +530,69 @@ export class StealthBrowserManager {
|
|
|
507
530
|
serviceWorkers: 'block'
|
|
508
531
|
};
|
|
509
532
|
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
533
|
+
// The proxy rides on the context, credentials included: Chromium takes it
|
|
534
|
+
// on Target.createBrowserContext and camoufox's Juggler on
|
|
535
|
+
// Browser.setContextProxy. Both were verified against a local authenticating
|
|
536
|
+
// proxy. camoufox additionally gets one at launch, because its geoip lookup
|
|
537
|
+
// runs there — but a launch-time proxy routes traffic and drops the
|
|
538
|
+
// credentials, so the context is what actually authenticates, on both
|
|
539
|
+
// engines. camoufox stays on the proxy it was launched with: its geolocation,
|
|
540
|
+
// timezone and locale were derived from that exit IP, and rotating
|
|
541
|
+
// underneath it would leave the first proxy's city behind the second
|
|
542
|
+
// proxy's address.
|
|
543
|
+
const proxy = this._launchedEngine === 'camoufox'
|
|
544
|
+
? (this.browser[CAMOUFOX_PROXY] || null)
|
|
545
|
+
: this.resolveProxy(validatedConfig);
|
|
546
|
+
if (proxy) {
|
|
547
|
+
contextOptions.proxy = proxy;
|
|
548
|
+
}
|
|
549
|
+
|
|
516
550
|
if (this._launchedEngine === 'camoufox') {
|
|
551
|
+
// camoufox's Firefox build predates the Browser.setDefaultViewport fields
|
|
552
|
+
// playwright-core 1.62 sends (screenSize, isMobile, ...) and rejects
|
|
553
|
+
// unknown properties, so any fixed viewport fails. viewport:null skips
|
|
554
|
+
// that protocol call entirely (deviceScaleFactor/isMobile/hasTouch/screen
|
|
555
|
+
// are invalid or meaningless without a viewport). camoufox generates its
|
|
556
|
+
// own screen and window and spoofs them below the JS layer.
|
|
517
557
|
contextOptions.viewport = null;
|
|
518
558
|
delete contextOptions.deviceScaleFactor;
|
|
519
559
|
delete contextOptions.isMobile;
|
|
520
560
|
delete contextOptions.hasTouch;
|
|
521
561
|
delete contextOptions.screen;
|
|
522
562
|
delete contextOptions.serviceWorkers;
|
|
563
|
+
|
|
564
|
+
// camoufox arrives with a complete Firefox identity of its own. Ours was
|
|
565
|
+
// overwriting it, and not with a Firefox one: for this engine the browser
|
|
566
|
+
// distribution is left open, so about two thirds of camoufox contexts were
|
|
567
|
+
// handed a Chrome User-Agent on a Gecko engine. Every one of them also
|
|
568
|
+
// sent sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform — client hints
|
|
569
|
+
// Firefox has never implemented and never sends. That is a decision a
|
|
570
|
+
// detector can make from the request headers alone, before a line of
|
|
571
|
+
// script runs. Playwright still derives a correctly shaped Accept-Language
|
|
572
|
+
// from `locale`, so dropping these loses nothing real.
|
|
573
|
+
delete contextOptions.userAgent;
|
|
574
|
+
delete contextOptions.extraHTTPHeaders;
|
|
575
|
+
|
|
576
|
+
// Behind a proxy, camoufox has already derived locale, timezone and
|
|
577
|
+
// geolocation from the exit IP. Those three agree with the address the
|
|
578
|
+
// site sees; a persona drawn here does not, so it must not override them.
|
|
579
|
+
if (proxy) {
|
|
580
|
+
delete contextOptions.locale;
|
|
581
|
+
delete contextOptions.timezoneId;
|
|
582
|
+
delete contextOptions.geolocation;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Keep what we hand back honest. create_context returns this fingerprint,
|
|
586
|
+
// and reporting a Chrome user agent and a persona the browser never uses
|
|
587
|
+
// is worse than reporting nothing: null reads as "the engine owns this".
|
|
588
|
+
fingerprint.userAgent = null;
|
|
589
|
+
fingerprint.headers = {};
|
|
590
|
+
fingerprint.viewport = null;
|
|
591
|
+
fingerprint.hardware = { ...fingerprint.hardware, platform: null };
|
|
592
|
+
if (proxy) {
|
|
593
|
+
fingerprint.locale = null;
|
|
594
|
+
fingerprint.timezone = null;
|
|
595
|
+
}
|
|
523
596
|
}
|
|
524
597
|
|
|
525
598
|
const context = await this.browser.newContext(contextOptions);
|
|
@@ -590,8 +663,11 @@ export class StealthBrowserManager {
|
|
|
590
663
|
platform: fingerprint.hardware.platform,
|
|
591
664
|
locale: fingerprint.locale,
|
|
592
665
|
timezone: fingerprint.timezone,
|
|
593
|
-
// width/height only — the pool's selection weight is an internal.
|
|
594
|
-
|
|
666
|
+
// width/height only — the pool's selection weight is an internal. null on
|
|
667
|
+
// camoufox, which sizes its own window.
|
|
668
|
+
viewport: fingerprint.viewport
|
|
669
|
+
? { width: fingerprint.viewport.width, height: fingerprint.viewport.height }
|
|
670
|
+
: null
|
|
595
671
|
};
|
|
596
672
|
}
|
|
597
673
|
|
|
@@ -1249,6 +1325,24 @@ export class StealthBrowserManager {
|
|
|
1249
1325
|
* Apply advanced stealth configurations to browser context
|
|
1250
1326
|
*/
|
|
1251
1327
|
async applyAdvancedStealthConfigurations(context, config, fingerprint) {
|
|
1328
|
+
// Nothing is injected into camoufox.
|
|
1329
|
+
//
|
|
1330
|
+
// Every script below is Chromium-shaped — it deletes navigator.webdriver,
|
|
1331
|
+
// installs a window.chrome, and patches getContext, AudioContext and font
|
|
1332
|
+
// metrics from the main world. camoufox does all of that in its own
|
|
1333
|
+
// C++/Juggler layer, where there is no JS seam to find, and a page that
|
|
1334
|
+
// compares property descriptors, Function.prototype.toString output or the
|
|
1335
|
+
// main thread against a Worker sees ours and not camoufox's. Injecting on
|
|
1336
|
+
// top of an engine built to need no injection only adds back the tells the
|
|
1337
|
+
// engine was chosen to avoid.
|
|
1338
|
+
//
|
|
1339
|
+
// This does not clear deviceandbrowserinfo.com's hasInconsistentWorkerValues
|
|
1340
|
+
// on camoufox: that flag stayed set with every one of these disabled, so it
|
|
1341
|
+
// is camoufox's own worker leak, not ours. What it removes is our share.
|
|
1342
|
+
if (this._launchedEngine === 'camoufox') {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1252
1346
|
// Enhanced initialization script with comprehensive stealth measures
|
|
1253
1347
|
await context.addInitScript((locale) => {
|
|
1254
1348
|
// Remove webdriver property completely
|
|
@@ -1636,8 +1730,33 @@ export class StealthBrowserManager {
|
|
|
1636
1730
|
// script; relative importScripts/fetch inside such a worker resolve
|
|
1637
1731
|
// against the original script URL. Module workers, data:/blob: worker
|
|
1638
1732
|
// URLs and a CSP that refuses blob workers fall through to the native
|
|
1639
|
-
// constructor untouched. camoufox
|
|
1640
|
-
|
|
1733
|
+
// constructor untouched. (camoufox never reaches here — this whole method
|
|
1734
|
+
// returns early for it.)
|
|
1735
|
+
//
|
|
1736
|
+
// MEASURED COVERAGE, 2026-09-16, chromium, comparing a worker's navigator
|
|
1737
|
+
// with the main thread's:
|
|
1738
|
+
//
|
|
1739
|
+
// level new Worker('/w.js') new Worker(blob:)
|
|
1740
|
+
// medium leaks all four leaks all four
|
|
1741
|
+
// advanced matches leaks all four
|
|
1742
|
+
//
|
|
1743
|
+
// The four are platform, hardwareConcurrency, deviceMemory and languages;
|
|
1744
|
+
// WebGL's unmasked vendor/renderer leaks the same way, because the
|
|
1745
|
+
// prototype patch above lives in the window, not in a worker scope. So a
|
|
1746
|
+
// detector that builds its worker from a Blob — which needs no second
|
|
1747
|
+
// request and is the usual shape — reads straight past this at every
|
|
1748
|
+
// level, and deviceandbrowserinfo.com's hasInconsistentWorkerValues
|
|
1749
|
+
// stays set. Every one of those mismatches is ours: a worker reports the
|
|
1750
|
+
// truth, and only the main thread was spoofed.
|
|
1751
|
+
//
|
|
1752
|
+
// Closing it properly means one of two things, and both are trades:
|
|
1753
|
+
// rewrite blob/data worker sources so the patch reaches them (more
|
|
1754
|
+
// surface, and it still misses module workers and SharedWorker), or stop
|
|
1755
|
+
// spoofing in the window what cannot be spoofed in a worker (consistent,
|
|
1756
|
+
// but then the real values show — 32 cores and a SwiftShader GPU, which
|
|
1757
|
+
// is its own datacenter tell). Left as it is pending that call; do not
|
|
1758
|
+
// read the `advanced` gate as coverage.
|
|
1759
|
+
if (config.level === 'advanced') {
|
|
1641
1760
|
await context.addInitScript(({ hardware, locale }) => {
|
|
1642
1761
|
const NativeWorker = window.Worker;
|
|
1643
1762
|
if (typeof NativeWorker !== 'function') return;
|
|
@@ -1960,25 +2079,93 @@ export class StealthBrowserManager {
|
|
|
1960
2079
|
}
|
|
1961
2080
|
|
|
1962
2081
|
/**
|
|
1963
|
-
*
|
|
2082
|
+
* Parse one proxyRotation entry into the { server, username, password } shape
|
|
2083
|
+
* Playwright takes.
|
|
2084
|
+
*
|
|
2085
|
+
* Residential proxies — the only kind that helps against Cloudflare's IP
|
|
2086
|
+
* reputation check — are issued as `http://user:pass@host:port`. Those
|
|
2087
|
+
* credentials are the whole point: the previous code pushed the entry into
|
|
2088
|
+
* `--proxy-server=`, a Chromium flag with nowhere to put them, so every
|
|
2089
|
+
* authenticating proxy answered 407 and the request failed. Splitting them out
|
|
2090
|
+
* here is what lets both engines authenticate.
|
|
2091
|
+
*
|
|
2092
|
+
* A malformed entry throws rather than returning null. A proxy that silently
|
|
2093
|
+
* does not apply is the failure mode this whole path is being fixed for: the
|
|
2094
|
+
* caller believes their traffic is proxied and it is not.
|
|
1964
2095
|
*/
|
|
1965
|
-
|
|
1966
|
-
if (
|
|
2096
|
+
parseProxyEntry(entry) {
|
|
2097
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
2098
|
+
throw new Error('proxyRotation.proxies entries must be non-empty strings');
|
|
2099
|
+
}
|
|
2100
|
+
const raw = entry.trim();
|
|
2101
|
+
// `host:port` with no scheme parses as protocol "host:" and an empty host,
|
|
2102
|
+
// so give the bare form the http:// every proxy list assumes.
|
|
2103
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
2104
|
+
|
|
2105
|
+
let url;
|
|
2106
|
+
try {
|
|
2107
|
+
url = new URL(withScheme);
|
|
2108
|
+
} catch {
|
|
2109
|
+
throw new Error(`Invalid proxy "${this.redactProxy(raw)}": expected host:port or scheme://user:pass@host:port`);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const scheme = url.protocol.replace(':', '').toLowerCase();
|
|
2113
|
+
if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) {
|
|
2114
|
+
throw new Error(`Invalid proxy scheme "${scheme}": expected http, https, socks4 or socks5`);
|
|
2115
|
+
}
|
|
2116
|
+
if (!url.hostname) {
|
|
2117
|
+
throw new Error(`Invalid proxy "${this.redactProxy(raw)}": no host`);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// url.origin is the string "null" for socks4/socks5 — they are not special
|
|
2121
|
+
// schemes — so the server is rebuilt from protocol and host.
|
|
2122
|
+
const proxy = { server: `${url.protocol}//${url.host}` };
|
|
2123
|
+
// A password with a "@" or ":" in it must arrive percent-encoded to parse at
|
|
2124
|
+
// all; the proxy expects the decoded value.
|
|
2125
|
+
if (url.username) proxy.username = decodeURIComponent(url.username);
|
|
2126
|
+
if (url.password) proxy.password = decodeURIComponent(url.password);
|
|
2127
|
+
return proxy;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
/** A proxy entry with its credentials removed, for logs and get_stats. */
|
|
2131
|
+
redactProxy(entry) {
|
|
2132
|
+
return String(entry).replace(/\/\/[^/@]*@/, '//');
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* Pick the proxy this context should use, advancing the rotation when its
|
|
2137
|
+
* interval has elapsed.
|
|
2138
|
+
*
|
|
2139
|
+
* Called per context rather than per launch. The old call site ran once, at
|
|
2140
|
+
* browser launch, and the browser is cached for the life of the process — so
|
|
2141
|
+
* rotationInterval could never elapse anywhere that mattered and the second
|
|
2142
|
+
* proxy in a list was never reached.
|
|
2143
|
+
*/
|
|
2144
|
+
resolveProxy(config) {
|
|
2145
|
+
const proxies = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
2146
|
+
if (!proxies.length) {
|
|
1967
2147
|
return null;
|
|
1968
2148
|
}
|
|
1969
|
-
|
|
2149
|
+
|
|
1970
2150
|
const now = Date.now();
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2151
|
+
if (this.proxyManager.currentProxy === null) {
|
|
2152
|
+
// First use takes proxies[0]. The old code advanced the index before its
|
|
2153
|
+
// first read, so a single-proxy list worked by wrapping to 0 and every
|
|
2154
|
+
// longer list silently started at the second entry.
|
|
2155
|
+
this.proxyManager.proxyIndex = 0;
|
|
2156
|
+
this.proxyManager.lastRotation = now;
|
|
2157
|
+
} else if (now - this.proxyManager.lastRotation > config.proxyRotation.rotationInterval) {
|
|
1974
2158
|
this.proxyManager.proxyIndex = (this.proxyManager.proxyIndex + 1) % proxies.length;
|
|
1975
|
-
this.proxyManager.currentProxy = proxies[this.proxyManager.proxyIndex];
|
|
1976
2159
|
this.proxyManager.lastRotation = now;
|
|
1977
|
-
|
|
1978
|
-
console.error('Rotated to proxy:', this.proxyManager.currentProxy);
|
|
1979
2160
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
2161
|
+
|
|
2162
|
+
const entry = proxies[this.proxyManager.proxyIndex % proxies.length];
|
|
2163
|
+
const parsed = this.parseProxyEntry(entry);
|
|
2164
|
+
// Only ever hold the redacted form: getStats() returns currentProxy to the
|
|
2165
|
+
// caller, and these strings carry a password.
|
|
2166
|
+
this.proxyManager.currentProxy = this.redactProxy(entry);
|
|
2167
|
+
this.proxyManager.activeProxies = proxies.map((p) => this.redactProxy(p));
|
|
2168
|
+
return parsed;
|
|
1982
2169
|
}
|
|
1983
2170
|
|
|
1984
2171
|
/**
|
|
@@ -2056,6 +2243,80 @@ export class StealthBrowserManager {
|
|
|
2056
2243
|
return Date.now() - started;
|
|
2057
2244
|
}
|
|
2058
2245
|
|
|
2246
|
+
/**
|
|
2247
|
+
* Give the page the chance to finish rendering before it is read.
|
|
2248
|
+
*
|
|
2249
|
+
* Navigation returns at DOMContentLoaded, and everything before this only
|
|
2250
|
+
* waits for the document to become non-empty (_waitOutEmptyDocument) or to
|
|
2251
|
+
* stop being an interstitial (_waitOutChallenge). A page that already has
|
|
2252
|
+
* prose and writes the part the caller came for in a load handler passed all
|
|
2253
|
+
* of those as finished, and the read landed mid-render.
|
|
2254
|
+
*
|
|
2255
|
+
* The 6.6.2 bench recorded that as a format bug — "markdown silently dropped
|
|
2256
|
+
* the verdict, text captured it" — but its two calls were two page loads and
|
|
2257
|
+
* only one of them raced. One call asking for markdown and text returns the
|
|
2258
|
+
* same content in both, because both are built from one render. The defect
|
|
2259
|
+
* was the timing.
|
|
2260
|
+
*
|
|
2261
|
+
* Two bounded waits: the page's own load event, then quiet in the DOM. What
|
|
2262
|
+
* this cannot do is predict a payload injected into a still page some
|
|
2263
|
+
* arbitrary time later — that is what the caller's `wait_for` is for.
|
|
2264
|
+
*
|
|
2265
|
+
* @returns {Promise<number>} milliseconds actually waited, 0 for a page that
|
|
2266
|
+
* was already finished.
|
|
2267
|
+
*/
|
|
2268
|
+
async _settleRender(page, { loadTimeoutMs = 3000, quietMs = 400, capMs = 2500 } = {}) {
|
|
2269
|
+
const started = Date.now();
|
|
2270
|
+
// Subresources are often what the render waits on. Bounded, because a page
|
|
2271
|
+
// with a hanging tracker request never fires load at all.
|
|
2272
|
+
await page.waitForLoadState('load', { timeout: loadTimeoutMs }).catch(() => {});
|
|
2273
|
+
const loadMs = Date.now() - started;
|
|
2274
|
+
return loadMs + await this._settleDom(page, { quietMs, capMs });
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* Resolve once the DOM has been unchanged for `quietMs`, or at `capMs`.
|
|
2279
|
+
*
|
|
2280
|
+
* A MutationObserver in the page, so this is one round trip and returns
|
|
2281
|
+
* immediately on a page that was already still. The cap bounds a page that
|
|
2282
|
+
* never stops animating.
|
|
2283
|
+
*
|
|
2284
|
+
* @returns {Promise<number>} milliseconds the DOM went on changing for — 0
|
|
2285
|
+
* when the page was already still, so a settled page reports no extra wait.
|
|
2286
|
+
*/
|
|
2287
|
+
async _settleDom(page, { quietMs = 400, capMs = 2500 } = {}) {
|
|
2288
|
+
try {
|
|
2289
|
+
return await page.evaluate(({ quiet, cap }) => new Promise((resolve) => {
|
|
2290
|
+
if (!document.documentElement) {
|
|
2291
|
+
resolve(0);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
const started = Date.now();
|
|
2295
|
+
let quietTimer;
|
|
2296
|
+
const finish = () => {
|
|
2297
|
+
observer.disconnect();
|
|
2298
|
+
clearTimeout(quietTimer);
|
|
2299
|
+
clearTimeout(capTimer);
|
|
2300
|
+
// Subtract the quiet window itself: what is worth reporting is how
|
|
2301
|
+
// long the page kept changing, not the time spent confirming it had
|
|
2302
|
+
// stopped.
|
|
2303
|
+
resolve(Math.max(0, Date.now() - started - quiet));
|
|
2304
|
+
};
|
|
2305
|
+
const observer = new MutationObserver(() => {
|
|
2306
|
+
clearTimeout(quietTimer);
|
|
2307
|
+
quietTimer = setTimeout(finish, quiet);
|
|
2308
|
+
});
|
|
2309
|
+
const capTimer = setTimeout(finish, cap);
|
|
2310
|
+
quietTimer = setTimeout(finish, quiet);
|
|
2311
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
2312
|
+
}), { quiet: quietMs, cap: capMs });
|
|
2313
|
+
} catch {
|
|
2314
|
+
// Navigated away, closed or crashed mid-settle. The read that follows
|
|
2315
|
+
// reports that properly; there is nothing to add here.
|
|
2316
|
+
return 0;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2059
2320
|
async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
|
|
2060
2321
|
let title;
|
|
2061
2322
|
try {
|
|
@@ -2100,7 +2361,13 @@ export class StealthBrowserManager {
|
|
|
2100
2361
|
// up, so the interstitial came back as success:true (R17, 2026-09-04).
|
|
2101
2362
|
// An auto-solving challenge gets one bounded wait to finish first.
|
|
2102
2363
|
await this._waitOutChallenge(page);
|
|
2103
|
-
const
|
|
2364
|
+
const emptyGraceMs = await this._waitOutEmptyDocument(page);
|
|
2365
|
+
// Last: let whatever is still rendering finish. Without this the read
|
|
2366
|
+
// below can land between "the page has content" and "the page has the
|
|
2367
|
+
// content the caller came for", and a half-rendered page is returned as
|
|
2368
|
+
// a successful scrape.
|
|
2369
|
+
const renderMs = await this._settleRender(page);
|
|
2370
|
+
const gracedMs = emptyGraceMs + renderMs;
|
|
2104
2371
|
|
|
2105
2372
|
// A failure to read the document is a failure. With every read wrapped
|
|
2106
2373
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2166,8 +2433,13 @@ export class StealthBrowserManager {
|
|
|
2166
2433
|
}
|
|
2167
2434
|
});
|
|
2168
2435
|
|
|
2169
|
-
// Add request headers
|
|
2170
|
-
|
|
2436
|
+
// Add request headers — Chromium only. These carry sec-ch-ua,
|
|
2437
|
+
// sec-ch-ua-mobile and sec-ch-ua-platform, client hints Gecko does not
|
|
2438
|
+
// implement, so setting them here would put them straight back onto a
|
|
2439
|
+
// camoufox page after createStealthContext had taken them off the context.
|
|
2440
|
+
if (isChromium(page)) {
|
|
2441
|
+
await page.setExtraHTTPHeaders(fingerprint.headers);
|
|
2442
|
+
}
|
|
2171
2443
|
|
|
2172
2444
|
// Emulate realistic network conditions.
|
|
2173
2445
|
//
|
|
@@ -2556,14 +2828,52 @@ export class CamoufoxAdapter extends BrowserEngine {
|
|
|
2556
2828
|
|
|
2557
2829
|
await this._ensureMacOSLayout(camoufox);
|
|
2558
2830
|
|
|
2831
|
+
// Before any Firefox page can run: camoufox reports an uncaught page error
|
|
2832
|
+
// with no location, and playwright reads one anyway. See the guard.
|
|
2833
|
+
guardFirefoxPageErrors();
|
|
2834
|
+
|
|
2559
2835
|
// camoufox's launcher is Camoufox(options) — the package has no launch()
|
|
2560
2836
|
// export. It resolves the fetched Firefox binary (npx camoufox fetch) and
|
|
2561
2837
|
// returns a Playwright-compatible Browser. Takes `headless` directly plus
|
|
2562
2838
|
// passthrough Playwright Firefox launch options.
|
|
2563
|
-
|
|
2839
|
+
//
|
|
2840
|
+
// The snake_case names below are camoufox's own option names. They are the
|
|
2841
|
+
// reason to run this engine at all: camoufox spoofs at the C++/Juggler
|
|
2842
|
+
// level, where a page cannot see the seam, and every one of these was
|
|
2843
|
+
// simply not being passed — camoufox ran with its own features off.
|
|
2844
|
+
const options = {
|
|
2564
2845
|
headless: config.headless !== false,
|
|
2565
2846
|
...config.launchOptions
|
|
2566
|
-
}
|
|
2847
|
+
};
|
|
2848
|
+
// A bare `{ server }` is fine here; camoufox normalises both shapes.
|
|
2849
|
+
if (config.proxy) options.proxy = config.proxy;
|
|
2850
|
+
// geoip derives longitude, latitude, timezone, country and locale from the
|
|
2851
|
+
// proxy's exit IP, which is the one thing that makes a proxied browser
|
|
2852
|
+
// coherent. It costs a request through the proxy, and the first ever call
|
|
2853
|
+
// downloads MaxMind's city database (~60 MB) into camoufox's install dir.
|
|
2854
|
+
if (config.geoip) options.geoip = true;
|
|
2855
|
+
// Blocking WebRTC is itself a signal. With geoip camoufox instead reports
|
|
2856
|
+
// the proxy's exit IP through WebRTC, which is the coherent answer — so
|
|
2857
|
+
// `blockWebRTC: false` is the stealthier setting behind a proxy.
|
|
2858
|
+
if (config.blockWebRTC) options.block_webrtc = true;
|
|
2859
|
+
// Native cursor humanization: camoufox moves the pointer along a plausible
|
|
2860
|
+
// path rather than teleporting it.
|
|
2861
|
+
if (config.humanize) options.humanize = true;
|
|
2862
|
+
|
|
2863
|
+
try {
|
|
2864
|
+
return await camoufox.Camoufox(options);
|
|
2865
|
+
} catch (err) {
|
|
2866
|
+
if (options.geoip) {
|
|
2867
|
+
// Do not quietly retry without geoip. A proxied camoufox whose
|
|
2868
|
+
// geolocation says one country while its exit IP says another is a
|
|
2869
|
+
// cleaner detection signal than an unproxied one.
|
|
2870
|
+
throw new Error(
|
|
2871
|
+
`camoufox failed to launch with geoip through the configured proxy: ${err.message}. ` +
|
|
2872
|
+
'Check the proxy credentials and that the proxy can reach the internet.'
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
throw err;
|
|
2876
|
+
}
|
|
2567
2877
|
}
|
|
2568
2878
|
|
|
2569
2879
|
/**
|
|
@@ -6,6 +6,23 @@
|
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'crypto';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The stdio transport frames one JSON-RPC message at a time, and since SDK
|
|
11
|
+
* 1.30 its ReadBuffer CLOSES the transport when a message runs past
|
|
12
|
+
* STDIO_MESSAGE_CEILING_BYTES: the whole session dies, every tool with it, and
|
|
13
|
+
* the overflow is not recoverable (modelcontextprotocol/typescript-sdk#2793).
|
|
14
|
+
* A full-page PNG of a long article is 17.4 MB — en.wikipedia.org/wiki/World_War_II
|
|
15
|
+
* is 61,341px tall — and JPEG only brings it to 11.3 MB, so neither format saves
|
|
16
|
+
* a caller who passes full_page (R23, 2026-09-13).
|
|
17
|
+
*
|
|
18
|
+
* Hence a budget on the blob a read may emit: base64 costs four bytes per three,
|
|
19
|
+
* and a tenth of the ceiling is left for the JSON-RPC envelope around it.
|
|
20
|
+
*/
|
|
21
|
+
const STDIO_MESSAGE_CEILING_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
export const MAX_RESOURCE_BLOB_BYTES =
|
|
23
|
+
Number(process.env.CRAWLFORGE_MAX_RESOURCE_BLOB_BYTES) ||
|
|
24
|
+
Math.floor(STDIO_MESSAGE_CEILING_BYTES * 0.9 * 3 / 4);
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* Supported resource types and their MIME types.
|
|
11
28
|
*/
|
|
@@ -84,6 +101,12 @@ export class ResourceRegistry {
|
|
|
84
101
|
createdAt: Date.now(),
|
|
85
102
|
ttl: this.defaultTtl,
|
|
86
103
|
});
|
|
104
|
+
// Handed back so the tool that took the shot can say, in the same result
|
|
105
|
+
// that carries the URI, whether that URI is readable over stdio at all.
|
|
106
|
+
return {
|
|
107
|
+
bytes: buf.length,
|
|
108
|
+
withinInlineBudget: buf.length <= MAX_RESOURCE_BLOB_BYTES,
|
|
109
|
+
};
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
/**
|
|
@@ -152,7 +175,7 @@ export class ResourceRegistry {
|
|
|
152
175
|
resources.push({
|
|
153
176
|
uri: `crawlforge://screenshot/${actionId}`,
|
|
154
177
|
name: `Screenshot ${actionId}`,
|
|
155
|
-
description: 'Screenshot from
|
|
178
|
+
description: 'Screenshot from a CrawlForge browser tool',
|
|
156
179
|
mimeType: RESOURCE_MIME.screenshot,
|
|
157
180
|
});
|
|
158
181
|
}
|
|
@@ -265,6 +288,17 @@ export class ResourceRegistry {
|
|
|
265
288
|
if (!entry || Date.now() - entry.createdAt >= entry.ttl) {
|
|
266
289
|
throw new Error(`Screenshot not found or expired: ${actionId}`);
|
|
267
290
|
}
|
|
291
|
+
// Refuse rather than emit: an oversized message costs the caller their
|
|
292
|
+
// whole session, and this error costs them one read they can act on.
|
|
293
|
+
if (entry.data.length > MAX_RESOURCE_BLOB_BYTES) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Screenshot ${actionId} is ${entry.data.length} bytes, over the ` +
|
|
296
|
+
`${MAX_RESOURCE_BLOB_BYTES}-byte limit for one MCP message. Returning it would ` +
|
|
297
|
+
`close the connection instead of failing this read, so it is refused. Take the ` +
|
|
298
|
+
`shot again without full_page, or as format:"jpeg" with a lower quality, or ` +
|
|
299
|
+
`scoped to one element with selector.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
268
302
|
return {
|
|
269
303
|
contents: [{
|
|
270
304
|
uri,
|
|
@@ -68,6 +68,30 @@ Operations: `configure`, `enable`, `disable`, `create_context`, `create_page`,
|
|
|
68
68
|
|
|
69
69
|
Full decision table: [engine selection](references/engine-selection.md).
|
|
70
70
|
|
|
71
|
+
### Proxies
|
|
72
|
+
|
|
73
|
+
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. Route
|
|
75
|
+
through your own residential proxy — CrawlForge supplies none.
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"tool": "stealth_mode",
|
|
80
|
+
"params": {
|
|
81
|
+
"operation": "scrape", "url": "https://protected-site.com", "engine": "camoufox",
|
|
82
|
+
"stealthConfig": {
|
|
83
|
+
"proxyRotation": { "enabled": true, "proxies": ["http://user:p%40ss@gw.provider.net:8080"] }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Credentials go in the URL, percent-encoded if the password contains `@`, `:` or
|
|
90
|
+
`/`. `http`, `https`, `socks4` and `socks5` are accepted. With a proxy, camoufox
|
|
91
|
+
derives its timezone, locale and geolocation from the exit IP, so the browser
|
|
92
|
+
agrees with the address the site sees — that lookup happens at launch, so a
|
|
93
|
+
camoufox browser keeps one proxy until `cleanup`.
|
|
94
|
+
|
|
71
95
|
### CLI
|
|
72
96
|
|
|
73
97
|
```bash
|
|
@@ -170,6 +170,21 @@ function withJsResult(result) {
|
|
|
170
170
|
return { ...result, jsResult: result.result.result };
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The gate's warnings — a respect_robots override, a crawl-delay note — are
|
|
175
|
+
* what the shared parameter description promises the caller gets back
|
|
176
|
+
* ("returns a warning in the response"). `scrape` publishes them through
|
|
177
|
+
* unifiedScrape; the browser path dropped them on the floor, so a session that
|
|
178
|
+
* overrode robots.txt was told nothing at all (R23, 2026-09-13).
|
|
179
|
+
*
|
|
180
|
+
* Last navigation wins, the same rule `__crawlforgeNavigation` follows: the
|
|
181
|
+
* warnings describe the hop the caller just made, not every hop of the session.
|
|
182
|
+
*/
|
|
183
|
+
function gateWarningFields(page) {
|
|
184
|
+
const warnings = page?.__crawlforgeGateWarnings;
|
|
185
|
+
return warnings?.length ? { warnings } : {};
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
export class BrowserSessionTool {
|
|
174
189
|
constructor(options = {}) {
|
|
175
190
|
const {
|
|
@@ -278,7 +293,8 @@ export class BrowserSessionTool {
|
|
|
278
293
|
viewportWidth: params.viewport?.width,
|
|
279
294
|
viewportHeight: params.viewport?.height,
|
|
280
295
|
timeout: params.timeout,
|
|
281
|
-
respectRobots: params.respect_robots
|
|
296
|
+
respectRobots: params.respect_robots,
|
|
297
|
+
tool: 'browser_session'
|
|
282
298
|
};
|
|
283
299
|
if (params.stealth) {
|
|
284
300
|
browserOptions.stealthMode = { enabled: true };
|
|
@@ -325,6 +341,7 @@ export class BrowserSessionTool {
|
|
|
325
341
|
operation: 'open',
|
|
326
342
|
...sessionInfo(session),
|
|
327
343
|
...verdictFields(verdict),
|
|
344
|
+
...gateWarningFields(page),
|
|
328
345
|
// The page is a wall, but the session behind it is real and holds a
|
|
329
346
|
// browser context — say so, or a caller reading only `success` abandons
|
|
330
347
|
// it to its TTL instead of closing it or acting through the challenge.
|
|
@@ -411,7 +428,7 @@ export class BrowserSessionTool {
|
|
|
411
428
|
const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
|
|
412
429
|
continueOnError: params.continue_on_error,
|
|
413
430
|
timeout: params.timeout,
|
|
414
|
-
browserOptions: { respectRobots: params.respect_robots }
|
|
431
|
+
browserOptions: { respectRobots: params.respect_robots, tool: 'browser_session' }
|
|
415
432
|
});
|
|
416
433
|
|
|
417
434
|
this.store.touch(session, result.finalUrl);
|
|
@@ -424,6 +441,7 @@ export class BrowserSessionTool {
|
|
|
424
441
|
...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
|
|
425
442
|
? { httpStatus: session.page.__crawlforgeNavigation.status }
|
|
426
443
|
: {}),
|
|
444
|
+
...gateWarningFields(session.page),
|
|
427
445
|
error: result.error,
|
|
428
446
|
actionResults: result.results.map(withJsResult),
|
|
429
447
|
screenshots: result.screenshots,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops a Firefox page's own JavaScript error from taking the process down.
|
|
3
|
+
*
|
|
4
|
+
* Camoufox's Juggler reports an uncaught page error without a `location`:
|
|
5
|
+
*
|
|
6
|
+
* ◀ RECV {"method":"Page.uncaughtError","params":{
|
|
7
|
+
* "frameId":"mainframe-8",
|
|
8
|
+
* "message":"TypeError: null has no properties",
|
|
9
|
+
* "stack":"@http://127.0.0.1:55712/:3:13\n"}}
|
|
10
|
+
*
|
|
11
|
+
* playwright-core 1.62's FFPage._onUncaughtError forwards that missing
|
|
12
|
+
* `params.location` into Page.addPageError, and the BrowserContext dispatcher
|
|
13
|
+
* then reads it unconditionally:
|
|
14
|
+
*
|
|
15
|
+
* location: { url: pageError.location.url, ... }
|
|
16
|
+
*
|
|
17
|
+
* The resulting "Cannot read properties of undefined (reading 'url')" is thrown
|
|
18
|
+
* inside the protocol dispatch loop, where nothing awaits it — it arrives as an
|
|
19
|
+
* uncaughtException. A plain `<script>null.boom;</script>` on any page is enough
|
|
20
|
+
* to trigger it, and bot.sannysoft.com does it in the ordinary course of
|
|
21
|
+
* running its checks.
|
|
22
|
+
*
|
|
23
|
+
* Under server.js the catch-all uncaughtException handler absorbs it and the
|
|
24
|
+
* scrape still returns, but every other consumer — the CLI, deep_research when
|
|
25
|
+
* it is embedded, any direct use of StealthBrowserManager — dies on the spot.
|
|
26
|
+
* Leaning on that handler is not a fix either: it logs a stack trace on a page
|
|
27
|
+
* that did nothing wrong, and Node treats the process state as undefined
|
|
28
|
+
* afterwards.
|
|
29
|
+
*
|
|
30
|
+
* So the missing value is filled in at its source. Firefox told us a page error
|
|
31
|
+
* happened but not where; the zeroed location says exactly that, and everything
|
|
32
|
+
* downstream — including the `pageerror` event a caller can listen for —
|
|
33
|
+
* behaves normally. Chromium and WebKit always send a location, so they never
|
|
34
|
+
* reach the fallback.
|
|
35
|
+
*
|
|
36
|
+
* Reaching into playwright's internals is deliberate and bounded: this patches
|
|
37
|
+
* one method, only when it exists and has not already been patched, and any
|
|
38
|
+
* failure leaves the original in place. A playwright that fixes this upstream,
|
|
39
|
+
* or renames the method, turns it into a no-op rather than a breakage. The
|
|
40
|
+
* `playwright-core/lib/coreBundle` specifier is one playwright-core publishes in
|
|
41
|
+
* its own `exports` map, and it resolves to the same module instance playwright
|
|
42
|
+
* itself loads. playwright-core is not declared as a dependency on purpose: it
|
|
43
|
+
* must stay the exact version playwright pins, so it is only ever reached
|
|
44
|
+
* through the copy playwright brought.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createRequire } from 'module';
|
|
48
|
+
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
|
|
51
|
+
// Firefox reports that an error happened but not where. Zeros say "unknown"
|
|
52
|
+
// without inventing a file or a line number.
|
|
53
|
+
const UNKNOWN_LOCATION = Object.freeze({ url: '', lineNumber: 0, columnNumber: 0 });
|
|
54
|
+
|
|
55
|
+
let applied = false;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Idempotent, best-effort, and safe to call before every Firefox/Camoufox
|
|
59
|
+
* launch. Returns true when the guard is in place (including when a previous
|
|
60
|
+
* call installed it), false when this playwright build did not match.
|
|
61
|
+
*/
|
|
62
|
+
export function guardFirefoxPageErrors() {
|
|
63
|
+
if (applied) return true;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const bundle = require('playwright-core/lib/coreBundle');
|
|
67
|
+
const prototype = bundle?.server?.Page?.prototype;
|
|
68
|
+
if (!prototype || typeof prototype.addPageError !== 'function') return false;
|
|
69
|
+
if (prototype.addPageError.__crawlforgeGuarded) {
|
|
70
|
+
applied = true;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const original = prototype.addPageError;
|
|
75
|
+
function addPageError(error, location) {
|
|
76
|
+
return original.call(this, error, location || UNKNOWN_LOCATION);
|
|
77
|
+
}
|
|
78
|
+
addPageError.__crawlforgeGuarded = true;
|
|
79
|
+
prototype.addPageError = addPageError;
|
|
80
|
+
|
|
81
|
+
applied = true;
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
// A playwright that moved or sealed this is not a reason to fail a launch;
|
|
85
|
+
// the crash it prevents is rarer than the launch it would break.
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Test seam: forget that the guard was installed. Does not un-patch. */
|
|
91
|
+
export function _resetFirefoxPageErrorGuard() {
|
|
92
|
+
applied = false;
|
|
93
|
+
}
|