feat(stealth): 增强指纹一致性并新增 creepjs 检测脚本
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end check for CreepJS headless/stealth indicators.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-creepjs-headless.js
|
||||||
|
* node scripts/check-creepjs-headless.js --compare-stealth
|
||||||
|
* node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const getArgValue = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||||
|
return args[index + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser'));
|
||||||
|
const sessionPrefix = getArgValue('--session-prefix', 'creepjs-e2e');
|
||||||
|
const compareStealth = args.includes('--compare-stealth');
|
||||||
|
const targetUrl = getArgValue('--url', 'https://abrahamjuliot.github.io/creepjs/');
|
||||||
|
|
||||||
|
const extractionScript = `(() => {
|
||||||
|
const headless = globalThis.Fingerprint?.headless ?? null;
|
||||||
|
const toNumber = (value) => (typeof value === 'number' ? value : null);
|
||||||
|
return {
|
||||||
|
found: !!headless,
|
||||||
|
metrics: headless ? {
|
||||||
|
chromium: !!headless.chromium,
|
||||||
|
likeHeadless: toNumber(headless.likeHeadlessRating),
|
||||||
|
headless: toNumber(headless.headlessRating),
|
||||||
|
stealth: toNumber(headless.stealthRating),
|
||||||
|
raw: headless,
|
||||||
|
} : null,
|
||||||
|
navigator: {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
userAgentData: navigator.userAgentData ? navigator.userAgentData.toJSON?.() ?? null : null,
|
||||||
|
language: navigator.language,
|
||||||
|
languages: navigator.languages,
|
||||||
|
platform: navigator.platform,
|
||||||
|
webdriver: navigator.webdriver,
|
||||||
|
webdriverInNavigator: ('webdriver' in navigator),
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
innerHeight: window.innerHeight,
|
||||||
|
outerWidth: window.outerWidth,
|
||||||
|
outerHeight: window.outerHeight,
|
||||||
|
screenX: window.screenX,
|
||||||
|
screenY: window.screenY,
|
||||||
|
},
|
||||||
|
intl: {
|
||||||
|
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function runCommand(commandArgs, options = {}) {
|
||||||
|
const result = spawnSync(binary, commandArgs, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0 && !options.allowFailure) {
|
||||||
|
const stderr = (result.stderr || '').trim();
|
||||||
|
const stdout = (result.stdout || '').trim();
|
||||||
|
throw new Error(
|
||||||
|
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
|
||||||
|
`${stderr || stdout || `exit code ${result.status}`}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSessionArgs(session, stealth) {
|
||||||
|
const base = ['--session', session];
|
||||||
|
if (stealth === false) {
|
||||||
|
base.push('--stealth', 'false');
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSingleCheck({ stealth, runId }) {
|
||||||
|
const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`;
|
||||||
|
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
|
||||||
|
runCommand([
|
||||||
|
...withSessionArgs(session, stealth),
|
||||||
|
'wait',
|
||||||
|
'--fn',
|
||||||
|
'!!(window.Fingerprint && window.Fingerprint.headless)',
|
||||||
|
]);
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'wait', '2000']);
|
||||||
|
|
||||||
|
const evalResult = runCommand([
|
||||||
|
...withSessionArgs(session, stealth),
|
||||||
|
'eval',
|
||||||
|
'--json',
|
||||||
|
extractionScript,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = JSON.parse(evalResult.stdout);
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
stealth,
|
||||||
|
url: targetUrl,
|
||||||
|
extracted: payload?.data?.result ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const runId = Date.now();
|
||||||
|
const checks = compareStealth ? [true, false] : [true];
|
||||||
|
const results = checks.map((stealth) => runSingleCheck({ stealth, runId }));
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
binary,
|
||||||
|
compareStealth,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+205
-19
@@ -27,7 +27,11 @@ import {
|
|||||||
decryptData,
|
decryptData,
|
||||||
ENCRYPTION_KEY_ENV,
|
ENCRYPTION_KEY_ENV,
|
||||||
} from './state-utils.js';
|
} from './state-utils.js';
|
||||||
import { STEALTH_CHROMIUM_ARGS, applyStealthScripts } from './stealth.js';
|
import {
|
||||||
|
STEALTH_CHROMIUM_ARGS,
|
||||||
|
applyStealthScripts,
|
||||||
|
type StealthScriptOptions,
|
||||||
|
} from './stealth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default Playwright timeout in milliseconds for standard operations.
|
* Returns the default Playwright timeout in milliseconds for standard operations.
|
||||||
@@ -114,6 +118,12 @@ export interface StealthStatus {
|
|||||||
providerManaged: boolean;
|
providerManaged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StealthContextDefaults {
|
||||||
|
locale?: string;
|
||||||
|
timezoneId?: string;
|
||||||
|
extraHTTPHeaders?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +153,10 @@ export class BrowserManager {
|
|||||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||||
private stealthEnabled: boolean = false;
|
private stealthEnabled: boolean = false;
|
||||||
private stealthConnectionKind: StealthConnectionKind = 'local';
|
private stealthConnectionKind: StealthConnectionKind = 'local';
|
||||||
|
private contextLocale: string | undefined = undefined;
|
||||||
|
private contextTimezoneId: string | undefined = undefined;
|
||||||
|
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||||
|
private contextUserAgent: string | undefined = undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the persistent color scheme preference.
|
* Set the persistent color scheme preference.
|
||||||
@@ -212,13 +226,130 @@ export class BrowserManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeLocaleTag(locale?: string): string | undefined {
|
||||||
|
if (!locale) return undefined;
|
||||||
|
const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
|
||||||
|
if (!cleaned) return undefined;
|
||||||
|
try {
|
||||||
|
return new Intl.Locale(cleaned).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAcceptLanguageHeader(locale: string): string {
|
||||||
|
const baseLanguage = locale.split('-')[0];
|
||||||
|
if (!baseLanguage || baseLanguage === locale) {
|
||||||
|
return `${locale};q=0.9`;
|
||||||
|
}
|
||||||
|
return `${locale},${baseLanguage};q=0.9`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaderValue(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
name: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === target) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveStealthLocale(headers?: Record<string, string>): string {
|
||||||
|
const headerLocale = this.getHeaderValue(headers, 'accept-language');
|
||||||
|
const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale);
|
||||||
|
if (normalizedHeaderLocale) return normalizedHeaderLocale;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
process.env.AGENT_BROWSER_LOCALE,
|
||||||
|
process.env.LC_ALL,
|
||||||
|
process.env.LC_MESSAGES,
|
||||||
|
process.env.LANG,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().locale,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = this.normalizeLocaleTag(candidate);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveStealthTimezoneId(): string | undefined {
|
||||||
|
const candidates = [
|
||||||
|
process.env.AGENT_BROWSER_TIMEZONE,
|
||||||
|
process.env.TZ,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
];
|
||||||
|
for (const value of candidates) {
|
||||||
|
const timezone = value?.trim();
|
||||||
|
if (!timezone) continue;
|
||||||
|
if (timezone === 'UTC' || timezone.includes('/')) return timezone;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStealthContextDefaults(
|
||||||
|
policy: StealthPolicy,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): StealthContextDefaults {
|
||||||
|
if (!policy.enabled) {
|
||||||
|
return { extraHTTPHeaders: headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = this.resolveStealthLocale(headers);
|
||||||
|
const timezoneId = this.resolveStealthTimezoneId();
|
||||||
|
const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined;
|
||||||
|
const extraHTTPHeaders = hasAcceptLanguage
|
||||||
|
? headers
|
||||||
|
: {
|
||||||
|
...(headers ?? {}),
|
||||||
|
'Accept-Language': this.buildAcceptLanguageHeader(locale),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
timezoneId,
|
||||||
|
extraHTTPHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractChromiumVersion(versionText: string): string | undefined {
|
||||||
|
const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStealthChromiumUserAgent(chromeVersion: string): string {
|
||||||
|
const platform = os.platform();
|
||||||
|
let osToken = 'X11; Linux x86_64';
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
osToken = 'Macintosh; Intel Mac OS X 10_15_7';
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
osToken = 'Windows NT 10.0; Win64; x64';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` +
|
||||||
|
`(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStealthUserAgentVersionHint(): string | undefined {
|
||||||
|
const deviceUA = devices['Desktop Chrome']?.userAgent;
|
||||||
|
if (!deviceUA) return undefined;
|
||||||
|
return this.extractChromiumVersion(deviceUA);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply context init-script stealth patches when policy allows.
|
* Apply context init-script stealth patches when policy allows.
|
||||||
*/
|
*/
|
||||||
private async applyStealthIfEnabled(context: BrowserContext): Promise<void> {
|
private async applyStealthIfEnabled(
|
||||||
|
context: BrowserContext,
|
||||||
|
options: StealthScriptOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
const policy = this.getStealthPolicy();
|
const policy = this.getStealthPolicy();
|
||||||
if (!policy.applyInitScripts) return;
|
if (!policy.applyInitScripts) return;
|
||||||
await applyStealthScripts(context);
|
await applyStealthScripts(context, options);
|
||||||
this.logStealthPolicy('init-script applied');
|
this.logStealthPolicy('init-script applied');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +508,13 @@ export class BrowserManager {
|
|||||||
context = this.contexts[this.contexts.length - 1];
|
context = this.contexts[this.contexts.length - 1];
|
||||||
} else if (this.browser) {
|
} else if (this.browser) {
|
||||||
context = await this.browser.newContext({
|
context = await this.browser.newContext({
|
||||||
|
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
|
||||||
|
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -988,7 +1123,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const context = contexts[0];
|
const context = contexts[0];
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
const page = pages[0] ?? (await context.newPage());
|
const page = pages[0] ?? (await context.newPage());
|
||||||
|
|
||||||
@@ -1127,11 +1262,11 @@ export class BrowserManager {
|
|||||||
// Kernel browsers launch with a default context and page
|
// Kernel browsers launch with a default context and page
|
||||||
if (contexts.length === 0) {
|
if (contexts.length === 0) {
|
||||||
context = await browser.newContext();
|
context = await browser.newContext();
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
} else {
|
} else {
|
||||||
context = contexts[0];
|
context = contexts[0];
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
page = pages[0] ?? (await context.newPage());
|
page = pages[0] ?? (await context.newPage());
|
||||||
}
|
}
|
||||||
@@ -1203,11 +1338,11 @@ export class BrowserManager {
|
|||||||
|
|
||||||
if (contexts.length === 0) {
|
if (contexts.length === 0) {
|
||||||
context = await browser.newContext();
|
context = await browser.newContext();
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
} else {
|
} else {
|
||||||
context = contexts[0];
|
context = contexts[0];
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
page = pages[0] ?? (await context.newPage());
|
page = pages[0] ?? (await context.newPage());
|
||||||
}
|
}
|
||||||
@@ -1279,6 +1414,12 @@ export class BrowserManager {
|
|||||||
this.colorScheme = options.colorScheme;
|
this.colorScheme = options.colorScheme;
|
||||||
}
|
}
|
||||||
this.stealthEnabled = options.stealth ?? false;
|
this.stealthEnabled = options.stealth ?? false;
|
||||||
|
this.contextLocale = this.stealthEnabled
|
||||||
|
? this.resolveStealthLocale(options.headers)
|
||||||
|
: undefined;
|
||||||
|
this.contextTimezoneId = this.stealthEnabled ? this.resolveStealthTimezoneId() : undefined;
|
||||||
|
this.contextHeaders = undefined;
|
||||||
|
this.contextUserAgent = options.userAgent;
|
||||||
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||||
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||||
|
|
||||||
@@ -1335,13 +1476,36 @@ export class BrowserManager {
|
|||||||
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
||||||
|
|
||||||
const stealthPolicy = this.getStealthPolicy(browserType);
|
const stealthPolicy = this.getStealthPolicy(browserType);
|
||||||
|
const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
|
||||||
|
const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
|
||||||
|
this.contextLocale = contextDefaults.locale;
|
||||||
|
this.contextTimezoneId = contextDefaults.timezoneId;
|
||||||
|
this.contextHeaders = contextDefaults.extraHTTPHeaders;
|
||||||
|
|
||||||
|
let contextUserAgent = options.userAgent;
|
||||||
|
if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||||
|
const versionHint = this.getStealthUserAgentVersionHint();
|
||||||
|
if (versionHint) {
|
||||||
|
contextUserAgent = this.buildStealthChromiumUserAgent(versionHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.contextUserAgent = contextUserAgent;
|
||||||
|
|
||||||
// Build base args array with file access flags and stealth args when policy allows.
|
// Build base args array with file access flags and stealth args when policy allows.
|
||||||
const fileAccessArgs = options.allowFileAccess
|
const fileAccessArgs = options.allowFileAccess
|
||||||
? ['--allow-file-access-from-files', '--allow-file-access']
|
? ['--allow-file-access-from-files', '--allow-file-access']
|
||||||
: [];
|
: [];
|
||||||
const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
|
const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
|
||||||
const implicitArgs = [...fileAccessArgs, ...stealthArgs];
|
const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent='));
|
||||||
|
const launchUserAgentArgs =
|
||||||
|
!hasUserAgentArg &&
|
||||||
|
!options.userAgent &&
|
||||||
|
stealthPolicy.enabled &&
|
||||||
|
browserType === 'chromium' &&
|
||||||
|
contextUserAgent
|
||||||
|
? [`--user-agent=${contextUserAgent}`]
|
||||||
|
: [];
|
||||||
|
const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs];
|
||||||
const baseArgs = options.args
|
const baseArgs = options.args
|
||||||
? [...implicitArgs, ...options.args]
|
? [...implicitArgs, ...options.args]
|
||||||
: implicitArgs.length > 0
|
: implicitArgs.length > 0
|
||||||
@@ -1375,8 +1539,10 @@ export class BrowserManager {
|
|||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
args: allArgs,
|
args: allArgs,
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders,
|
||||||
userAgent: options.userAgent,
|
userAgent: contextUserAgent,
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
@@ -1392,8 +1558,10 @@ export class BrowserManager {
|
|||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders,
|
||||||
userAgent: options.userAgent,
|
userAgent: contextUserAgent,
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
@@ -1408,6 +1576,14 @@ export class BrowserManager {
|
|||||||
});
|
});
|
||||||
this.cdpEndpoint = null;
|
this.cdpEndpoint = null;
|
||||||
|
|
||||||
|
if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||||
|
const runtimeVersion = this.extractChromiumVersion(this.browser.version());
|
||||||
|
if (runtimeVersion) {
|
||||||
|
contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion);
|
||||||
|
this.contextUserAgent = contextUserAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for auto-load state file (supports encrypted files)
|
// Check for auto-load state file (supports encrypted files)
|
||||||
let storageState:
|
let storageState:
|
||||||
| string
|
| string
|
||||||
@@ -1477,16 +1653,18 @@ export class BrowserManager {
|
|||||||
|
|
||||||
context = await this.browser.newContext({
|
context = await this.browser.newContext({
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders,
|
||||||
userAgent: options.userAgent,
|
userAgent: contextUserAgent,
|
||||||
storageState,
|
storageState,
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
|
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
@@ -1564,7 +1742,7 @@ export class BrowserManager {
|
|||||||
this.cdpEndpoint = cdpEndpoint;
|
this.cdpEndpoint = cdpEndpoint;
|
||||||
|
|
||||||
for (const context of contexts) {
|
for (const context of contexts) {
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(10000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -1820,9 +1998,13 @@ export class BrowserManager {
|
|||||||
|
|
||||||
const context = await this.browser.newContext({
|
const context = await this.browser.newContext({
|
||||||
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
|
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
|
||||||
|
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
|
||||||
|
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
await this.applyStealthIfEnabled(context);
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -2564,6 +2746,10 @@ export class BrowserManager {
|
|||||||
this.colorScheme = null;
|
this.colorScheme = null;
|
||||||
this.stealthEnabled = false;
|
this.stealthEnabled = false;
|
||||||
this.stealthConnectionKind = 'local';
|
this.stealthConnectionKind = 'local';
|
||||||
|
this.contextLocale = undefined;
|
||||||
|
this.contextTimezoneId = undefined;
|
||||||
|
this.contextHeaders = undefined;
|
||||||
|
this.contextUserAgent = undefined;
|
||||||
this.refMap = {};
|
this.refMap = {};
|
||||||
this.lastSnapshot = '';
|
this.lastSnapshot = '';
|
||||||
this.frameCallback = null;
|
this.frameCallback = null;
|
||||||
|
|||||||
@@ -51,4 +51,47 @@ describe('Stealth mode', () => {
|
|||||||
expect(signals.ownNavigator).toBe(false);
|
expect(signals.ownNavigator).toBe(false);
|
||||||
expect(signals.ownPrototype).toBe(false);
|
expect(signals.ownPrototype).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => {
|
||||||
|
const previousLocale = process.env.AGENT_BROWSER_LOCALE;
|
||||||
|
process.env.AGENT_BROWSER_LOCALE = 'fr-FR';
|
||||||
|
|
||||||
|
try {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const languageSignals = await browser.getPage().evaluate(() => ({
|
||||||
|
language: navigator.language,
|
||||||
|
languages: navigator.languages,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(languageSignals.language).toBe('fr-FR');
|
||||||
|
expect(languageSignals.languages).toEqual(['fr-FR', 'fr']);
|
||||||
|
} finally {
|
||||||
|
if (previousLocale === undefined) {
|
||||||
|
delete process.env.AGENT_BROWSER_LOCALE;
|
||||||
|
} else {
|
||||||
|
process.env.AGENT_BROWSER_LOCALE = previousLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const userAgentSignals = await browser.getPage().evaluate(async () => {
|
||||||
|
const pageUA = navigator.userAgent;
|
||||||
|
const workerUA = await new Promise<string>((resolve) => {
|
||||||
|
const source = 'postMessage(navigator.userAgent);';
|
||||||
|
const blob = new Blob([source], { type: 'application/javascript' });
|
||||||
|
const worker = new Worker(URL.createObjectURL(blob));
|
||||||
|
worker.onmessage = (event) => resolve(String(event.data));
|
||||||
|
});
|
||||||
|
return { pageUA, workerUA };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome');
|
||||||
|
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+228
-25
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
import type { BrowserContext } from 'playwright-core';
|
import type { BrowserContext } from 'playwright-core';
|
||||||
|
|
||||||
|
export interface StealthScriptOptions {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chromium args that reduce automation fingerprint.
|
* Chromium args that reduce automation fingerprint.
|
||||||
* Intended to be merged into the user-supplied args array at launch time.
|
* Intended to be merged into the user-supplied args array at launch time.
|
||||||
@@ -18,22 +22,52 @@ export const STEALTH_CHROMIUM_ARGS: string[] = ['--disable-blink-features=Automa
|
|||||||
* Apply all stealth patches to a BrowserContext.
|
* Apply all stealth patches to a BrowserContext.
|
||||||
* Must be called BEFORE any page is created / navigated.
|
* Must be called BEFORE any page is created / navigated.
|
||||||
*/
|
*/
|
||||||
export async function applyStealthScripts(context: BrowserContext): Promise<void> {
|
export async function applyStealthScripts(
|
||||||
await context.addInitScript({ content: buildStealthScript() });
|
context: BrowserContext,
|
||||||
|
options: StealthScriptOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await context.addInitScript({ content: buildStealthScript(options) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStealthScript(): string {
|
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
|
// Each patch is an IIFE so variable scoping is clean
|
||||||
return [
|
return [
|
||||||
|
configScript,
|
||||||
patchNavigatorWebdriver(),
|
patchNavigatorWebdriver(),
|
||||||
patchChromeRuntime(),
|
patchChromeRuntime(),
|
||||||
|
patchNavigatorLanguages(),
|
||||||
patchNavigatorPlugins(),
|
patchNavigatorPlugins(),
|
||||||
patchNavigatorPermissions(),
|
patchNavigatorPermissions(),
|
||||||
patchWebGLVendor(),
|
patchWebGLVendor(),
|
||||||
patchCdcProperties(),
|
patchCdcProperties(),
|
||||||
patchIframeContentWindow(),
|
patchWindowDimensions(),
|
||||||
patchNavigatorHardwareConcurrency(),
|
patchNavigatorHardwareConcurrency(),
|
||||||
patchMediaDevices(),
|
patchMediaDevices(),
|
||||||
|
patchUserAgentData(),
|
||||||
patchUserAgent(),
|
patchUserAgent(),
|
||||||
patchPerformanceMemory(),
|
patchPerformanceMemory(),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -56,6 +90,9 @@ function patchNavigatorWebdriver(): string {
|
|||||||
removeWebdriver(navigator);
|
removeWebdriver(navigator);
|
||||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
removeWebdriver(Object.getPrototypeOf(navigator));
|
||||||
removeWebdriver(Navigator.prototype);
|
removeWebdriver(Navigator.prototype);
|
||||||
|
if (typeof WorkerNavigator !== 'undefined') {
|
||||||
|
removeWebdriver(WorkerNavigator.prototype);
|
||||||
|
}
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +104,59 @@ function patchChromeRuntime(): string {
|
|||||||
return `(function(){
|
return `(function(){
|
||||||
if (!window.chrome) { window.chrome = {}; }
|
if (!window.chrome) { window.chrome = {}; }
|
||||||
if (!window.chrome.runtime) {
|
if (!window.chrome.runtime) {
|
||||||
window.chrome.runtime = {
|
const makeEvent = () => ({
|
||||||
connect: function(){},
|
addListener: () => {},
|
||||||
sendMessage: function(){},
|
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(window.chrome, '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 a realistic navigator.plugins array.
|
* Inject a realistic navigator.plugins array.
|
||||||
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
|
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
|
||||||
@@ -115,14 +197,42 @@ function patchNavigatorPlugins(): string {
|
|||||||
*/
|
*/
|
||||||
function patchNavigatorPermissions(): string {
|
function patchNavigatorPermissions(): string {
|
||||||
return `(function(){
|
return `(function(){
|
||||||
if (!navigator.permissions) return;
|
if (!navigator.permissions || !navigator.permissions.query) return;
|
||||||
const origQuery = navigator.permissions.query.bind(navigator.permissions);
|
const origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||||
navigator.permissions.query = (params) => {
|
const makePermissionStatus = (state) => {
|
||||||
if (params.name === 'notifications') {
|
if (typeof PermissionStatus !== 'undefined') {
|
||||||
return Promise.resolve({ state: Notification.permission, onchange: null });
|
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 origQuery(params);
|
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 {}
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,20 +289,56 @@ function patchCdcProperties(): string {
|
|||||||
* contentWindow on cross-origin iframes: Playwright sometimes returns null
|
* contentWindow on cross-origin iframes: Playwright sometimes returns null
|
||||||
* where real browsers return a (restricted) Window object.
|
* where real browsers return a (restricted) Window object.
|
||||||
*/
|
*/
|
||||||
function patchIframeContentWindow(): string {
|
function patchWindowDimensions(): string {
|
||||||
return `(function(){
|
return `(function(){
|
||||||
const orig = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
const widthDelta = 12;
|
||||||
if (orig && orig.get) {
|
const heightDelta = 74;
|
||||||
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
const patchWidth =
|
||||||
get: function() {
|
!Number.isFinite(window.outerWidth) ||
|
||||||
const w = orig.get.call(this);
|
window.outerWidth === 0 ||
|
||||||
if (w === null) {
|
Math.abs(window.outerWidth - window.innerWidth) <= 1;
|
||||||
return window;
|
const patchHeight =
|
||||||
}
|
!Number.isFinite(window.outerHeight) ||
|
||||||
return w;
|
window.outerHeight === 0 ||
|
||||||
},
|
Math.abs(window.outerHeight - window.innerHeight) <= 1;
|
||||||
configurable: true,
|
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 {}
|
||||||
}
|
}
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
@@ -256,6 +402,63 @@ function patchUserAgent(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
* Provide a fake performance.memory (Chrome-only, non-standard).
|
||||||
* Headless Chrome omits this; some detectors check for its presence.
|
* Headless Chrome omits this; some detectors check for its presence.
|
||||||
|
|||||||
Reference in New Issue
Block a user