crawlforge-mcp-server 6.7.0 → 6.9.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 +19 -2
- package/README.md +21 -3
- package/package.json +4 -2
- package/server.js +51 -27
- package/src/constants/config.js +22 -0
- package/src/core/AgentOrchestrator.js +123 -25
- package/src/core/AuthManager.js +10 -1
- 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-deep-research/SKILL.md +1 -1
- 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/agent/agent.js +29 -9
- package/src/tools/agent/escalation.js +40 -0
- 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
|
@@ -11,19 +11,34 @@
|
|
|
11
11
|
import { chromium } from 'playwright';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import crypto from 'crypto';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { createRequire } from 'module';
|
|
14
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
15
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
16
20
|
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
17
|
-
import {
|
|
21
|
+
import { looksLikeInterstitial } from '../utils/challengeDetection.js';
|
|
18
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
|
+
import { serverStealthProxies } from '../constants/config.js';
|
|
19
24
|
|
|
20
25
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
21
26
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
22
27
|
|
|
28
|
+
// The Chrome major claimed when the installed binary cannot be read at all.
|
|
29
|
+
// Only ever a floor: installedChromeVersion() prefers playwright-core's own
|
|
30
|
+
// browsers.json, and a launched browser's real version overrides both.
|
|
31
|
+
const FALLBACK_CHROME_MAJOR = 151;
|
|
32
|
+
|
|
23
33
|
// The proxy a camoufox browser was launched with, kept on the browser itself so
|
|
24
34
|
// it survives being parked and restored by an engine switch.
|
|
25
35
|
const CAMOUFOX_PROXY = Symbol('crawlforge.camoufoxProxy');
|
|
26
36
|
|
|
37
|
+
// The locale that camoufox browser was launched with — null when geoip derived
|
|
38
|
+
// one from the proxy's exit IP and we therefore do not know it. Kept on the
|
|
39
|
+
// browser for the same reason as the proxy: camoufox fixes it at launch.
|
|
40
|
+
const CAMOUFOX_LOCALE = Symbol('crawlforge.camoufoxLocale');
|
|
41
|
+
|
|
27
42
|
const StealthConfigSchema = z.object({
|
|
28
43
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
29
44
|
randomizeFingerprint: z.boolean().default(true),
|
|
@@ -66,8 +81,11 @@ const StealthConfigSchema = z.object({
|
|
|
66
81
|
hardwareSpoofing: z.boolean().default(true)
|
|
67
82
|
}).optional(),
|
|
68
83
|
|
|
69
|
-
// C2: browser engine selection — '
|
|
70
|
-
|
|
84
|
+
// C2: browser engine selection — 'auto' (default), 'chromium' or 'camoufox'
|
|
85
|
+
// (Firefox-based). 'auto' is resolved to one of the concrete two by
|
|
86
|
+
// resolveStealthEngine before anything reads this field; see it for why the
|
|
87
|
+
// default moved off chromium.
|
|
88
|
+
engine: z.enum(['chromium', 'camoufox', 'auto']).optional().default('auto')
|
|
71
89
|
});
|
|
72
90
|
|
|
73
91
|
/**
|
|
@@ -89,9 +107,73 @@ function isChromium(page) {
|
|
|
89
107
|
}
|
|
90
108
|
}
|
|
91
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a requested engine to the one that will actually launch.
|
|
112
|
+
*
|
|
113
|
+
* Camoufox is the engine that gets past the walls a stealth call is made for —
|
|
114
|
+
* it spoofs at the C++/Juggler level, where a page cannot see the seam — so
|
|
115
|
+
* 'auto', now the default everywhere, asks for it and drops to Chromium only
|
|
116
|
+
* when the package is not there. That is the semantic ResearchOrchestrator has
|
|
117
|
+
* run with since v4.6.6 (RESEARCH_STEALTH_ENGINE='auto'); it is lifted here so
|
|
118
|
+
* every stealth path shares one definition of it.
|
|
119
|
+
*
|
|
120
|
+
* A caller who NAMED an engine gets that engine and nothing else: 'camoufox'
|
|
121
|
+
* is never downgraded, so a missing install still fails at launch with the
|
|
122
|
+
* install instructions rather than quietly running a browser the caller
|
|
123
|
+
* rejected — the failure mode this whole review exists to remove.
|
|
124
|
+
*
|
|
125
|
+
* @param {'auto'|'chromium'|'camoufox'|'playwright'|null|undefined} requested
|
|
126
|
+
* 'playwright' is the tool layer's public name for chromium; null/undefined
|
|
127
|
+
* mean 'auto'.
|
|
128
|
+
* @returns {Promise<{engine: 'chromium'|'camoufox', fallbackWarning: string|null}>}
|
|
129
|
+
* fallbackWarning is set only when 'auto' wanted camoufox and could not have
|
|
130
|
+
* it, and is the one thing that stops that downgrade from being silent.
|
|
131
|
+
*/
|
|
132
|
+
export async function resolveStealthEngine(requested) {
|
|
133
|
+
if (requested === 'camoufox') return { engine: 'camoufox', fallbackWarning: null };
|
|
134
|
+
if (requested && requested !== 'auto') return { engine: 'chromium', fallbackWarning: null };
|
|
135
|
+
|
|
136
|
+
// Deployment-level answer for 'auto', because there is no correct global one.
|
|
137
|
+
// Which engine clears a wall depends on the exit IP: the 2026-09-21
|
|
138
|
+
// residential run had Camoufox clearing indeed.com where Chromium failed, and
|
|
139
|
+
// two hosted runs from a datacenter address found the exact reverse, with
|
|
140
|
+
// Camoufox winning no wall Chromium lost. An operator who knows which side of
|
|
141
|
+
// that their egress sits on can say so here; a caller naming an engine still
|
|
142
|
+
// overrides it. Anything unrecognised is ignored rather than fatal — a typo in
|
|
143
|
+
// an env var should not take stealth down.
|
|
144
|
+
const configured = String(process.env.CRAWLFORGE_STEALTH_ENGINE || '').trim().toLowerCase();
|
|
145
|
+
if (configured === 'chromium' || configured === 'playwright') {
|
|
146
|
+
return { engine: 'chromium', fallbackWarning: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let reason = 'camoufox is not installed (npm install camoufox)';
|
|
150
|
+
try {
|
|
151
|
+
if (await new CamoufoxAdapter().isAvailable()) {
|
|
152
|
+
return { engine: 'camoufox', fallbackWarning: null };
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Installed-but-broken arrives as a throw, and it is a different problem
|
|
156
|
+
// from not installed — a fetched binary that will not load is worth
|
|
157
|
+
// naming. Either way it is a reason to run Chromium, not to fail a scrape
|
|
158
|
+
// the caller never asked to pin to one engine.
|
|
159
|
+
reason = error.message;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
engine: 'chromium',
|
|
163
|
+
fallbackWarning:
|
|
164
|
+
`Stealth engine fell back to chromium: ${reason}. ` +
|
|
165
|
+
'Camoufox passes bot walls this Chromium does not; install it to use it.'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
92
169
|
export class StealthBrowserManager {
|
|
93
170
|
constructor(options = {}) {
|
|
94
171
|
this.browser = null;
|
|
172
|
+
// Why the running browser is not the engine 'auto' asked for, or null when
|
|
173
|
+
// nothing was downgraded. Kept on the instance beside _launchedEngine so a
|
|
174
|
+
// tool layer can tell the caller which browser actually ran — a drop to
|
|
175
|
+
// Chromium that nobody reports reads as a camoufox result that failed.
|
|
176
|
+
this._engineFallbackWarning = null;
|
|
95
177
|
this._maxContexts = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10);
|
|
96
178
|
this.contexts = this._createContextPool();
|
|
97
179
|
// D2.2: fingerprints Map is capped at _maxContexts to prevent unbounded growth.
|
|
@@ -154,25 +236,19 @@ export class StealthBrowserManager {
|
|
|
154
236
|
// UA thirty majors behind the engine — the pool sat at 119–121 while
|
|
155
237
|
// Chrome 152 shipped — is a staleness tell for anything that reads
|
|
156
238
|
// sec-ch-ua (R15, 2026-09-04).
|
|
239
|
+
//
|
|
240
|
+
// The Chrome major is now read from the installed binary rather than
|
|
241
|
+
// listed. A pool of four majors on one binary meant three of four personas
|
|
242
|
+
// mis-stated the version navigator.userAgentData reports — the UA claimed
|
|
243
|
+
// Chrome 149 on a 151 Chromium (2026-09-21 benchmark, ua-version-vs-binary).
|
|
244
|
+
// One string per OS is not lost entropy: Chrome's UA has been frozen since
|
|
245
|
+
// the UA-reduction rollout, so minor/build/patch are always 0.0.0 and the OS
|
|
246
|
+
// token is fixed, and that one string is the only one a real Chrome of that
|
|
247
|
+
// major sends. Variation lives in the display, fonts, hardware and persona.
|
|
248
|
+
this.chromeVersion = StealthBrowserManager.installedChromeVersion() || `${FALLBACK_CHROME_MAJOR}.0.0.0`;
|
|
249
|
+
this.chromeMajor = StealthBrowserManager.majorVersion(this.chromeVersion) || FALLBACK_CHROME_MAJOR;
|
|
157
250
|
this.userAgentPools = {
|
|
158
|
-
chrome:
|
|
159
|
-
windows: [
|
|
160
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
161
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
162
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
|
163
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
|
|
164
|
-
],
|
|
165
|
-
macos: [
|
|
166
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
167
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
168
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
|
169
|
-
],
|
|
170
|
-
linux: [
|
|
171
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
172
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
173
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
|
174
|
-
]
|
|
175
|
-
},
|
|
251
|
+
chrome: this.buildChromeUserAgents(this.chromeMajor),
|
|
176
252
|
firefox: {
|
|
177
253
|
windows: [
|
|
178
254
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
|
|
@@ -193,13 +269,9 @@ export class StealthBrowserManager {
|
|
|
193
269
|
}
|
|
194
270
|
};
|
|
195
271
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
macos: 0.15,
|
|
200
|
-
linux: 0.10
|
|
201
|
-
};
|
|
202
|
-
|
|
272
|
+
// No OS distribution any more: the persona's OS is the host's (see
|
|
273
|
+
// selectOS), not a draw from market share.
|
|
274
|
+
|
|
203
275
|
// Browser market share for realistic selection
|
|
204
276
|
this.browserDistribution = {
|
|
205
277
|
chrome: 0.65,
|
|
@@ -292,6 +364,12 @@ export class StealthBrowserManager {
|
|
|
292
364
|
*/
|
|
293
365
|
async launchStealthBrowser(config = {}) {
|
|
294
366
|
const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
|
|
367
|
+
// 'auto' has to become a concrete engine before the comparison below: an
|
|
368
|
+
// unresolved 'auto' never equals _launchedEngine, so every call would park
|
|
369
|
+
// a perfectly good browser and launch a second one beside it. This is the
|
|
370
|
+
// call that decides the engine — every other path reaches a browser
|
|
371
|
+
// through it — so it is the one that records the outcome.
|
|
372
|
+
await this._resolveConfigEngine(validatedConfig, true);
|
|
295
373
|
|
|
296
374
|
// A Chromium that was OOM-killed or crashed doesn't error on reuse — its
|
|
297
375
|
// protocol calls hang. Detect the corpse and relaunch instead.
|
|
@@ -333,11 +411,49 @@ export class StealthBrowserManager {
|
|
|
333
411
|
}
|
|
334
412
|
}
|
|
335
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Rewrite config.engine in place to the engine that will actually launch,
|
|
416
|
+
* recording any downgrade on the instance.
|
|
417
|
+
*
|
|
418
|
+
* In place, because `engine` is read again further down — the park/reuse
|
|
419
|
+
* comparison, the fingerprint's browser pool, the camoufox context surgery —
|
|
420
|
+
* and every one of them has to see the same answer. Idempotent and cheap (a
|
|
421
|
+
* concrete engine resolves to itself; isAvailable()'s require is cached by
|
|
422
|
+
* the module loader), so the context path and the launch path can each call
|
|
423
|
+
* it without arranging who goes first.
|
|
424
|
+
*
|
|
425
|
+
* @param {{engine: string}} validatedConfig — mutated
|
|
426
|
+
* @param {boolean} [ownsTheDecision] — true for the call this launch's engine
|
|
427
|
+
* is decided by (launchStealthBrowser). _doLaunchStealthBrowser re-checks
|
|
428
|
+
* the very config that call already resolved, and recording "nothing was
|
|
429
|
+
* downgraded" there would erase the downgrade the first pass had just
|
|
430
|
+
* found — which is how the fallback would go silent again.
|
|
431
|
+
* @returns {Promise<string|null>} this call's fallback warning, if any
|
|
432
|
+
*/
|
|
433
|
+
async _resolveConfigEngine(validatedConfig, ownsTheDecision = false) {
|
|
434
|
+
// A concrete engine is already the answer and cannot have fallen back.
|
|
435
|
+
if (validatedConfig.engine && validatedConfig.engine !== 'auto') {
|
|
436
|
+
if (ownsTheDecision) this._engineFallbackWarning = null;
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { engine, fallbackWarning } = await resolveStealthEngine(validatedConfig.engine);
|
|
441
|
+
validatedConfig.engine = engine;
|
|
442
|
+
this._engineFallbackWarning = fallbackWarning;
|
|
443
|
+
return fallbackWarning;
|
|
444
|
+
}
|
|
445
|
+
|
|
336
446
|
/**
|
|
337
447
|
* Actual browser launch, guarded by launchStealthBrowser's in-flight
|
|
338
448
|
* promise so only one launch can be in progress at a time.
|
|
339
449
|
*/
|
|
340
450
|
async _doLaunchStealthBrowser(validatedConfig) {
|
|
451
|
+
// Resolved again here rather than trusted: this method is reachable on its
|
|
452
|
+
// own, and an 'auto' that got this far unresolved would fall through to the
|
|
453
|
+
// chromium branch below — the exact silent downgrade 'auto' is meant to
|
|
454
|
+
// report.
|
|
455
|
+
await this._resolveConfigEngine(validatedConfig);
|
|
456
|
+
|
|
341
457
|
// C2: delegate to CamoufoxAdapter when engine === 'camoufox'
|
|
342
458
|
if (validatedConfig.engine === 'camoufox') {
|
|
343
459
|
const adapter = new CamoufoxAdapter();
|
|
@@ -355,9 +471,49 @@ export class StealthBrowserManager {
|
|
|
355
471
|
// proxy's exit IP, which is a worse signal than not rotating at all.
|
|
356
472
|
// A rotation takes effect on the next launch, after cleanup().
|
|
357
473
|
const proxy = this.resolveProxy(validatedConfig);
|
|
474
|
+
// The caller's locale goes to the launcher, not to the context. camoufox
|
|
475
|
+
// sets language, Accept-Language and Intl together below the JS layer,
|
|
476
|
+
// where a Worker reads the same answer as the document; Playwright's
|
|
477
|
+
// Firefox context override reaches the document only, so asking for
|
|
478
|
+
// de-DE there produced ["de-DE"] in the page beside camoufox's own list
|
|
479
|
+
// in its worker (2026-09-21 benchmark, worker-languages). Fixed at
|
|
480
|
+
// launch, like the proxy: a later context asking for a different locale
|
|
481
|
+
// inherits the launched one until cleanup(). With a proxy, geoip derives
|
|
482
|
+
// the locale from the exit IP and must win — a persona from this call
|
|
483
|
+
// would name a country the address contradicts.
|
|
484
|
+
const locale = proxy ? null : validatedConfig.locale;
|
|
358
485
|
const browser = await adapter.launch({
|
|
359
|
-
|
|
486
|
+
// 'virtual' runs a real, windowed Firefox inside Xvfb instead of the
|
|
487
|
+
// headless build. Headless Firefox is its own tell — no window manager,
|
|
488
|
+
// no compositor, and a set of media/GL answers that differ from the
|
|
489
|
+
// browser everyone else runs — and camoufox ships this mode precisely
|
|
490
|
+
// to avoid it. Only on Linux: that is where a hosted image has no
|
|
491
|
+
// display and where the Dockerfile provides Xvfb; a Mac or Windows
|
|
492
|
+
// developer machine has neither, so plain headless stays there.
|
|
493
|
+
headless: process.platform === 'linux' ? 'virtual' : true,
|
|
360
494
|
proxy,
|
|
495
|
+
locale,
|
|
496
|
+
// camoufox draws its persona from ["windows","macos","linux"] when it
|
|
497
|
+
// is told nothing, so it claimed Windows on a Mac while still reporting
|
|
498
|
+
// an Apple M1 GPU and a `-apple-system: Mac` CSS platform hint
|
|
499
|
+
// (2026-09-21 benchmark, persona-os-vs-host). The host's OS is the only
|
|
500
|
+
// one the GPU strings, the font list and the TCP/IP fingerprint agree
|
|
501
|
+
// with, so it is the one camoufox is given.
|
|
502
|
+
//
|
|
503
|
+
// KNOWN NOT TO TAKE EFFECT on camoufox npm 0.1.19, measured 2026-09-21:
|
|
504
|
+
// ten launches asking for "macos" on a Mac produced a Mac persona about
|
|
505
|
+
// one time in three, which is the market-share draw, not the answer to a
|
|
506
|
+
// constraint. The client forwards `{ screen, os }` to
|
|
507
|
+
// fingerprint-generator (chunk-QNWJYPXY.js `generateFingerprint`), whose
|
|
508
|
+
// option for this is `operatingSystems: string[]` — `os` is not a key it
|
|
509
|
+
// knows, so it is dropped and the OS is drawn at random. The call below
|
|
510
|
+
// is the documented API and is kept: it is inert (camoufox only
|
|
511
|
+
// validates the value), it is correct the moment upstream honours it,
|
|
512
|
+
// and the alternative — generating a BrowserForge fingerprint ourselves
|
|
513
|
+
// and passing it through `fingerprint` — needs a dependency this package
|
|
514
|
+
// does not declare. Phase 2 of the stealth review owns the camoufox
|
|
515
|
+
// client; until then persona-os-vs-host stays baselined for camoufox.
|
|
516
|
+
os: this.hostOS(),
|
|
361
517
|
// Only ask for geoip when there is a proxy to derive it from. Without
|
|
362
518
|
// one it would look up this machine's own public IP — a network call,
|
|
363
519
|
// and an external service learning our address, to confirm a location
|
|
@@ -368,6 +524,7 @@ export class StealthBrowserManager {
|
|
|
368
524
|
launchOptions: {}
|
|
369
525
|
});
|
|
370
526
|
browser[CAMOUFOX_PROXY] = proxy;
|
|
527
|
+
browser[CAMOUFOX_LOCALE] = locale;
|
|
371
528
|
this.browser = browser;
|
|
372
529
|
this._launchedEngine = 'camoufox';
|
|
373
530
|
return this.browser;
|
|
@@ -379,9 +536,7 @@ export class StealthBrowserManager {
|
|
|
379
536
|
'--no-sandbox',
|
|
380
537
|
'--disable-dev-shm-usage',
|
|
381
538
|
'--disable-blink-features=AutomationControlled',
|
|
382
|
-
'--disable-web-security',
|
|
383
539
|
'--disable-features=VizDisplayCompositor',
|
|
384
|
-
'--disable-extensions',
|
|
385
540
|
'--disable-plugins',
|
|
386
541
|
'--disable-background-timer-throttling',
|
|
387
542
|
'--disable-backgrounding-occluded-windows',
|
|
@@ -397,12 +552,18 @@ export class StealthBrowserManager {
|
|
|
397
552
|
'--safebrowsing-disable-auto-update',
|
|
398
553
|
'--password-store=basic',
|
|
399
554
|
'--use-mock-keychain',
|
|
400
|
-
|
|
555
|
+
|
|
556
|
+
// A headless Chromium has no pointing device, so it reports
|
|
557
|
+
// primaryPointerType=coarse/none and hover:none — the media-query answer
|
|
558
|
+
// of a touchscreen kiosk under a desktop UA. These four blink settings
|
|
559
|
+
// (2 = HOVER_HOVER_TYPE, 4 = POINTER_FINE_TYPE) give it the mouse a
|
|
560
|
+
// desktop persona is supposed to have. Verified present in Chromium 151
|
|
561
|
+
// with `strings -a` on the binary.
|
|
562
|
+
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
|
|
563
|
+
|
|
401
564
|
// Additional stealth arguments
|
|
402
|
-
'--disable-default-apps',
|
|
403
565
|
'--disable-component-extensions-with-background-pages',
|
|
404
566
|
'--disable-background-networking',
|
|
405
|
-
'--disable-component-update',
|
|
406
567
|
'--disable-client-side-phishing-detection',
|
|
407
568
|
'--disable-domain-reliability',
|
|
408
569
|
'--disable-ipc-flooding-protection',
|
|
@@ -415,9 +576,7 @@ export class StealthBrowserManager {
|
|
|
415
576
|
if (validatedConfig.level === 'advanced') {
|
|
416
577
|
stealthArgs.push(
|
|
417
578
|
'--disable-gpu-sandbox',
|
|
418
|
-
'--disable-popup-blocking',
|
|
419
579
|
'--disable-setuid-sandbox',
|
|
420
|
-
'--disable-site-isolation-trials',
|
|
421
580
|
'--disable-threaded-animation',
|
|
422
581
|
'--disable-threaded-scrolling',
|
|
423
582
|
'--disable-in-process-stack-traces',
|
|
@@ -436,15 +595,15 @@ export class StealthBrowserManager {
|
|
|
436
595
|
);
|
|
437
596
|
}
|
|
438
597
|
|
|
439
|
-
// WebRTC blocking
|
|
598
|
+
// WebRTC blocking. The five flags that used to stand here disabled
|
|
599
|
+
// hardware codecs and extra routes — none of them stops an ICE candidate
|
|
600
|
+
// carrying the real address, which is what CreepJS read the host's IPv6
|
|
601
|
+
// out of (2026-09-21 benchmark, finding 9). This one is the switch that
|
|
602
|
+
// governs which local addresses WebRTC may use: with it, only the address
|
|
603
|
+
// the proxy already exposes is offered. Verified present in Chromium 151
|
|
604
|
+
// with `strings -a` on the binary.
|
|
440
605
|
if (validatedConfig.blockWebRTC) {
|
|
441
|
-
stealthArgs.push(
|
|
442
|
-
'--disable-webrtc-hw-decoding',
|
|
443
|
-
'--disable-webrtc-hw-encoding',
|
|
444
|
-
'--disable-webrtc-multiple-routes',
|
|
445
|
-
'--disable-webrtc-hw-vp8-encoding',
|
|
446
|
-
'--enforce-webrtc-ip-permission-check'
|
|
447
|
-
);
|
|
606
|
+
stealthArgs.push('--webrtc-ip-handling-policy=disable_non_proxied_udp');
|
|
448
607
|
}
|
|
449
608
|
|
|
450
609
|
// No proxy argument here. Chromium's --proxy-server= has no field for the
|
|
@@ -459,12 +618,27 @@ export class StealthBrowserManager {
|
|
|
459
618
|
// never reads it) — see Dockerfile.
|
|
460
619
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
|
|
461
620
|
args: stealthArgs,
|
|
621
|
+
// patchright's "marks you as a stealth driver" list. Playwright passes
|
|
622
|
+
// all four by default, so dropping them from stealthArgs above is only
|
|
623
|
+
// half the job — they have to be taken off the default set too, or the
|
|
624
|
+
// command line still carries them. --disable-web-security went with them:
|
|
625
|
+
// it is readable in one line from any page (a cross-origin fetch that
|
|
626
|
+
// should throw and does not), and it contradicts this file's own decision
|
|
627
|
+
// to leave bypassCSP unset (R15, 2026-09-04).
|
|
462
628
|
ignoreDefaultArgs: [
|
|
463
629
|
'--enable-blink-features=IdleDetection',
|
|
464
|
-
'--enable-automation'
|
|
630
|
+
'--enable-automation',
|
|
631
|
+
'--disable-component-update',
|
|
632
|
+
'--disable-default-apps',
|
|
633
|
+
'--disable-extensions',
|
|
634
|
+
'--disable-popup-blocking'
|
|
465
635
|
]
|
|
466
636
|
});
|
|
467
637
|
|
|
638
|
+
// A hosted image's system Chromium may not be the version browsers.json
|
|
639
|
+
// named, and the UA has to state the version that is actually running.
|
|
640
|
+
this._alignUserAgentsWithBinary(browser);
|
|
641
|
+
|
|
468
642
|
// If this Chromium dies (OOM kill, crash), drop the handle so the next
|
|
469
643
|
// call relaunches instead of reusing a corpse. The identity guard keeps a
|
|
470
644
|
// late event from an old instance from nulling a newer one.
|
|
@@ -483,12 +657,18 @@ export class StealthBrowserManager {
|
|
|
483
657
|
*/
|
|
484
658
|
async createStealthContext(config = {}) {
|
|
485
659
|
const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
|
|
486
|
-
|
|
660
|
+
|
|
487
661
|
// Always go through launchStealthBrowser: it returns the running browser
|
|
488
662
|
// when the engine matches, and closes + relaunches on an engine mismatch.
|
|
489
663
|
// Guarding on `!this.browser` here skipped that mismatch check, so a
|
|
490
664
|
// camoufox request silently reused an already-running chromium browser.
|
|
491
665
|
await this.launchStealthBrowser(validatedConfig);
|
|
666
|
+
// The browser that came back is the authority on which engine this context
|
|
667
|
+
// is created over, and everything below reads it: the fingerprint's browser
|
|
668
|
+
// pool branches on it, and an 'auto' left unresolved here would draw a
|
|
669
|
+
// Chrome persona for a Firefox browser.
|
|
670
|
+
validatedConfig.engine = this._launchedEngine ?? validatedConfig.engine;
|
|
671
|
+
const engineFallbackWarning = this._engineFallbackWarning;
|
|
492
672
|
|
|
493
673
|
// Generate fingerprint for this context
|
|
494
674
|
const fingerprint = this.generateAdvancedFingerprint(validatedConfig);
|
|
@@ -568,16 +748,23 @@ export class StealthBrowserManager {
|
|
|
568
748
|
// sent sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform — client hints
|
|
569
749
|
// Firefox has never implemented and never sends. That is a decision a
|
|
570
750
|
// detector can make from the request headers alone, before a line of
|
|
571
|
-
// script runs.
|
|
572
|
-
// from `locale`, so dropping these loses nothing real.
|
|
751
|
+
// script runs.
|
|
573
752
|
delete contextOptions.userAgent;
|
|
574
753
|
delete contextOptions.extraHTTPHeaders;
|
|
575
754
|
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
755
|
+
// The locale goes the same way, proxy or no proxy — but it is not
|
|
756
|
+
// dropped: _doLaunchStealthBrowser hands the caller's locale to camoufox
|
|
757
|
+
// itself, which sets language, Accept-Language and Intl together below
|
|
758
|
+
// the JS layer. Playwright's Firefox context override reaches the
|
|
759
|
+
// document only, so setting it here reported ["de-DE"] in the page beside
|
|
760
|
+
// camoufox's own list in its worker (2026-09-21 benchmark,
|
|
761
|
+
// worker-languages).
|
|
762
|
+
delete contextOptions.locale;
|
|
763
|
+
|
|
764
|
+
// Behind a proxy, camoufox has already derived timezone and geolocation
|
|
765
|
+
// from the exit IP. Those agree with the address the site sees; a persona
|
|
766
|
+
// drawn here does not, so it must not override them.
|
|
579
767
|
if (proxy) {
|
|
580
|
-
delete contextOptions.locale;
|
|
581
768
|
delete contextOptions.timezoneId;
|
|
582
769
|
delete contextOptions.geolocation;
|
|
583
770
|
}
|
|
@@ -589,8 +776,11 @@ export class StealthBrowserManager {
|
|
|
589
776
|
fingerprint.headers = {};
|
|
590
777
|
fingerprint.viewport = null;
|
|
591
778
|
fingerprint.hardware = { ...fingerprint.hardware, platform: null };
|
|
779
|
+
// The locale camoufox was actually launched with, which is this call's
|
|
780
|
+
// only when this call is the one that launched it — null behind a proxy,
|
|
781
|
+
// where geoip derived a locale we never see.
|
|
782
|
+
fingerprint.locale = this.browser[CAMOUFOX_LOCALE] ?? null;
|
|
592
783
|
if (proxy) {
|
|
593
|
-
fingerprint.locale = null;
|
|
594
784
|
fingerprint.timezone = null;
|
|
595
785
|
}
|
|
596
786
|
}
|
|
@@ -605,7 +795,11 @@ export class StealthBrowserManager {
|
|
|
605
795
|
// D2.2: enforce LRU cap on fingerprints Map
|
|
606
796
|
this._setFingerprint(contextId, fingerprint);
|
|
607
797
|
|
|
608
|
-
|
|
798
|
+
// The engine and its warning ride back with the context, not only on the
|
|
799
|
+
// instance: the instance fields follow the browser, which the next call
|
|
800
|
+
// may switch, and the caller of THIS context still has to be able to say
|
|
801
|
+
// which browser it got.
|
|
802
|
+
return { context, contextId, fingerprint, engine: validatedConfig.engine, engineFallbackWarning };
|
|
609
803
|
}
|
|
610
804
|
|
|
611
805
|
/**
|
|
@@ -702,17 +896,33 @@ export class StealthBrowserManager {
|
|
|
702
896
|
|
|
703
897
|
/**
|
|
704
898
|
* Choose a single OS ('windows' | 'macos' | 'linux') for a fingerprint.
|
|
705
|
-
* A custom UA pins the OS to whatever that UA reports;
|
|
706
|
-
*
|
|
899
|
+
* A custom UA pins the OS to whatever that UA reports; every other path takes
|
|
900
|
+
* the host's OS.
|
|
901
|
+
*
|
|
902
|
+
* A persona drawn at random claimed Windows, then Linux, on a macOS host
|
|
903
|
+
* (2026-09-21 benchmark, persona-os-vs-host). Nothing about the machine
|
|
904
|
+
* follows the draw: the TCP/IP fingerprint is the host kernel's, the GPU
|
|
905
|
+
* strings and the CSS platform hints are the host's, and a WebGL renderer
|
|
906
|
+
* reading "Apple M1" under a Windows UA is a cleaner signal than no spoofing
|
|
907
|
+
* at all. So the OS is the one thing in this fingerprint that is not
|
|
908
|
+
* randomised — it is observed.
|
|
707
909
|
*/
|
|
708
910
|
selectOS(config = {}) {
|
|
709
911
|
if (config.customUserAgent) {
|
|
710
912
|
return this.inferOSFromUserAgent(config.customUserAgent);
|
|
711
913
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
914
|
+
return this.hostOS();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The host's own OS in the vocabulary the pools and generators use. Anything
|
|
919
|
+
* that is neither macOS nor Windows is treated as linux: it is the only other
|
|
920
|
+
* desktop persona modelled, and the closest to a BSD host's TCP fingerprint.
|
|
921
|
+
*/
|
|
922
|
+
hostOS() {
|
|
923
|
+
if (process.platform === 'darwin') return 'macos';
|
|
924
|
+
if (process.platform === 'win32') return 'windows';
|
|
925
|
+
return 'linux';
|
|
716
926
|
}
|
|
717
927
|
|
|
718
928
|
/**
|
|
@@ -724,6 +934,64 @@ export class StealthBrowserManager {
|
|
|
724
934
|
return 'windows';
|
|
725
935
|
}
|
|
726
936
|
|
|
937
|
+
/**
|
|
938
|
+
* The version of the Chromium that is actually installed, as
|
|
939
|
+
* `151.0.7922.34`. playwright-core's browsers.json names the build it
|
|
940
|
+
* downloads — the same file Scrapling reads — and is the only source
|
|
941
|
+
* available before a browser is launched; once one is running,
|
|
942
|
+
* _alignUserAgentsWithBinary() corrects for a hosted image that pointed
|
|
943
|
+
* PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH at a Chromium of its own.
|
|
944
|
+
*/
|
|
945
|
+
static installedChromeVersion() {
|
|
946
|
+
try {
|
|
947
|
+
const require = createRequire(import.meta.url);
|
|
948
|
+
const file = path.join(path.dirname(require.resolve('playwright-core')), 'browsers.json');
|
|
949
|
+
const entry = JSON.parse(fs.readFileSync(file, 'utf8')).browsers
|
|
950
|
+
.find((browser) => browser.name === 'chromium');
|
|
951
|
+
return entry?.browserVersion || null;
|
|
952
|
+
} catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** The leading integer of a version string: `151.0.7922.34` -> 151. */
|
|
958
|
+
static majorVersion(version) {
|
|
959
|
+
const match = /^(\d+)/.exec(String(version ?? '').trim());
|
|
960
|
+
return match ? Number(match[1]) : null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* One Chrome user agent per OS at `major`. Chrome froze every other token of
|
|
965
|
+
* the string years ago, so these are literal, not templates with room.
|
|
966
|
+
*/
|
|
967
|
+
buildChromeUserAgents(major) {
|
|
968
|
+
const chrome = `AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
969
|
+
return {
|
|
970
|
+
windows: [`Mozilla/5.0 (Windows NT 10.0; Win64; x64) ${chrome}`],
|
|
971
|
+
macos: [`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ${chrome}`],
|
|
972
|
+
linux: [`Mozilla/5.0 (X11; Linux x86_64) ${chrome}`]
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Re-derive the Chrome pool from the browser that actually launched. Only
|
|
978
|
+
* does anything when the running binary is not the one browsers.json named —
|
|
979
|
+
* a hosted image's system Chromium — which is exactly the case where the
|
|
980
|
+
* pool would otherwise mis-state the version userAgentData reports.
|
|
981
|
+
*/
|
|
982
|
+
_alignUserAgentsWithBinary(browser) {
|
|
983
|
+
let version = null;
|
|
984
|
+
try {
|
|
985
|
+
version = browser.version();
|
|
986
|
+
} catch { /* a browser that cannot be asked keeps the installed version */ }
|
|
987
|
+
if (!version || version === this.chromeVersion) return;
|
|
988
|
+
this.chromeVersion = version;
|
|
989
|
+
const major = StealthBrowserManager.majorVersion(version);
|
|
990
|
+
if (!major || major === this.chromeMajor) return;
|
|
991
|
+
this.chromeMajor = major;
|
|
992
|
+
this.userAgentPools.chrome = this.buildChromeUserAgents(major);
|
|
993
|
+
}
|
|
994
|
+
|
|
727
995
|
/**
|
|
728
996
|
* Select realistic user agent based on market distribution
|
|
729
997
|
*/
|
|
@@ -733,12 +1001,14 @@ export class StealthBrowserManager {
|
|
|
733
1001
|
}
|
|
734
1002
|
|
|
735
1003
|
if (!config.useRandomUserAgent) {
|
|
736
|
-
|
|
1004
|
+
// Not "the Windows default" any more: a fixed UA still has to be the
|
|
1005
|
+
// host's, or it contradicts everything the machine itself reports.
|
|
1006
|
+
return this.userAgentPools.chrome[this.hostOS()][0];
|
|
737
1007
|
}
|
|
738
1008
|
|
|
739
|
-
// Use the OS chosen once for this fingerprint (falls back to
|
|
740
|
-
// if called without one,
|
|
741
|
-
selectedOS = selectedOS || this.
|
|
1009
|
+
// Use the OS chosen once for this fingerprint (falls back to the host's own
|
|
1010
|
+
// if called without one, which is what selectOS would have returned).
|
|
1011
|
+
selectedOS = selectedOS || this.hostOS();
|
|
742
1012
|
|
|
743
1013
|
// Select browser based on distribution and OS compatibility
|
|
744
1014
|
let availableBrowsers = { ...this.browserDistribution };
|
|
@@ -872,24 +1142,41 @@ export class StealthBrowserManager {
|
|
|
872
1142
|
}
|
|
873
1143
|
|
|
874
1144
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
1145
|
+
* The brand list Chrome presents, in both places it is read: the sec-ch-ua
|
|
1146
|
+
* request header and navigator.userAgentData.brands.
|
|
1147
|
+
*
|
|
1148
|
+
* One source for both because the two used to disagree. The header was built
|
|
1149
|
+
* here and said "Google Chrome"; userAgentData was left to Chromium, which
|
|
1150
|
+
* filled it in from the binary and answered "HeadlessChrome 151, Chromium
|
|
1151
|
+
* 151" with no Google Chrome brand at all — rebrowser flagged the page as
|
|
1152
|
+
* Chrome for Testing (2026-09-21 benchmark, useragentdata-brands and
|
|
1153
|
+
* headless-markers). _applyEmulatedIdentity now hands this list to the
|
|
1154
|
+
* renderer, so the header and the JS API are the same list.
|
|
1155
|
+
*
|
|
878
1156
|
* @param {string} [userAgent] — the selected user agent string
|
|
1157
|
+
* @returns {Array<{brand:string, version:string}>}
|
|
879
1158
|
*/
|
|
880
|
-
|
|
1159
|
+
generateUserAgentBrands(userAgent = '') {
|
|
881
1160
|
// Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
|
|
882
|
-
// Fall back to the
|
|
883
|
-
const match = userAgent.match(/Chrome\/(\d+)/i);
|
|
884
|
-
const version = match ? match[1] :
|
|
1161
|
+
// Fall back to the installed Chromium's major if the UA is not a Chrome UA.
|
|
1162
|
+
const match = String(userAgent).match(/Chrome\/(\d+)/i);
|
|
1163
|
+
const version = match ? match[1] : String(this.chromeMajor);
|
|
885
1164
|
|
|
886
|
-
|
|
1165
|
+
return [
|
|
887
1166
|
{ brand: 'Not_A Brand', version: '8' },
|
|
888
1167
|
{ brand: 'Chromium', version },
|
|
889
1168
|
{ brand: 'Google Chrome', version }
|
|
890
1169
|
];
|
|
1170
|
+
}
|
|
891
1171
|
|
|
892
|
-
|
|
1172
|
+
/**
|
|
1173
|
+
* Generate sec-ch-ua header.
|
|
1174
|
+
* C2: brand versions are derived from the UA's Chrome major version so
|
|
1175
|
+
* sec-ch-ua and the User-Agent header stay consistent.
|
|
1176
|
+
* @param {string} [userAgent] — the selected user agent string
|
|
1177
|
+
*/
|
|
1178
|
+
generateSecChUaHeader(userAgent = '') {
|
|
1179
|
+
return this.generateUserAgentBrands(userAgent)
|
|
893
1180
|
.map(b => `"${b.brand}";v="${b.version}"`)
|
|
894
1181
|
.join(', ');
|
|
895
1182
|
}
|
|
@@ -904,7 +1191,7 @@ export class StealthBrowserManager {
|
|
|
904
1191
|
linux: '"Linux"'
|
|
905
1192
|
};
|
|
906
1193
|
|
|
907
|
-
selectedOS = selectedOS || this.
|
|
1194
|
+
selectedOS = selectedOS || this.hostOS();
|
|
908
1195
|
return platforms[selectedOS] || '"Windows"';
|
|
909
1196
|
}
|
|
910
1197
|
|
|
@@ -1169,7 +1456,7 @@ export class StealthBrowserManager {
|
|
|
1169
1456
|
* Generate realistic hardware fingerprint
|
|
1170
1457
|
*/
|
|
1171
1458
|
generateHardwareFingerprint(selectedOS) {
|
|
1172
|
-
selectedOS = selectedOS || this.
|
|
1459
|
+
selectedOS = selectedOS || this.hostOS();
|
|
1173
1460
|
|
|
1174
1461
|
const processors = [
|
|
1175
1462
|
{ cores: 4, threads: 8, name: 'Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz' },
|
|
@@ -1180,20 +1467,60 @@ export class StealthBrowserManager {
|
|
|
1180
1467
|
{ cores: 8, threads: 16, name: 'AMD Ryzen 7 3700X 8-Core Processor' }
|
|
1181
1468
|
];
|
|
1182
1469
|
|
|
1183
|
-
|
|
1470
|
+
// hardwareConcurrency and deviceMemory are observed, not drawn — the same
|
|
1471
|
+
// call taken for the persona OS, and for the same reason.
|
|
1472
|
+
//
|
|
1473
|
+
// Both are readable from a Worker. An init script does not run in one, and
|
|
1474
|
+
// Emulation.setHardwareConcurrencyOverride does not reach one either: a
|
|
1475
|
+
// Worker is a separate target and the Emulation domain is not available on
|
|
1476
|
+
// it. So a drawn value only ever landed in the document, beside the host's
|
|
1477
|
+
// real one in the worker — 16 cores against 32 (2026-09-21 benchmark,
|
|
1478
|
+
// worker-hardware-concurrency). A machine that reports its own cores is not
|
|
1479
|
+
// a signal. A machine that disagrees with itself is.
|
|
1480
|
+
const hardwareConcurrency = this.hostHardwareConcurrency();
|
|
1481
|
+
const matching = processors.filter((p) => p.threads === hardwareConcurrency);
|
|
1482
|
+
const selectedProcessor = matching.length
|
|
1483
|
+
? matching[Math.floor(Math.random() * matching.length)]
|
|
1484
|
+
// No modelled CPU has this many threads. Name one that could — the
|
|
1485
|
+
// processor string is reported in the fingerprint, never to a page.
|
|
1486
|
+
: {
|
|
1487
|
+
cores: Math.max(1, Math.round(hardwareConcurrency / 2)),
|
|
1488
|
+
threads: hardwareConcurrency,
|
|
1489
|
+
name: `AMD Ryzen ${Math.max(1, Math.round(hardwareConcurrency / 2))}-Core Processor`
|
|
1490
|
+
};
|
|
1184
1491
|
|
|
1185
1492
|
return {
|
|
1186
|
-
hardwareConcurrency
|
|
1493
|
+
hardwareConcurrency,
|
|
1187
1494
|
processor: selectedProcessor.name,
|
|
1188
1495
|
architecture: 'x86_64',
|
|
1189
1496
|
memory: Math.floor(Math.random() * 24) + 8, // 8-32 GB
|
|
1190
|
-
|
|
1191
|
-
// or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
|
|
1192
|
-
deviceMemory: Math.random() < 0.7 ? 8 : 4,
|
|
1497
|
+
deviceMemory: this.hostDeviceMemory(),
|
|
1193
1498
|
platform: this.selectRealisticPlatform(selectedOS)
|
|
1194
1499
|
};
|
|
1195
1500
|
}
|
|
1196
1501
|
|
|
1502
|
+
/**
|
|
1503
|
+
* The core count navigator.hardwareConcurrency will report: the host's own
|
|
1504
|
+
* logical CPU count, which is what Chromium answers with
|
|
1505
|
+
* (base::SysInfo::NumberOfProcessors) in the document and in every worker.
|
|
1506
|
+
*/
|
|
1507
|
+
hostHardwareConcurrency() {
|
|
1508
|
+
const count = os.cpus()?.length || 0;
|
|
1509
|
+
return count > 0 ? count : 4;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* navigator.deviceMemory as Chromium computes it: physical RAM rounded to the
|
|
1514
|
+
* nearest power of two, in GiB, then clamped to [0.25, 8]. The clamp is why
|
|
1515
|
+
* 16 or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL),
|
|
1516
|
+
* and why almost every desktop answers 8.
|
|
1517
|
+
*/
|
|
1518
|
+
hostDeviceMemory() {
|
|
1519
|
+
const gib = os.totalmem() / (1024 ** 3);
|
|
1520
|
+
if (!(gib > 0)) return 8;
|
|
1521
|
+
return Math.min(8, Math.max(0.25, 2 ** Math.round(Math.log2(gib))));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1197
1524
|
/**
|
|
1198
1525
|
* Map the chosen OS to its navigator.platform value so it stays consistent
|
|
1199
1526
|
* with the user-agent and sec-ch-ua-platform header.
|
|
@@ -1327,7 +1654,7 @@ export class StealthBrowserManager {
|
|
|
1327
1654
|
async applyAdvancedStealthConfigurations(context, config, fingerprint) {
|
|
1328
1655
|
// Nothing is injected into camoufox.
|
|
1329
1656
|
//
|
|
1330
|
-
// Every script below is Chromium-shaped — it
|
|
1657
|
+
// Every script below is Chromium-shaped — it patches navigator.webdriver,
|
|
1331
1658
|
// installs a window.chrome, and patches getContext, AudioContext and font
|
|
1332
1659
|
// metrics from the main world. camoufox does all of that in its own
|
|
1333
1660
|
// C++/Juggler layer, where there is no JS seam to find, and a page that
|
|
@@ -1344,16 +1671,48 @@ export class StealthBrowserManager {
|
|
|
1344
1671
|
}
|
|
1345
1672
|
|
|
1346
1673
|
// Enhanced initialization script with comprehensive stealth measures
|
|
1347
|
-
await context.addInitScript((
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1674
|
+
await context.addInitScript(() => {
|
|
1675
|
+
// Everything this script changes about navigator goes on
|
|
1676
|
+
// Navigator.prototype, never on the navigator instance.
|
|
1677
|
+
//
|
|
1678
|
+
// A real Chrome answers Object.getOwnPropertyNames(navigator) with an
|
|
1679
|
+
// empty array: every property it has is inherited. So one
|
|
1680
|
+
// defineProperty(navigator, …) puts the spoof's own name in a list any
|
|
1681
|
+
// page can print in a line — rebrowser's bot detector prints exactly that
|
|
1682
|
+
// list, and it read ["connection","plugins","mimeTypes","getBattery"]
|
|
1683
|
+
// (2026-09-21 bench). It is the same class of tell as the deleted
|
|
1684
|
+
// webdriver property, one level up.
|
|
1685
|
+
//
|
|
1686
|
+
// A property that is not on the prototype is left alone rather than
|
|
1687
|
+
// invented: a shape that no Chromium has is worse than the real value.
|
|
1688
|
+
const defineOnPrototype = (key, descriptor) => {
|
|
1689
|
+
if (!(key in Navigator.prototype)) return false;
|
|
1690
|
+
try {
|
|
1691
|
+
// WebIDL attributes and operations are both enumerable and
|
|
1692
|
+
// configurable on the prototype; matching that keeps the descriptor
|
|
1693
|
+
// indistinguishable from the one it replaces.
|
|
1694
|
+
Object.defineProperty(Navigator.prototype, key, Object.assign(
|
|
1695
|
+
{ configurable: true, enumerable: true }, descriptor
|
|
1696
|
+
));
|
|
1697
|
+
return true;
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// navigator.webdriver reports false; the property stays. Deleting it was
|
|
1704
|
+
// its own tell — rebrowser's bot detector marks a missing webdriver red,
|
|
1705
|
+
// because every real Chrome has the property and answers false
|
|
1706
|
+
// (2026-09-21 benchmark, navigator-webdriver). The launch flags do this
|
|
1707
|
+
// already (--disable-blink-features=AutomationControlled, and
|
|
1708
|
+
// --enable-automation taken off the default args), so on a normal launch
|
|
1709
|
+
// this is a no-op and the native prototype getter is left untouched; the
|
|
1710
|
+
// redefinition is only for a binary where the flag did not take.
|
|
1711
|
+
if (navigator.webdriver !== false) {
|
|
1712
|
+
defineOnPrototype('webdriver', { get: () => false });
|
|
1713
|
+
}
|
|
1353
1714
|
|
|
1354
1715
|
// Hide automation indicators
|
|
1355
|
-
delete window.navigator.__proto__.webdriver;
|
|
1356
|
-
delete window.navigator.webdriver;
|
|
1357
1716
|
delete window.webdriver;
|
|
1358
1717
|
delete window._phantom;
|
|
1359
1718
|
delete window.__nightmare;
|
|
@@ -1378,18 +1737,15 @@ export class StealthBrowserManager {
|
|
|
1378
1737
|
originalQuery(parameters)
|
|
1379
1738
|
);
|
|
1380
1739
|
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
get: () => 4,
|
|
1388
|
-
configurable: true
|
|
1389
|
-
});
|
|
1740
|
+
// No hardwareConcurrency here, and none anywhere else either: the
|
|
1741
|
+
// fingerprint now carries the host's own core count
|
|
1742
|
+
// (generateHardwareFingerprint), so there is nothing to override. A
|
|
1743
|
+
// defineProperty here only ever reached the document, and the worker
|
|
1744
|
+
// answering with the real count beside it was the mismatch
|
|
1745
|
+
// bot.incolumitas.com scores.
|
|
1390
1746
|
|
|
1391
1747
|
// Spoof connection
|
|
1392
|
-
|
|
1748
|
+
defineOnPrototype('connection', {
|
|
1393
1749
|
get: () => ({
|
|
1394
1750
|
effectiveType: '4g',
|
|
1395
1751
|
rtt: 50 + Math.random() * 50,
|
|
@@ -1454,7 +1810,7 @@ export class StealthBrowserManager {
|
|
|
1454
1810
|
Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
|
|
1455
1811
|
Object.defineProperty(pluginArray, plugin.name, { value: plugin });
|
|
1456
1812
|
});
|
|
1457
|
-
|
|
1813
|
+
defineOnPrototype('plugins', { get: () => pluginArray });
|
|
1458
1814
|
|
|
1459
1815
|
// Real Chrome pairs those plugins with two navigator.mimeTypes entries
|
|
1460
1816
|
// (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
|
|
@@ -1473,31 +1829,29 @@ export class StealthBrowserManager {
|
|
|
1473
1829
|
Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
|
|
1474
1830
|
Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
|
|
1475
1831
|
});
|
|
1476
|
-
|
|
1832
|
+
defineOnPrototype('mimeTypes', { get: () => mimeTypeArray });
|
|
1477
1833
|
}
|
|
1478
1834
|
}
|
|
1479
1835
|
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
// the
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
return
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
});
|
|
1500
|
-
};
|
|
1836
|
+
// No languages here either. The persona's language list is handed to
|
|
1837
|
+
// Chromium as the accept-language override in _applyEmulatedIdentity, so
|
|
1838
|
+
// the document and every worker parse the same list; this defineProperty
|
|
1839
|
+
// reached only the document, and the worker's ["en-US"] against the
|
|
1840
|
+
// document's ["en-US", "en"] was the difference the benchmark measured
|
|
1841
|
+
// (2026-09-21, worker-languages).
|
|
1842
|
+
|
|
1843
|
+
// Mock battery API with realistic values. A data property, not an
|
|
1844
|
+
// accessor: getBattery is a WebIDL operation, so on a real Navigator
|
|
1845
|
+
// prototype it is a writable function value and not a getter.
|
|
1846
|
+
defineOnPrototype('getBattery', {
|
|
1847
|
+
writable: true,
|
|
1848
|
+
value: function getBattery() {
|
|
1849
|
+
return Promise.resolve({
|
|
1850
|
+
charging: true,
|
|
1851
|
+
chargingTime: 0,
|
|
1852
|
+
dischargingTime: Infinity,
|
|
1853
|
+
level: 0.8 + Math.random() * 0.19 // 80-99%
|
|
1854
|
+
});
|
|
1501
1855
|
}
|
|
1502
1856
|
});
|
|
1503
1857
|
|
|
@@ -1529,7 +1883,7 @@ export class StealthBrowserManager {
|
|
|
1529
1883
|
return originalPrepareStackTrace.call(this, error, filteredStack);
|
|
1530
1884
|
};
|
|
1531
1885
|
}
|
|
1532
|
-
}
|
|
1886
|
+
});
|
|
1533
1887
|
|
|
1534
1888
|
// WebRTC leak prevention with advanced spoofing
|
|
1535
1889
|
if (config.blockWebRTC) {
|
|
@@ -1703,73 +2057,56 @@ export class StealthBrowserManager {
|
|
|
1703
2057
|
}, fingerprint.mediaDevices);
|
|
1704
2058
|
}
|
|
1705
2059
|
|
|
1706
|
-
// Hardware
|
|
1707
|
-
|
|
2060
|
+
// Hardware. Nothing is defined onto the document's navigator any more.
|
|
2061
|
+
// hardwareConcurrency and deviceMemory are the host's own values
|
|
2062
|
+
// (generateHardwareFingerprint), so there is nothing left to override — a
|
|
2063
|
+
// defineProperty that sets a property to the value it already has is a JS
|
|
2064
|
+
// seam bought for nothing. platform is set through CDP
|
|
2065
|
+
// (_applyEmulatedIdentity), and with the persona OS taken from the host it
|
|
2066
|
+
// is usually the truth as well.
|
|
2067
|
+
//
|
|
2068
|
+
// Init scripts never run inside a dedicated Worker, so a detector that
|
|
2069
|
+
// compares navigator in a worker with the main thread saw the real
|
|
2070
|
+
// platform, deviceMemory and hardwareConcurrency beside the spoofed
|
|
2071
|
+
// ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
|
|
2072
|
+
// R17 2026-09-04). At the advanced level, classic workers start from a
|
|
2073
|
+
// blob that patches WorkerNavigator and then importScripts() the real
|
|
2074
|
+
// script; relative importScripts/fetch inside such a worker resolve
|
|
2075
|
+
// against the original script URL. Module workers, data:/blob: worker
|
|
2076
|
+
// URLs and a CSP that refuses blob workers fall through to the native
|
|
2077
|
+
// constructor untouched. (camoufox never reaches here — this whole method
|
|
2078
|
+
// returns early for it.)
|
|
2079
|
+
//
|
|
2080
|
+
// MEASURED COVERAGE, 2026-09-16, chromium, comparing a worker's navigator
|
|
2081
|
+
// with the main thread's:
|
|
2082
|
+
//
|
|
2083
|
+
// level new Worker('/w.js') new Worker(blob:)
|
|
2084
|
+
// medium leaks all four leaks all four
|
|
2085
|
+
// advanced matches leaks all four
|
|
2086
|
+
//
|
|
2087
|
+
// The four are platform, hardwareConcurrency, deviceMemory and languages.
|
|
2088
|
+
// So a detector that builds its worker from a Blob — which needs no second
|
|
2089
|
+
// request and is the usual shape — read straight past this at every level.
|
|
2090
|
+
//
|
|
2091
|
+
// THE CALL, taken 2026-09-21: stop drawing what a worker can contradict.
|
|
2092
|
+
// hardwareConcurrency and deviceMemory are the host's own values now, and
|
|
2093
|
+
// languages comes from the context locale, which Playwright applies to the
|
|
2094
|
+
// worker as well as the document — all three agree without a wrapper.
|
|
2095
|
+
// That leaves platform, and only for a caller who pins a customUserAgent
|
|
2096
|
+
// from another OS: the persona's OS is otherwise the host's, so the spoofed
|
|
2097
|
+
// platform IS the real one and the wrapper would rewrite every worker
|
|
2098
|
+
// source on the page to change nothing. Hence the guard — the surface is
|
|
2099
|
+
// paid for only when it buys something. WebGL's unmasked vendor/renderer is
|
|
2100
|
+
// still window-only and still leaks in a worker.
|
|
2101
|
+
const hostPlatform = this.selectRealisticPlatform(this.hostOS());
|
|
2102
|
+
if (config.fingerprinting?.hardwareSpoofing
|
|
2103
|
+
&& config.level === 'advanced'
|
|
2104
|
+
&& fingerprint.hardware.platform !== hostPlatform) {
|
|
1708
2105
|
await context.addInitScript((hardware) => {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
});
|
|
1712
|
-
|
|
1713
|
-
Object.defineProperty(navigator, 'platform', {
|
|
1714
|
-
get: () => hardware.platform
|
|
1715
|
-
});
|
|
1716
|
-
|
|
1717
|
-
if (navigator.deviceMemory !== undefined) {
|
|
1718
|
-
Object.defineProperty(navigator, 'deviceMemory', {
|
|
1719
|
-
get: () => hardware.deviceMemory
|
|
1720
|
-
});
|
|
1721
|
-
}
|
|
1722
|
-
}, fingerprint.hardware);
|
|
1723
|
-
|
|
1724
|
-
// Init scripts never run inside a dedicated Worker, so a detector that
|
|
1725
|
-
// compares navigator in a worker with the main thread saw the real
|
|
1726
|
-
// platform, deviceMemory and hardwareConcurrency beside the spoofed
|
|
1727
|
-
// ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
|
|
1728
|
-
// R17 2026-09-04). At the advanced level, classic workers start from a
|
|
1729
|
-
// blob that patches WorkerNavigator and then importScripts() the real
|
|
1730
|
-
// script; relative importScripts/fetch inside such a worker resolve
|
|
1731
|
-
// against the original script URL. Module workers, data:/blob: worker
|
|
1732
|
-
// URLs and a CSP that refuses blob workers fall through to the native
|
|
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') {
|
|
1760
|
-
await context.addInitScript(({ hardware, locale }) => {
|
|
1761
|
-
const NativeWorker = window.Worker;
|
|
1762
|
-
if (typeof NativeWorker !== 'function') return;
|
|
1763
|
-
const primary = locale.split('-')[0];
|
|
1764
|
-
const languages = primary === locale ? [locale] : [locale, primary];
|
|
1765
|
-
const spoof = JSON.stringify({
|
|
1766
|
-
hardwareConcurrency: hardware.hardwareConcurrency,
|
|
1767
|
-
platform: hardware.platform,
|
|
1768
|
-
deviceMemory: hardware.deviceMemory,
|
|
1769
|
-
languages,
|
|
1770
|
-
language: locale
|
|
1771
|
-
});
|
|
1772
|
-
const prelude = (scriptUrl) =>
|
|
2106
|
+
const NativeWorker = window.Worker;
|
|
2107
|
+
if (typeof NativeWorker !== 'function') return;
|
|
2108
|
+
const spoof = JSON.stringify({ platform: hardware.platform });
|
|
2109
|
+
const prelude = (scriptUrl) =>
|
|
1773
2110
|
`(() => {
|
|
1774
2111
|
const spoof = ${spoof};
|
|
1775
2112
|
const proto = self.WorkerNavigator && self.WorkerNavigator.prototype;
|
|
@@ -1777,11 +2114,7 @@ export class StealthBrowserManager {
|
|
|
1777
2114
|
try { Object.defineProperty(proto, key, { get: () => value, configurable: true }); } catch (e) {}
|
|
1778
2115
|
};
|
|
1779
2116
|
if (proto) {
|
|
1780
|
-
define('hardwareConcurrency', spoof.hardwareConcurrency);
|
|
1781
2117
|
define('platform', spoof.platform);
|
|
1782
|
-
if ('deviceMemory' in self.navigator) define('deviceMemory', spoof.deviceMemory);
|
|
1783
|
-
define('languages', spoof.languages);
|
|
1784
|
-
define('language', spoof.language);
|
|
1785
2118
|
}
|
|
1786
2119
|
const base = ${JSON.stringify(scriptUrl)};
|
|
1787
2120
|
const resolve = (u) => { try { return new URL(String(u), base).href; } catch (e) { return u; } };
|
|
@@ -1799,25 +2132,24 @@ export class StealthBrowserManager {
|
|
|
1799
2132
|
}
|
|
1800
2133
|
})();
|
|
1801
2134
|
importScripts(${JSON.stringify(scriptUrl)});`;
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
}
|
|
1810
|
-
} catch (e) {
|
|
1811
|
-
// CSP refused the blob URL or the URL failed to parse: native worker.
|
|
2135
|
+
const Wrapped = function Worker(scriptURL, options) {
|
|
2136
|
+
try {
|
|
2137
|
+
const isModule = options && options.type === 'module';
|
|
2138
|
+
const absolute = new URL(String(scriptURL), location.href).href;
|
|
2139
|
+
if (!isModule && /^https?:/.test(absolute)) {
|
|
2140
|
+
const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
|
|
2141
|
+
return new NativeWorker(URL.createObjectURL(blob), options);
|
|
1812
2142
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2143
|
+
} catch (e) {
|
|
2144
|
+
// CSP refused the blob URL or the URL failed to parse: native worker.
|
|
2145
|
+
}
|
|
2146
|
+
return new NativeWorker(scriptURL, options);
|
|
2147
|
+
};
|
|
2148
|
+
Wrapped.prototype = NativeWorker.prototype;
|
|
2149
|
+
Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
|
|
2150
|
+
Wrapped.toString = () => 'function Worker() { [native code] }';
|
|
2151
|
+
window.Worker = Wrapped;
|
|
2152
|
+
}, fingerprint.hardware);
|
|
1821
2153
|
}
|
|
1822
2154
|
|
|
1823
2155
|
// Font spoofing
|
|
@@ -1899,11 +2231,22 @@ export class StealthBrowserManager {
|
|
|
1899
2231
|
// Battery API spoofing
|
|
1900
2232
|
if (config.antiDetection?.spoofBatteryAPI) {
|
|
1901
2233
|
await context.addInitScript((battery) => {
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2234
|
+
// On the prototype, where getBattery natively lives. A plain
|
|
2235
|
+
// `navigator.getBattery = …` assignment makes it an OWN property of the
|
|
2236
|
+
// navigator instance, which is the list rebrowser prints (2026-09-21
|
|
2237
|
+
// bench) — a real Chrome's Object.getOwnPropertyNames(navigator) is
|
|
2238
|
+
// empty.
|
|
2239
|
+
if (!('getBattery' in Navigator.prototype)) return;
|
|
2240
|
+
try {
|
|
2241
|
+
Object.defineProperty(Navigator.prototype, 'getBattery', {
|
|
2242
|
+
configurable: true,
|
|
2243
|
+
enumerable: true,
|
|
2244
|
+
writable: true,
|
|
2245
|
+
value: function getBattery() {
|
|
2246
|
+
return Promise.resolve(battery);
|
|
2247
|
+
}
|
|
2248
|
+
});
|
|
2249
|
+
} catch (e) { /* a build that refuses the redefinition keeps the real one */ }
|
|
1907
2250
|
}, fingerprint.battery);
|
|
1908
2251
|
}
|
|
1909
2252
|
}
|
|
@@ -2142,10 +2485,21 @@ export class StealthBrowserManager {
|
|
|
2142
2485
|
* proxy in a list was never reached.
|
|
2143
2486
|
*/
|
|
2144
2487
|
resolveProxy(config) {
|
|
2145
|
-
|
|
2488
|
+
// The caller's list always wins: a per-call proxyRotation is a deliberate
|
|
2489
|
+
// choice of exit address, and the server-level CRAWLFORGE_STEALTH_PROXIES
|
|
2490
|
+
// is only what this server goes out from when nobody chose one — the
|
|
2491
|
+
// escalation stage, the agent and browser_session have no caller to ask.
|
|
2492
|
+
// Read through the helper on every call, so an operator's list can be set
|
|
2493
|
+
// for a run without restarting the process.
|
|
2494
|
+
const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
2495
|
+
const proxies = requested.length ? requested : serverStealthProxies();
|
|
2146
2496
|
if (!proxies.length) {
|
|
2147
2497
|
return null;
|
|
2148
2498
|
}
|
|
2499
|
+
// The server-level list arrives as a bare list with no rotation block of
|
|
2500
|
+
// its own, so it gets the schema's own default interval rather than
|
|
2501
|
+
// comparing against undefined — which is never greater, i.e. never rotates.
|
|
2502
|
+
const rotationInterval = config.proxyRotation?.rotationInterval ?? 300000;
|
|
2149
2503
|
|
|
2150
2504
|
const now = Date.now();
|
|
2151
2505
|
if (this.proxyManager.currentProxy === null) {
|
|
@@ -2154,7 +2508,7 @@ export class StealthBrowserManager {
|
|
|
2154
2508
|
// longer list silently started at the second entry.
|
|
2155
2509
|
this.proxyManager.proxyIndex = 0;
|
|
2156
2510
|
this.proxyManager.lastRotation = now;
|
|
2157
|
-
} else if (now - this.proxyManager.lastRotation >
|
|
2511
|
+
} else if (now - this.proxyManager.lastRotation > rotationInterval) {
|
|
2158
2512
|
this.proxyManager.proxyIndex = (this.proxyManager.proxyIndex + 1) % proxies.length;
|
|
2159
2513
|
this.proxyManager.lastRotation = now;
|
|
2160
2514
|
}
|
|
@@ -2201,18 +2555,27 @@ export class StealthBrowserManager {
|
|
|
2201
2555
|
*
|
|
2202
2556
|
* @param {Object} params
|
|
2203
2557
|
* @param {string} params.url — URL to scrape
|
|
2204
|
-
* @param {string} [params.engine] — browser engine (forwarded to config;
|
|
2558
|
+
* @param {string} [params.engine] — browser engine (forwarded to config; 'auto' by default, i.e. camoufox when installed)
|
|
2205
2559
|
* @param {number} [params.wait_for] — extra wait after load, in ms
|
|
2206
2560
|
* @param {boolean} [params.screenshot] — capture a base64 PNG screenshot
|
|
2207
2561
|
* @param {Object} [params.stealthConfig] — stealth configuration overrides
|
|
2208
|
-
* @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string}>}
|
|
2562
|
+
* @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string, engine:?string, warnings:string[]}>}
|
|
2563
|
+
* `engine` is the one that actually ran and `warnings` carries the
|
|
2564
|
+
* 'auto'→chromium downgrade, so the caller can report it.
|
|
2209
2565
|
*/
|
|
2210
2566
|
/**
|
|
2211
|
-
* If the document is a self-solving bot-wall interstitial
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2567
|
+
* If the document is a self-solving bot-wall interstitial, wait up to
|
|
2568
|
+
* timeoutMs for the title to change — the challenge script navigates to the
|
|
2569
|
+
* real page when it passes — and for that navigation to reach
|
|
2570
|
+
* domcontentloaded. A challenge that never passes is left to
|
|
2571
|
+
* challengeDetection to report as blocked.
|
|
2572
|
+
*
|
|
2573
|
+
* The trigger is looksLikeInterstitial, not a known title: nowsecure.nl's
|
|
2574
|
+
* interstitial is titled "nowsecure.nl", so a title-only test skipped the
|
|
2575
|
+
* wait entirely and the block verdict fired on a challenge that had not been
|
|
2576
|
+
* given its chance to solve itself (2026-09-21 benchmark, section 2.2). That
|
|
2577
|
+
* costs one page.content() per call, which is what reading the challenge
|
|
2578
|
+
* bootstrap out of the document takes.
|
|
2216
2579
|
*/
|
|
2217
2580
|
/**
|
|
2218
2581
|
* A document with no title and no text right after domcontentloaded is
|
|
@@ -2324,7 +2687,15 @@ export class StealthBrowserManager {
|
|
|
2324
2687
|
} catch {
|
|
2325
2688
|
return;
|
|
2326
2689
|
}
|
|
2327
|
-
|
|
2690
|
+
// A page that is navigating — which a challenge that has just solved itself
|
|
2691
|
+
// is — can answer title() and still refuse content(). Falling back to the
|
|
2692
|
+
// title-only decision keeps a known interstitial waited on; only the marker
|
|
2693
|
+
// branch, and with it the custom-titled wall, is lost for that call.
|
|
2694
|
+
let html = '';
|
|
2695
|
+
try {
|
|
2696
|
+
html = await page.content();
|
|
2697
|
+
} catch { /* title-only decision */ }
|
|
2698
|
+
if (!looksLikeInterstitial({ title, html })) return;
|
|
2328
2699
|
await page
|
|
2329
2700
|
.waitForFunction((t) => document.title !== t, title, { timeout: timeoutMs })
|
|
2330
2701
|
.catch(() => {});
|
|
@@ -2334,7 +2705,12 @@ export class StealthBrowserManager {
|
|
|
2334
2705
|
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
|
|
2335
2706
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
2336
2707
|
|
|
2337
|
-
const { contextId } = await this.createStealthContext({ ...stealthConfig, engine });
|
|
2708
|
+
const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
|
|
2709
|
+
// What ran, not what was asked for: 'auto' becomes chromium when camoufox
|
|
2710
|
+
// is absent, and a result that says the stealth browser did not get the
|
|
2711
|
+
// page has to name the browser that tried.
|
|
2712
|
+
const engineUsed = this._launchedEngine ?? null;
|
|
2713
|
+
const warnings = engineFallbackWarning ? [engineFallbackWarning] : [];
|
|
2338
2714
|
try {
|
|
2339
2715
|
const page = await this.createStealthPage(contextId);
|
|
2340
2716
|
let crashed = false;
|
|
@@ -2394,44 +2770,132 @@ export class StealthBrowserManager {
|
|
|
2394
2770
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
2395
2771
|
: null;
|
|
2396
2772
|
|
|
2397
|
-
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs };
|
|
2773
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
|
|
2398
2774
|
} finally {
|
|
2399
2775
|
await this.closeContext(contextId).catch(() => {});
|
|
2400
2776
|
}
|
|
2401
2777
|
}
|
|
2402
2778
|
|
|
2403
2779
|
/**
|
|
2404
|
-
* Whether a request is dropped before it leaves the browser.
|
|
2405
|
-
*
|
|
2780
|
+
* Whether a request is dropped before it leaves the browser. Nothing is, at
|
|
2781
|
+
* any level, which is why applyPageStealthMeasures routes no page at all —
|
|
2782
|
+
* this is the one place that answers the question, and the answer is the
|
|
2783
|
+
* reason the interception is gone.
|
|
2406
2784
|
* @param {string} resourceType - Playwright's request.resourceType()
|
|
2407
2785
|
* @param {string} level - stealth level
|
|
2408
2786
|
* @returns {boolean}
|
|
2409
2787
|
*/
|
|
2410
|
-
static shouldAbortRequest(
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2788
|
+
static shouldAbortRequest(_resourceType, _level) {
|
|
2789
|
+
return false;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* The full user-agent client-hint metadata for a fingerprint: what Playwright
|
|
2794
|
+
* already derives from the user agent, plus the brand lists it leaves to the
|
|
2795
|
+
* binary. Omitting a field means "fill this in with what you would normally
|
|
2796
|
+
* use", and what a headless Chromium normally uses says HeadlessChrome — in
|
|
2797
|
+
* brands, in fullVersionList, and so in every high-entropy hint a detector
|
|
2798
|
+
* asks for.
|
|
2799
|
+
*
|
|
2800
|
+
* The five derived fields are what playwright-core's own
|
|
2801
|
+
* calculateUserAgentMetadata produces for the three desktop personas this
|
|
2802
|
+
* manager draws — so for every user agent it can hand out, this changes the
|
|
2803
|
+
* brands and nothing else. A mobile persona is out of scope here for the same
|
|
2804
|
+
* reason isMobile is pinned false in generateAdvancedFingerprint: the pool has
|
|
2805
|
+
* no mobile user agent to be coherent with.
|
|
2806
|
+
*
|
|
2807
|
+
* @param {Object} fingerprint
|
|
2808
|
+
* @returns {Object} a CDP Emulation.UserAgentMetadata
|
|
2809
|
+
*/
|
|
2810
|
+
generateUserAgentMetadata(fingerprint) {
|
|
2811
|
+
const userAgent = String(fingerprint.userAgent || '');
|
|
2812
|
+
const brands = this.generateUserAgentBrands(userAgent);
|
|
2813
|
+
const brandVersion = brands[brands.length - 1].version;
|
|
2814
|
+
// The four-part build number, but only when it is the binary the brand
|
|
2815
|
+
// version names; a custom UA on another major gets the reduced form rather
|
|
2816
|
+
// than this machine's build under someone else's version.
|
|
2817
|
+
const fullVersion = brandVersion === String(this.chromeMajor)
|
|
2818
|
+
? this.chromeVersion
|
|
2819
|
+
: `${brandVersion}.0.0.0`;
|
|
2820
|
+
const platformVersions = { windows: '10.0', macos: '10_15_7', linux: '' };
|
|
2821
|
+
const platforms = { windows: 'Windows', macos: 'macOS', linux: 'Linux' };
|
|
2822
|
+
const os = this.inferOSFromUserAgent(userAgent);
|
|
2823
|
+
|
|
2824
|
+
return {
|
|
2825
|
+
brands,
|
|
2826
|
+
fullVersionList: brands.map((brand) => ({ brand: brand.brand, version: fullVersion })),
|
|
2827
|
+
fullVersion,
|
|
2828
|
+
platform: platforms[os],
|
|
2829
|
+
platformVersion: platformVersions[os],
|
|
2830
|
+
architecture: 'x86',
|
|
2831
|
+
model: '',
|
|
2832
|
+
mobile: false
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* Tell the renderer who it is, instead of patching it from script.
|
|
2838
|
+
*
|
|
2839
|
+
* navigator.userAgent, .platform and .userAgentData were being defined on the
|
|
2840
|
+
* document's navigator by init scripts. An init script does not run in a
|
|
2841
|
+
* Worker, so every one of those values was spoofed in the document and
|
|
2842
|
+
* truthful in the worker beside it — a MacIntel under a Windows persona,
|
|
2843
|
+
* which is what bot.incolumitas.com and CreepJS score (2026-09-21 benchmark,
|
|
2844
|
+
* worker-*). Emulation.setUserAgentOverride is applied by the renderer, and
|
|
2845
|
+
* the brands it carries are the ones the binary would otherwise fill in as
|
|
2846
|
+
* HeadlessChrome.
|
|
2847
|
+
*
|
|
2848
|
+
* `acceptLanguage` is the plain locale tag, the same single value Playwright
|
|
2849
|
+
* gives the context. It is deliberately NOT the two-entry "en-US,en" list:
|
|
2850
|
+
* this override reaches the document only, while a worker's
|
|
2851
|
+
* navigator.languages comes from the context locale, so a second entry here
|
|
2852
|
+
* showed up in the document and nowhere else — the very mismatch the init
|
|
2853
|
+
* script used to create (2026-09-21 benchmark, worker-languages). The
|
|
2854
|
+
* Accept-Language *header* still carries the `en;q=0.9` fallback, from
|
|
2855
|
+
* generateAdvancedHeaders, which is what a real Chrome sends for a
|
|
2856
|
+
* single-locale preference. The field cannot simply be omitted: that clears
|
|
2857
|
+
* the override Playwright installed and a de-DE persona would fall back to
|
|
2858
|
+
* the binary's default.
|
|
2859
|
+
*
|
|
2860
|
+
* Chromium only: CDP does not exist on camoufox's Firefox, which spoofs all
|
|
2861
|
+
* of this in its own engine anyway. A failure here is not fatal — Playwright
|
|
2862
|
+
* has already set the user agent through the context, so the persona survives
|
|
2863
|
+
* without the brands.
|
|
2864
|
+
*/
|
|
2865
|
+
async _applyEmulatedIdentity(page, config, fingerprint) {
|
|
2866
|
+
if (!isChromium(page) || !fingerprint.userAgent) return;
|
|
2867
|
+
try {
|
|
2868
|
+
const client = await page.context().newCDPSession(page);
|
|
2869
|
+
await client.send('Emulation.setUserAgentOverride', {
|
|
2870
|
+
userAgent: fingerprint.userAgent,
|
|
2871
|
+
acceptLanguage: fingerprint.locale || config.locale || 'en-US',
|
|
2872
|
+
platform: fingerprint.hardware.platform,
|
|
2873
|
+
userAgentMetadata: this.generateUserAgentMetadata(fingerprint)
|
|
2874
|
+
});
|
|
2875
|
+
} catch (error) {
|
|
2876
|
+
console.warn(`Identity emulation skipped: ${error.message}`);
|
|
2877
|
+
}
|
|
2415
2878
|
}
|
|
2416
2879
|
|
|
2417
2880
|
/**
|
|
2418
2881
|
* Apply page-level stealth measures
|
|
2419
2882
|
*/
|
|
2420
2883
|
async applyPageStealthMeasures(page, config, fingerprint) {
|
|
2421
|
-
//
|
|
2422
|
-
// blocked challenges.cloudflare.com
|
|
2423
|
-
//
|
|
2424
|
-
//
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2884
|
+
// No request routing at all. R15 had already stopped aborting by URL (the
|
|
2885
|
+
// old list blocked challenges.cloudflare.com, so a Cloudflare challenge
|
|
2886
|
+
// could never complete, and killed the navigation to www.selenium.dev);
|
|
2887
|
+
// what was left was the advanced level dropping about a third of images,
|
|
2888
|
+
// fonts and stylesheets at random. Both halves of that are timeable: a page
|
|
2889
|
+
// that renders without the fonts and images it asked for does not look like
|
|
2890
|
+
// a browser reading it, and route('**/*') itself puts every request through
|
|
2891
|
+
// a node round trip, which shows up as latency no network explains
|
|
2892
|
+
// (2026-09-21 review, finding 10). With nothing left to abort there is
|
|
2893
|
+
// nothing for the interception to decide, so the handler is gone rather
|
|
2894
|
+
// than made conditional.
|
|
2895
|
+
|
|
2896
|
+
// Every identity the renderer can be told about directly, rather than
|
|
2897
|
+
// patched into from script.
|
|
2898
|
+
await this._applyEmulatedIdentity(page, config, fingerprint);
|
|
2435
2899
|
|
|
2436
2900
|
// Add request headers — Chromium only. These carry sec-ch-ua,
|
|
2437
2901
|
// sec-ch-ua-mobile and sec-ch-ua-platform, client hints Gecko does not
|
|
@@ -2641,6 +3105,7 @@ export class StealthBrowserManager {
|
|
|
2641
3105
|
this._parkedBrowsers.clear();
|
|
2642
3106
|
}
|
|
2643
3107
|
this._launchedEngine = null;
|
|
3108
|
+
this._engineFallbackWarning = null;
|
|
2644
3109
|
for (const browser of browsers) {
|
|
2645
3110
|
const closed = await withDeadline(browser.close(), 5000);
|
|
2646
3111
|
if (!closed) {
|
|
@@ -2813,6 +3278,115 @@ export class CamoufoxAdapter extends BrowserEngine {
|
|
|
2813
3278
|
}
|
|
2814
3279
|
}
|
|
2815
3280
|
|
|
3281
|
+
/**
|
|
3282
|
+
* A browserforge fingerprint pinned to the installed binary's Firefox major.
|
|
3283
|
+
*
|
|
3284
|
+
* camoufox@0.1.19 rewrites the persona's version tokens with
|
|
3285
|
+
*
|
|
3286
|
+
* data.replace(/(?<!\d)(1[0-9]{2})(\.0)(?!\d)/, `${ffVersion}$2`)
|
|
3287
|
+
*
|
|
3288
|
+
* and there is no /g, so only the FIRST match in each string changes. In a
|
|
3289
|
+
* Firefox UA that match is `rv:`, and `Firefox/` keeps whatever version
|
|
3290
|
+
* browserforge drew — the two then agree only by coincidence. Measured on
|
|
3291
|
+
* the installed 135 binary: 4 of 8 launches produced a self-contradicting UA
|
|
3292
|
+
* (`rv:135.0 ... Firefox/150.0`), which is a one-line detection. Generating
|
|
3293
|
+
* the persona ourselves at the binary's own version makes that replace a
|
|
3294
|
+
* no-op and both tokens agree (8/8 measured, end to end through
|
|
3295
|
+
* launchOptions).
|
|
3296
|
+
*
|
|
3297
|
+
* Nothing else is constrained here because camoufox constrains nothing else:
|
|
3298
|
+
* its getScreenCons() returns null on both branches, and its `os` option is
|
|
3299
|
+
* dropped before it reaches the generator (the client sends `os`, the
|
|
3300
|
+
* generator's key is `operatingSystems`). Pinning the OS too would fix that
|
|
3301
|
+
* second bug, but it is Phase 1's documented `persona-os-vs-host` finding and
|
|
3302
|
+
* changing it here would move a baseline this change has no business moving.
|
|
3303
|
+
*
|
|
3304
|
+
* @returns {object|null} null when the version cannot be read, or when
|
|
3305
|
+
* browserforge has no data for it — 152 is outside its set, and silently
|
|
3306
|
+
* drawing some other version would put us back where we started. The
|
|
3307
|
+
* caller then lets camoufox generate as it did before.
|
|
3308
|
+
*/
|
|
3309
|
+
/** process.platform in the vocabulary fingerprint-generator uses, or null. */
|
|
3310
|
+
static _hostOperatingSystem() {
|
|
3311
|
+
if (process.platform === 'darwin') return 'macos';
|
|
3312
|
+
if (process.platform === 'win32') return 'windows';
|
|
3313
|
+
if (process.platform === 'linux') return 'linux';
|
|
3314
|
+
return null;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
/** The OS a Firefox UA claims, in the same vocabulary, or null. */
|
|
3318
|
+
static _osFromUserAgent(ua) {
|
|
3319
|
+
if (/Macintosh|Mac OS X/.test(ua)) return 'macos';
|
|
3320
|
+
if (/Windows/.test(ua)) return 'windows';
|
|
3321
|
+
if (/Android/.test(ua)) return null; // Linux-derived, but not the host OS we mean
|
|
3322
|
+
if (/X11|Linux/.test(ua)) return 'linux';
|
|
3323
|
+
return null;
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
_pinnedFingerprint(camoufox) {
|
|
3327
|
+
try {
|
|
3328
|
+
const raw = fs.readFileSync(path.join(camoufox.INSTALL_DIR, 'version.json'), 'utf8');
|
|
3329
|
+
const major = parseInt(String(JSON.parse(raw).version).split('.')[0], 10);
|
|
3330
|
+
if (!Number.isInteger(major)) return null;
|
|
3331
|
+
|
|
3332
|
+
// Resolved through camoufox's own module path first: fingerprint-generator
|
|
3333
|
+
// is its dependency, not ours, and this guarantees the same copy it feeds
|
|
3334
|
+
// its data to rather than a second one hoisted elsewhere.
|
|
3335
|
+
const here = createRequire(import.meta.url);
|
|
3336
|
+
let load;
|
|
3337
|
+
try {
|
|
3338
|
+
load = createRequire(here.resolve('camoufox'));
|
|
3339
|
+
} catch {
|
|
3340
|
+
load = here;
|
|
3341
|
+
}
|
|
3342
|
+
const { FingerprintGenerator } = load('fingerprint-generator');
|
|
3343
|
+
|
|
3344
|
+
// Verify rather than assume: a generator with no data for a constraint can
|
|
3345
|
+
// still hand back a persona that ignores it, and an unchecked fingerprint
|
|
3346
|
+
// would reintroduce exactly the mismatch this exists to close.
|
|
3347
|
+
const attempt = (operatingSystems) => {
|
|
3348
|
+
const { fingerprint } = new FingerprintGenerator({
|
|
3349
|
+
browsers: [{ name: 'firefox', minVersion: major, maxVersion: major }],
|
|
3350
|
+
...(operatingSystems ? { operatingSystems } : {})
|
|
3351
|
+
}).getFingerprint();
|
|
3352
|
+
const ua = fingerprint?.navigator?.userAgent ?? '';
|
|
3353
|
+
if (ua.match(/rv:(\d+)/)?.[1] !== String(major)) return null;
|
|
3354
|
+
if (ua.match(/Firefox\/(\d+)/)?.[1] !== String(major)) return null;
|
|
3355
|
+
if (operatingSystems && CamoufoxAdapter._osFromUserAgent(ua) !== operatingSystems[0]) return null;
|
|
3356
|
+
return fingerprint;
|
|
3357
|
+
};
|
|
3358
|
+
|
|
3359
|
+
// `operatingSystems` is the key the client gets wrong — it sends `os`, so
|
|
3360
|
+
// the OS is drawn from market share and a Linux host ships a macOS
|
|
3361
|
+
// persona. Generating here means we can use the right key. Measured on the
|
|
3362
|
+
// hosted box 2026-09-22: persona-os-vs-host failed with a coherent UA
|
|
3363
|
+
// version, which is a contradiction a detector scores on its own.
|
|
3364
|
+
const hostOs = CamoufoxAdapter._hostOperatingSystem();
|
|
3365
|
+
if (hostOs) {
|
|
3366
|
+
try {
|
|
3367
|
+
const matched = attempt([hostOs]);
|
|
3368
|
+
if (matched) return matched;
|
|
3369
|
+
} catch {
|
|
3370
|
+
// An impossible constraint THROWS here rather than returning a
|
|
3371
|
+
// mismatched persona ("No headers based on this input can be
|
|
3372
|
+
// generated"), and browserforge has no Firefox 135 on Linux at all —
|
|
3373
|
+
// measured, and it is the combination the hosted box runs. Letting
|
|
3374
|
+
// that escape would lose the version pin too and put the UA back
|
|
3375
|
+
// where it started, so it falls through to the version-only attempt.
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
// Fall back to a version-only pin. browserforge may have no data for this
|
|
3380
|
+
// Firefox version on this OS, and a persona that is coherent about its
|
|
3381
|
+
// version still beats one that is coherent about neither.
|
|
3382
|
+
return attempt(null);
|
|
3383
|
+
} catch {
|
|
3384
|
+
// Best effort: a missing version.json or an absent generator is a reason
|
|
3385
|
+
// to fall back to camoufox's own persona, not to fail a launch.
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
|
|
2816
3390
|
async launch(config = {}) {
|
|
2817
3391
|
let camoufox;
|
|
2818
3392
|
try {
|
|
@@ -2842,11 +3416,24 @@ export class CamoufoxAdapter extends BrowserEngine {
|
|
|
2842
3416
|
// level, where a page cannot see the seam, and every one of these was
|
|
2843
3417
|
// simply not being passed — camoufox ran with its own features off.
|
|
2844
3418
|
const options = {
|
|
2845
|
-
|
|
3419
|
+
// A string mode ('virtual' — Xvfb) has to reach camoufox as that string.
|
|
3420
|
+
// `config.headless !== false` collapsed it to `true`, which is plain
|
|
3421
|
+
// headless: the one mode 'virtual' exists to avoid. Booleans keep their
|
|
3422
|
+
// old meaning, so an unset headless is still headless.
|
|
3423
|
+
headless: typeof config.headless === 'string' ? config.headless : config.headless !== false,
|
|
2846
3424
|
...config.launchOptions
|
|
2847
3425
|
};
|
|
2848
3426
|
// A bare `{ server }` is fine here; camoufox normalises both shapes.
|
|
2849
3427
|
if (config.proxy) options.proxy = config.proxy;
|
|
3428
|
+
// "windows" | "macos" | "linux", as a plain string — a one-element ARRAY is
|
|
3429
|
+
// ignored and falls back to the random draw. Left unset, camoufox picks one
|
|
3430
|
+
// of the three at random, which is how a Mac ended up claiming Windows.
|
|
3431
|
+
if (config.os) options.os = config.os;
|
|
3432
|
+
// The first listed locale is the one used for the Intl API. camoufox
|
|
3433
|
+
// applies it in its own engine, so navigator.language, Accept-Language and
|
|
3434
|
+
// Intl agree in the document and in every worker. Left unset with geoip on,
|
|
3435
|
+
// camoufox derives it from the proxy's exit IP instead.
|
|
3436
|
+
if (config.locale) options.locale = config.locale;
|
|
2850
3437
|
// geoip derives longitude, latitude, timezone, country and locale from the
|
|
2851
3438
|
// proxy's exit IP, which is the one thing that makes a proxied browser
|
|
2852
3439
|
// coherent. It costs a request through the proxy, and the first ever call
|
|
@@ -2859,6 +3446,21 @@ export class CamoufoxAdapter extends BrowserEngine {
|
|
|
2859
3446
|
// Native cursor humanization: camoufox moves the pointer along a plausible
|
|
2860
3447
|
// path rather than teleporting it.
|
|
2861
3448
|
if (config.humanize) options.humanize = true;
|
|
3449
|
+
// Pin the persona to the installed binary's Firefox major so the UA's two
|
|
3450
|
+
// version tokens agree. Left unpinned, camoufox's own version rewrite
|
|
3451
|
+
// reaches only `rv:` and about half of all launches announce a Firefox
|
|
3452
|
+
// that is not the one running — see _pinnedFingerprint.
|
|
3453
|
+
const fingerprint = this._pinnedFingerprint(camoufox);
|
|
3454
|
+
if (fingerprint) {
|
|
3455
|
+
options.fingerprint = fingerprint;
|
|
3456
|
+
// Switches off exactly two things: camoufox's "you passed your own
|
|
3457
|
+
// fingerprint" advisory, and its non-Firefox fingerprint check, which is
|
|
3458
|
+
// satisfied by construction — the generator above is Firefox-only. The
|
|
3459
|
+
// other options this flag gates (ff_version, block_images, disable_coop,
|
|
3460
|
+
// block_webgl) are ones this adapter never sets, so nothing else is
|
|
3461
|
+
// silenced.
|
|
3462
|
+
options.i_know_what_im_doing = true;
|
|
3463
|
+
}
|
|
2862
3464
|
|
|
2863
3465
|
try {
|
|
2864
3466
|
return await camoufox.Camoufox(options);
|