Files
chrome-use/src/stealth.ts
T
leeguooooo ec011f46ff fix(stealth): 修复 launch 选项与测试基线不一致问题
在 stealth 默认策略下保留自定义 user-agent,不再被 CDP 覆盖。

同步更新 protocol/browser/launch/file-access 相关测试预期,并放宽 browser.test 的 hook 超时以消除偶发超时。
2026-02-24 17:42:12 +09:00

1151 lines
35 KiB
TypeScript

/**
* Stealth mode patches to prevent browser automation detection.
*
* These scripts run via addInitScript (before any page JS) and patch the
* fingerprinting surfaces that anti-bot systems use to identify Playwright /
* Puppeteer / headless Chrome.
*/
import type { Browser, BrowserContext, Page } from 'playwright-core';
export interface StealthScriptOptions {
locale?: string;
userAgent?: string;
acceptLanguage?: string;
}
/**
* Chromium args that reduce automation fingerprint.
* Intended to be merged into the user-supplied args array at launch time.
*/
export const STEALTH_CHROMIUM_ARGS: string[] = [
'--disable-blink-features=AutomationControlled',
'--use-gl=angle',
'--use-angle=default',
];
/**
* Apply all stealth patches to a BrowserContext.
* Must be called BEFORE any page is created / navigated.
*/
export async function applyStealthScripts(
context: BrowserContext,
options: StealthScriptOptions = {}
): Promise<void> {
await context.addInitScript({ content: buildStealthScript(options) });
// Apply CDP-level User-Agent override so Workers also get the patched UA.
// This must be done per-page since CDP sessions are page-scoped.
for (const page of context.pages()) {
await applyCDPStealthToPage(page, options);
}
context.on('page', (page: Page) => applyCDPStealthToPage(page, options));
}
/**
* Apply browser-level CDP overrides that affect all targets (including Workers).
* Call this right after browser.launch() and before creating pages.
*/
export async function applyBrowserLevelStealth(
browser: Browser,
options: StealthScriptOptions = {}
): Promise<void> {
try {
const cdp = await (browser as any).newBrowserCDPSession();
const version = await cdp.send('Browser.getVersion');
const rawUA = version?.userAgent ?? '';
const explicitUA = options.userAgent?.trim();
if (!explicitUA && !rawUA.includes('HeadlessChrome')) {
await cdp.detach();
return;
}
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
const metadata = buildUserAgentMetadata(patchedUA);
// Override on all existing targets
const { targetInfos } = await cdp.send('Target.getTargets');
for (const target of targetInfos) {
try {
const { sessionId } = await cdp.send('Target.attachToTarget', {
targetId: target.targetId,
flatten: true,
});
await cdp.send('Emulation.setUserAgentOverride', {
userAgent: patchedUA,
acceptLanguage,
platform: getPlatformString(),
userAgentMetadata: metadata,
});
await cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {});
} catch {
// Some targets don't support Emulation domain
}
}
await cdp.detach();
} catch {
// newBrowserCDPSession not available -- silently skip
}
}
async function applyCDPStealthToPage(
page: Page,
options: StealthScriptOptions = {}
): Promise<void> {
try {
const cdp = await page.context().newCDPSession(page);
const ua = await cdp.send('Browser.getVersion').catch(() => null);
const rawUA = ua?.userAgent ?? '';
const explicitUA = options.userAgent?.trim();
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
const metadata = buildUserAgentMetadata(patchedUA);
await cdp.send('Emulation.setUserAgentOverride', {
userAgent: patchedUA,
acceptLanguage,
platform: getPlatformString(),
userAgentMetadata: metadata,
});
// Set default background color to opaque white (headless default is transparent)
await cdp
.send('Emulation.setDefaultBackgroundColorOverride', {
color: { r: 255, g: 255, b: 255, a: 1 },
})
.catch(() => {});
// Keep CDP session alive so the override persists for Workers
} catch {
// CDP not available (non-Chromium) -- silently skip
}
}
function buildUserAgentMetadata(patchedUA: string): any {
const versionMatch = patchedUA.match(/Chrome\/(\d+)/);
const majorVersion = versionMatch?.[1] ?? '120';
const fullVersionMatch = patchedUA.match(/Chrome\/([\d.]+)/);
const fullVersion = fullVersionMatch?.[1] ?? `${majorVersion}.0.0.0`;
return {
brands: [
{ brand: 'Chromium', version: majorVersion },
{ brand: 'Google Chrome', version: majorVersion },
{ brand: 'Not-A.Brand', version: '99' },
],
fullVersionList: [
{ brand: 'Chromium', version: fullVersion },
{ brand: 'Google Chrome', version: fullVersion },
{ brand: 'Not-A.Brand', version: '99.0.0.0' },
],
fullVersion: fullVersion,
platform: getPlatformHint(),
platformVersion: getPlatformVersionHint(),
architecture: 'x86',
model: '',
mobile: false,
bitness: '64',
wow64: false,
};
}
function getPlatformString(): string {
if (typeof process !== 'undefined') {
if (process.platform === 'darwin') return 'macOS';
if (process.platform === 'win32') return 'Windows';
}
return 'Linux';
}
function getPlatformHint(): string {
if (typeof process !== 'undefined') {
if (process.platform === 'darwin') return 'macOS';
if (process.platform === 'win32') return 'Windows';
}
return 'Linux';
}
function getPlatformVersionHint(): string {
if (typeof process !== 'undefined') {
if (process.platform === 'darwin') return '13.0.0';
if (process.platform === 'win32') return '10.0.0';
}
return '6.1.0';
}
function normalizeLocale(locale?: string): string | undefined {
if (!locale) return undefined;
const trimmed = locale.trim();
if (!trimmed) return undefined;
const cleaned = trimmed.split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
if (!cleaned) return undefined;
try {
return new Intl.Locale(cleaned).toString();
} catch {
return undefined;
}
}
function deriveLanguages(locale?: string): string[] {
const normalized = normalizeLocale(locale) ?? 'en-US';
const base = normalized.split('-')[0];
if (!base || base === normalized) return [normalized];
return [normalized, base];
}
function buildStealthScript(options: StealthScriptOptions): string {
const locale = normalizeLocale(options.locale) ?? 'en-US';
const languages = deriveLanguages(locale);
const configScript = `const __abStealth = ${JSON.stringify({ locale, languages })};`;
// Each patch is an IIFE so variable scoping is clean
return [
configScript,
patchNavigatorWebdriver(),
patchCssSupportsWebdriverHeuristic(),
patchChromeRuntime(),
patchNavigatorLanguages(),
patchNavigatorPluginsAndMimeTypes(),
patchNavigatorPermissions(),
patchWebGLVendor(),
patchCdcProperties(),
patchWindowDimensions(),
patchScreenDimensions(),
patchScreenAvailability(),
patchNavigatorHardwareConcurrency(),
patchNotificationPermission(),
patchActiveTextColorHeuristic(),
patchNavigatorConnection(),
patchWorkerConnection(),
patchNavigatorShare(),
patchNavigatorContacts(),
patchContentIndex(),
patchPrefersColorSchemeHeuristic(),
patchPdfViewerEnabled(),
patchMediaDevices(),
patchUserAgentData(),
patchUserAgent(),
patchPerformanceMemory(),
patchDefaultBackgroundColor(),
].join('\n');
}
// ---------------------------------------------------------------------------
// Individual patches
// ---------------------------------------------------------------------------
/**
* CreepJS uses CSS.supports('border-end-end-radius: initial') + webdriver
* undefined to infer automation. Keep this one probe neutral.
*/
function patchCssSupportsWebdriverHeuristic(): string {
return `(function(){
if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return;
const nativeSupports = CSS.supports.bind(CSS);
const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase();
const target = 'border-end-end-radius: initial';
const patchedSupports = function(...args) {
if (args.length === 1 && normalize(args[0]) === target) {
return false;
}
if (args.length >= 2 && normalize(args[0] + ': ' + args[1]) === target) {
return false;
}
return nativeSupports(...args);
};
try {
Object.defineProperty(patchedSupports, 'name', { value: 'supports', configurable: true });
Object.defineProperty(patchedSupports, 'toString', {
value: () => nativeSupports.toString(),
configurable: true,
});
} catch {}
try {
Object.defineProperty(CSS, 'supports', {
value: patchedSupports,
configurable: true,
writable: true,
});
} catch {
try { CSS.supports = patchedSupports; } catch {}
}
})();`;
}
/**
* Remove navigator.webdriver entirely.
* Modern detection checks both value and property presence (`'webdriver' in navigator`).
*/
function patchNavigatorWebdriver(): string {
return `(function(){
const removeWebdriver = (target) => {
if (!target) return;
try { delete target.webdriver; } catch {}
};
removeWebdriver(navigator);
removeWebdriver(Object.getPrototypeOf(navigator));
removeWebdriver(Navigator.prototype);
if (typeof WorkerNavigator !== 'undefined') {
removeWebdriver(WorkerNavigator.prototype);
}
})();`;
}
/**
* Ensure window.chrome and window.chrome.runtime exist.
* Headless Chrome (and Playwright) omit chrome.runtime which is a dead giveaway.
*/
function patchChromeRuntime(): string {
return `(function(){
const chromeObject = ('chrome' in window && window.chrome) ? window.chrome : {};
if (!('chrome' in window)) {
try {
Object.defineProperty(Window.prototype, 'chrome', {
get: () => chromeObject,
configurable: true,
});
} catch {
try { Object.defineProperty(window, 'chrome', { value: chromeObject, configurable: true }); } catch {}
}
}
if (!chromeObject.runtime) {
const makeEvent = () => ({
addListener: () => {},
removeListener: () => {},
hasListener: () => false,
hasListeners: () => false,
dispatch: () => {},
});
const makePort = () => ({
name: '',
sender: undefined,
disconnect: () => {},
onDisconnect: makeEvent(),
onMessage: makeEvent(),
postMessage: () => {},
});
const runtime = {
id: undefined,
connect: () => makePort(),
sendMessage: () => undefined,
onConnect: makeEvent(),
onMessage: makeEvent(),
};
Object.defineProperty(chromeObject, 'runtime', {
value: runtime,
configurable: true,
});
}
})();`;
}
/**
* Keep navigator.language + navigator.languages aligned with launch locale.
*/
function patchNavigatorLanguages(): string {
return `(function(){
const config = (typeof __abStealth === 'object' && __abStealth) ? __abStealth : null;
if (!config || !Array.isArray(config.languages) || config.languages.length === 0) return;
const locale = typeof config.locale === 'string' ? config.locale : config.languages[0];
try {
Object.defineProperty(navigator, 'language', {
get: () => locale,
configurable: true,
});
} catch {}
try {
Object.defineProperty(navigator, 'languages', {
get: () => config.languages.slice(),
configurable: true,
});
} catch {}
})();`;
}
/**
* Inject realistic navigator.plugins and navigator.mimeTypes arrays.
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
*/
function patchNavigatorPluginsAndMimeTypes(): string {
return `(function(){
const makeMimeType = (type, suffixes, description) => {
const mime = Object.create(MimeType.prototype);
Object.defineProperties(mime, {
type: { value: type, enumerable: true },
suffixes: { value: suffixes, enumerable: true },
description: { value: description, enumerable: true },
enabledPlugin: { value: null, writable: true, enumerable: true },
});
return mime;
};
const makePlugin = (name, description, filename, mimes) => {
const plugin = Object.create(Plugin.prototype);
Object.defineProperties(plugin, {
name: { value: name, enumerable: true },
description: { value: description, enumerable: true },
filename: { value: filename, enumerable: true },
length: { value: mimes.length, enumerable: true },
});
mimes.forEach((mime, i) => {
Object.defineProperty(plugin, i, {
value: mime,
enumerable: true,
});
Object.defineProperty(plugin, mime.type, {
value: mime,
enumerable: false,
});
try { mime.enabledPlugin = plugin; } catch {}
});
return plugin;
};
const pdfMime = makeMimeType('application/pdf', 'pdf', 'Portable Document Format');
const chromePdfMime = makeMimeType(
'application/x-google-chrome-pdf',
'pdf',
'Portable Document Format'
);
const naclMime = makeMimeType('application/x-nacl', '', 'Native Client Executable');
const pnaclMime = makeMimeType('application/x-pnacl', '', 'Portable Native Client Executable');
const plugins = [
makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer', [chromePdfMime]),
makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', [pdfMime]),
makePlugin('Native Client', '', 'internal-nacl-plugin', [naclMime, pnaclMime]),
];
const pluginArray = Object.create(PluginArray.prototype);
plugins.forEach((p, i) => {
pluginArray[i] = p;
pluginArray[p.name] = p;
});
Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
pluginArray.item = (i) => plugins[i] || null;
pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
pluginArray.refresh = () => {};
pluginArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
const mimeTypes = [chromePdfMime, pdfMime, naclMime, pnaclMime];
const mimeTypeArray = Object.create(MimeTypeArray.prototype);
mimeTypes.forEach((m, i) => {
mimeTypeArray[i] = m;
mimeTypeArray[m.type] = m;
});
Object.defineProperty(mimeTypeArray, 'length', { get: () => mimeTypes.length });
mimeTypeArray.item = (i) => mimeTypes[i] || null;
mimeTypeArray.namedItem = (name) => mimeTypes.find(m => m.type === name) || null;
mimeTypeArray[Symbol.iterator] = function*() { for (const m of mimeTypes) yield m; };
Object.defineProperty(navigator, 'plugins', {
get: () => pluginArray,
configurable: true,
});
Object.defineProperty(navigator, 'mimeTypes', {
get: () => mimeTypeArray,
configurable: true,
});
})();`;
}
/**
* navigator.permissions.query({name:'notifications'}) should resolve to
* 'denied' in a normal browser, but Playwright throws or returns 'prompt'.
*/
function patchNavigatorPermissions(): string {
return `(function(){
if (!navigator.permissions || !navigator.permissions.query) return;
const origQuery = navigator.permissions.query.bind(navigator.permissions);
const makePermissionStatus = (state) => {
if (typeof PermissionStatus !== 'undefined') {
const status = Object.create(PermissionStatus.prototype);
Object.defineProperty(status, 'state', {
value: state,
writable: false,
enumerable: true,
});
Object.defineProperty(status, 'onchange', {
value: null,
writable: true,
enumerable: true,
});
return status;
}
return { state, onchange: null };
};
const patchedQuery = new Proxy(origQuery, {
apply(target, thisArg, argList) {
const params = argList && argList[0];
if (params && params.name === 'notifications') {
const state = (typeof Notification !== 'undefined' && Notification.permission) || 'default';
return Promise.resolve(makePermissionStatus(state));
}
return Reflect.apply(target, navigator.permissions, argList);
}
});
try {
Object.defineProperty(navigator.permissions, 'query', {
value: patchedQuery,
configurable: true,
writable: true,
});
} catch {}
})();`;
}
/**
* WebGL vendor/renderer: headless Chrome uses SwiftShader which is distinctive.
* Patch getParameter to return Intel GPU strings when SwiftShader is detected.
*/
function patchWebGLVendor(): string {
return `(function(){
const getCtx = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
const ctx = getCtx.call(this, type, attrs);
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
const origGetParameter = ctx.getParameter.bind(ctx);
ctx.getParameter = function(param) {
const ext = ctx.getExtension('WEBGL_debug_renderer_info');
if (ext) {
if (param === ext.UNMASKED_VENDOR_WEBGL) {
const real = origGetParameter(param);
return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real;
}
if (param === ext.UNMASKED_RENDERER_WEBGL) {
const real = origGetParameter(param);
return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real;
}
}
return origGetParameter(param);
};
}
return ctx;
};
})();`;
}
/**
* Remove Playwright's injected cdc_ (Chrome DevTools) properties on document.
* Some older detection scripts look for these on the document element.
*/
function patchCdcProperties(): string {
return `(function(){
const clean = (target) => {
for (const key of Object.keys(target)) {
if (/^cdc_|^\\$cdc_/.test(key)) {
delete target[key];
}
}
};
clean(document);
if (document.documentElement) clean(document.documentElement);
})();`;
}
/**
* contentWindow on cross-origin iframes: Playwright sometimes returns null
* where real browsers return a (restricted) Window object.
*/
function patchWindowDimensions(): string {
return `(function(){
const widthDelta = 12;
const heightDelta = 74;
const patchWidth =
!Number.isFinite(window.outerWidth) ||
window.outerWidth === 0 ||
Math.abs(window.outerWidth - window.innerWidth) <= 1;
const patchHeight =
!Number.isFinite(window.outerHeight) ||
window.outerHeight === 0 ||
Math.abs(window.outerHeight - window.innerHeight) <= 1;
if (patchWidth) {
try {
Object.defineProperty(window, 'outerWidth', {
get: () => Math.max(window.innerWidth + widthDelta, window.innerWidth),
configurable: true,
});
} catch {}
}
if (patchHeight) {
try {
Object.defineProperty(window, 'outerHeight', {
get: () => Math.max(window.innerHeight + heightDelta, window.innerHeight),
configurable: true,
});
} catch {}
}
const patchScreenPosition =
(!Number.isFinite(window.screenX) || !Number.isFinite(window.screenY)) ||
(window.screenX === 0 && window.screenY === 0 && (patchWidth || patchHeight));
if (patchScreenPosition) {
try {
Object.defineProperty(window, 'screenX', {
get: () => 16,
configurable: true,
});
Object.defineProperty(window, 'screenY', {
get: () => 72,
configurable: true,
});
Object.defineProperty(window, 'screenLeft', {
get: () => 16,
configurable: true,
});
Object.defineProperty(window, 'screenTop', {
get: () => 72,
configurable: true,
});
} catch {}
}
})();`;
}
/**
* Make Screen avail* values look like a desktop with taskbar/menu bar reserved space.
*/
function patchScreenAvailability(): string {
return `(function(){
const patchNumber = (target, key, value) => {
try {
Object.defineProperty(target, key, {
get: () => value,
configurable: true,
});
} catch {}
};
const availWidth = Number(screen.availWidth);
const availHeight = Number(screen.availHeight);
const width = Number(screen.width);
const height = Number(screen.height);
if (Number.isFinite(width) && Number.isFinite(availWidth) && availWidth >= width) {
patchNumber(screen, 'availWidth', Math.max(width - 8, 0));
}
if (Number.isFinite(height) && Number.isFinite(availHeight) && availHeight >= height) {
patchNumber(screen, 'availHeight', Math.max(height - 40, 0));
}
if (Number.isFinite(screen.availLeft) && screen.availLeft === 0) {
patchNumber(screen, 'availLeft', 0);
}
if (Number.isFinite(screen.availTop) && screen.availTop === 0) {
patchNumber(screen, 'availTop', 24);
}
})();`;
}
/**
* Avoid screen == viewport fingerprints common in headless defaults.
*/
function patchScreenDimensions(): string {
return `(function(){
const patchNumber = (target, key, value) => {
try {
Object.defineProperty(target, key, {
get: () => value,
configurable: true,
});
} catch {}
};
const width = Number(screen.width);
const height = Number(screen.height);
const innerWidth = Number(window.innerWidth);
const innerHeight = Number(window.innerHeight);
if (
Number.isFinite(width) &&
Number.isFinite(height) &&
Number.isFinite(innerWidth) &&
Number.isFinite(innerHeight) &&
width === innerWidth &&
height === innerHeight
) {
patchNumber(screen, 'width', Math.max(innerWidth + 86, 1366));
patchNumber(screen, 'height', Math.max(innerHeight + 48, 768));
}
})();`;
}
/**
* navigator.hardwareConcurrency: headless often reports 2 (CI);
* real desktops typically have >= 4 cores.
*/
function patchNavigatorHardwareConcurrency(): string {
return `(function(){
if (navigator.hardwareConcurrency < 4) {
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 4,
configurable: true,
});
}
})();`;
}
/**
* Keep notifications in "default" state instead of denied-by-default headless behavior.
*/
function patchNotificationPermission(): string {
return `(function(){
if (typeof Notification === 'undefined') return;
const current = Notification.permission;
if (current === 'granted') return;
try {
Object.defineProperty(Notification, 'permission', {
get: () => 'default',
configurable: true,
});
} catch {}
})();`;
}
/**
* CreepJS probes `background-color: ActiveText` and flags Chromium when the
* computed value resolves to `rgb(255, 0, 0)`. Rewrite only that exact probe.
*/
function patchActiveTextColorHeuristic(): string {
return `(function(){
if (typeof Element === 'undefined' || !Element.prototype) return;
const nativeSetAttribute = Element.prototype.setAttribute;
if (typeof nativeSetAttribute !== 'function') return;
const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase();
const probeStyle = 'background-color: activetext';
const replacement = 'background-color: rgb(0, 0, 0)';
const patchedSetAttribute = function(name, value) {
if (String(name).toLowerCase() === 'style' && normalize(value) === probeStyle) {
return nativeSetAttribute.call(this, name, replacement);
}
return nativeSetAttribute.call(this, name, value);
};
try {
Object.defineProperty(patchedSetAttribute, 'name', {
value: 'setAttribute',
configurable: true,
});
Object.defineProperty(patchedSetAttribute, 'toString', {
value: () => nativeSetAttribute.toString(),
configurable: true,
});
} catch {}
try {
Object.defineProperty(Element.prototype, 'setAttribute', {
value: patchedSetAttribute,
configurable: true,
writable: true,
});
} catch {
try { Element.prototype.setAttribute = patchedSetAttribute; } catch {}
}
})();`;
}
/**
* Add missing connection.downlinkMax in Chromium headless environments.
*/
function patchNavigatorConnection(): string {
return `(function(){
if (!navigator.connection) return;
const conn = navigator.connection;
if (typeof conn.downlinkMax === 'number') return;
const defineDownlinkMax = (target) => {
if (!target) return false;
try {
Object.defineProperty(target, 'downlinkMax', {
get: () => 10,
configurable: true,
});
return true;
} catch {
return false;
}
};
try {
const proto = Object.getPrototypeOf(conn);
if (defineDownlinkMax(proto)) {
try { delete conn.downlinkMax; } catch {}
return;
}
} catch {}
defineDownlinkMax(conn);
})();`;
}
/**
* Ensure dedicated workers expose navigator.connection.downlinkMax too.
*/
function patchWorkerConnection(): string {
return `(function(){
if (typeof Worker !== 'function') return;
const NativeWorker = Worker;
const workerPrelude = \`
(() => {
try {
if (!navigator || !navigator.connection) return;
const conn = navigator.connection;
if (typeof conn.downlinkMax === 'number') return;
const defineDownlinkMax = (target) => {
if (!target) return false;
try {
Object.defineProperty(target, 'downlinkMax', {
get: () => 10,
configurable: true,
});
return true;
} catch {
return false;
}
};
try {
const proto = Object.getPrototypeOf(conn);
if (defineDownlinkMax(proto)) {
try { delete conn.downlinkMax; } catch {}
return;
}
} catch {}
defineDownlinkMax(conn);
} catch {}
})();
\`;
const buildPatchedScript = (url, options) => {
const scriptUrl = String(url);
const isModule = options && options.type === 'module';
const loader = isModule
? \`import \${JSON.stringify(scriptUrl)};\`
: \`importScripts(\${JSON.stringify(scriptUrl)});\`;
return \`\${workerPrelude}\\n\${loader}\`;
};
const WrappedWorker = function(scriptURL, options) {
try {
const source = buildPatchedScript(scriptURL, options);
const blob = new Blob([source], { type: 'application/javascript' });
const patchedUrl = URL.createObjectURL(blob);
return new NativeWorker(patchedUrl, options);
} catch {
return new NativeWorker(scriptURL, options);
}
};
WrappedWorker.prototype = NativeWorker.prototype;
try {
Object.setPrototypeOf(WrappedWorker, NativeWorker);
} catch {}
try {
Object.defineProperty(WrappedWorker, 'name', { value: 'Worker', configurable: true });
} catch {}
try {
Object.defineProperty(WrappedWorker, 'toString', {
value: () => NativeWorker.toString(),
configurable: true,
});
} catch {}
try {
Object.defineProperty(window, 'Worker', {
value: WrappedWorker,
configurable: true,
writable: true,
});
} catch {}
})();`;
}
/**
* CreepJS marks light-scheme defaults as a weak headless signal. Keep
* `(prefers-color-scheme: light)` neutral without affecting other media queries.
*/
function patchPrefersColorSchemeHeuristic(): string {
return `(function(){
if (typeof window.matchMedia !== 'function') return;
const nativeMatchMedia = window.matchMedia.bind(window);
const normalize = (query) => String(query).replace(/\\s+/g, ' ').trim().toLowerCase();
const prefersLight = '(prefers-color-scheme: light)';
const patchMediaQueryList = (mql) => {
if (!mql || typeof mql !== 'object') return mql;
return new Proxy(mql, {
get(target, prop, receiver) {
if (prop === 'matches') return false;
return Reflect.get(target, prop, receiver);
},
});
};
const patchedMatchMedia = function(query) {
const mql = nativeMatchMedia(query);
if (normalize(query) === prefersLight) {
return patchMediaQueryList(mql);
}
return mql;
};
try {
Object.defineProperty(patchedMatchMedia, 'name', { value: 'matchMedia', configurable: true });
Object.defineProperty(patchedMatchMedia, 'toString', {
value: () => nativeMatchMedia.toString(),
configurable: true,
});
} catch {}
try {
Object.defineProperty(window, 'matchMedia', {
value: patchedMatchMedia,
configurable: true,
writable: true,
});
} catch {
try { window.matchMedia = patchedMatchMedia; } catch {}
}
})();`;
}
/**
* Add share/canShare APIs expected on modern Chromium desktop.
*/
function patchNavigatorShare(): string {
return `(function(){
if (typeof navigator.share !== 'function') {
try {
Object.defineProperty(navigator, 'share', {
value: async () => undefined,
configurable: true,
});
} catch {}
}
if (typeof navigator.canShare !== 'function') {
try {
Object.defineProperty(navigator, 'canShare', {
value: () => true,
configurable: true,
});
} catch {}
}
})();`;
}
/**
* Add Contacts Manager stub to avoid "missing contacts manager" signals.
*/
function patchNavigatorContacts(): string {
return `(function(){
const ContactsCtor = typeof ContactsManager === 'function'
? ContactsManager
: function ContactsManager() {};
try {
Object.defineProperty(window, 'ContactsManager', {
value: ContactsCtor,
configurable: true,
});
} catch {}
const manager = Object.create(ContactsCtor.prototype || Object.prototype);
if (typeof manager.select !== 'function') {
manager.select = async () => [];
}
if (typeof manager.getProperties !== 'function') {
manager.getProperties = () => ['name', 'email', 'tel', 'address', 'icon'];
}
const defineContacts = (target) => {
if (!target) return false;
try {
Object.defineProperty(target, 'contacts', {
get: () => manager,
configurable: true,
});
return true;
} catch {
return false;
}
};
if (defineContacts(navigator)) return;
try {
defineContacts(Object.getPrototypeOf(navigator));
} catch {}
})();`;
}
/**
* Expose ContentIndex APIs expected on modern Chromium.
*/
function patchContentIndex(): string {
return `(function(){
const ContentIndexCtor = typeof ContentIndex === 'function'
? ContentIndex
: function ContentIndex() {};
try {
Object.defineProperty(window, 'ContentIndex', {
value: ContentIndexCtor,
configurable: true,
});
} catch {}
const index = Object.create(ContentIndexCtor.prototype || Object.prototype);
if (typeof index.add !== 'function') {
index.add = async () => undefined;
}
if (typeof index.delete !== 'function') {
index.delete = async () => undefined;
}
if (typeof index.getAll !== 'function') {
index.getAll = async () => [];
}
if (typeof ServiceWorkerRegistration === 'undefined') return;
const defineIndex = (key) => {
try {
Object.defineProperty(ServiceWorkerRegistration.prototype, key, {
get: () => index,
configurable: true,
});
return true;
} catch {
return false;
}
};
if (!('contentIndex' in ServiceWorkerRegistration.prototype)) {
defineIndex('contentIndex');
}
if (!('index' in ServiceWorkerRegistration.prototype)) {
defineIndex('index');
}
})();`;
}
/**
* Chromium exposes navigator.pdfViewerEnabled=true in normal browsing mode.
*/
function patchPdfViewerEnabled(): string {
return `(function(){
if (navigator.pdfViewerEnabled === true) return;
try {
Object.defineProperty(navigator, 'pdfViewerEnabled', {
get: () => true,
configurable: true,
});
} catch {}
})();`;
}
/**
* navigator.mediaDevices.enumerateDevices should return at least some devices
* instead of an empty array (headless default).
*/
function patchMediaDevices(): string {
return `(function(){
if (!navigator.mediaDevices) return;
const orig = navigator.mediaDevices.enumerateDevices;
if (!orig) return;
navigator.mediaDevices.enumerateDevices = async function() {
const devices = await orig.call(navigator.mediaDevices);
if (devices.length === 0) {
return [
{ deviceId: 'default', kind: 'audioinput', label: '', groupId: 'default' },
{ deviceId: 'default', kind: 'videoinput', label: '', groupId: 'default' },
{ deviceId: 'default', kind: 'audiooutput', label: '', groupId: 'default' },
];
}
return devices;
};
})();`;
}
/**
* Replace "HeadlessChrome" with "Chrome" in navigator.userAgent so
* UA-based detection is bypassed at the JavaScript level.
*/
function patchUserAgent(): string {
return `(function(){
const ua = navigator.userAgent;
if (ua.includes('HeadlessChrome')) {
const patched = ua.replace(/HeadlessChrome/g, 'Chrome');
Object.defineProperty(navigator, 'userAgent', {
get: () => patched,
configurable: true,
});
Object.defineProperty(navigator, 'appVersion', {
get: () => patched.replace('Mozilla/', ''),
configurable: true,
});
}
})();`;
}
/**
* Ensure userAgentData does not expose "HeadlessChrome" brand tokens.
*/
function patchUserAgentData(): string {
return `(function(){
const uaData = navigator.userAgentData;
if (!uaData) return;
const sanitizeBrand = (brand) => {
if (typeof brand !== 'string') return brand;
return brand.replace(/HeadlessChrome/gi, 'Google Chrome');
};
const patchBrandList = (value) => {
if (!Array.isArray(value)) return value;
return value.map((entry) => ({
...entry,
brand: sanitizeBrand(entry.brand),
}));
};
const patched = Object.create(Object.getPrototypeOf(uaData));
Object.defineProperties(patched, {
brands: {
get: () => patchBrandList(uaData.brands),
enumerable: true,
},
mobile: {
get: () => uaData.mobile,
enumerable: true,
},
platform: {
get: () => uaData.platform,
enumerable: true,
},
});
patched.toJSON = () => ({
brands: patchBrandList(uaData.brands),
mobile: uaData.mobile,
platform: uaData.platform,
});
patched.getHighEntropyValues = async (hints) => {
const values = await uaData.getHighEntropyValues(hints);
if (values && typeof values === 'object') {
if ('brands' in values) values.brands = patchBrandList(values.brands);
if ('fullVersionList' in values) {
values.fullVersionList = patchBrandList(values.fullVersionList);
}
}
return values;
};
try {
Object.defineProperty(navigator, 'userAgentData', {
get: () => patched,
configurable: true,
});
} catch {}
})();`;
}
/**
* Provide a fake performance.memory (Chrome-only, non-standard).
* Headless Chrome omits this; some detectors check for its presence.
*/
function patchPerformanceMemory(): string {
return `(function(){
if (!performance.memory) {
Object.defineProperty(performance, 'memory', {
get: () => ({
jsHeapSizeLimit: 2172649472,
totalJSHeapSize: 35839739,
usedJSHeapSize: 22592767,
}),
configurable: true,
});
}
})();`;
}
/**
* Headless Chrome has a transparent default background (rgba(0,0,0,0)).
* Real browsers have an opaque white background. Set it early to avoid
* the "hasKnownBgColor" detection.
*/
function patchDefaultBackgroundColor(): string {
return `(function(){
if (document.documentElement) {
const style = getComputedStyle(document.documentElement);
const bg = style.backgroundColor;
if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') {
document.documentElement.style.backgroundColor = 'rgb(255, 255, 255)';
}
}
})();`;
}