feat(stealth): 增强指纹一致性并新增 creepjs 检测脚本

This commit is contained in:
leeguooooo
2026-02-24 14:19:25 +09:00
parent 955543b757
commit 02ebc9f328
4 changed files with 613 additions and 44 deletions
+205 -19
View File
@@ -27,7 +27,11 @@ import {
decryptData,
ENCRYPTION_KEY_ENV,
} 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.
@@ -114,6 +118,12 @@ export interface StealthStatus {
providerManaged: boolean;
}
interface StealthContextDefaults {
locale?: string;
timezoneId?: string;
extraHTTPHeaders?: Record<string, string>;
}
/**
* 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 stealthEnabled: boolean = false;
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.
@@ -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.
*/
private async applyStealthIfEnabled(context: BrowserContext): Promise<void> {
private async applyStealthIfEnabled(
context: BrowserContext,
options: StealthScriptOptions = {}
): Promise<void> {
const policy = this.getStealthPolicy();
if (!policy.applyInitScripts) return;
await applyStealthScripts(context);
await applyStealthScripts(context, options);
this.logStealthPolicy('init-script applied');
}
@@ -377,9 +508,13 @@ export class BrowserManager {
context = this.contexts[this.contexts.length - 1];
} else if (this.browser) {
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 }),
});
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
@@ -988,7 +1123,7 @@ export class BrowserManager {
}
const context = contexts[0];
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages();
const page = pages[0] ?? (await context.newPage());
@@ -1127,11 +1262,11 @@ export class BrowserManager {
// Kernel browsers launch with a default context and page
if (contexts.length === 0) {
context = await browser.newContext();
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
page = await context.newPage();
} else {
context = contexts[0];
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages();
page = pages[0] ?? (await context.newPage());
}
@@ -1203,11 +1338,11 @@ export class BrowserManager {
if (contexts.length === 0) {
context = await browser.newContext();
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
page = await context.newPage();
} else {
context = contexts[0];
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages();
page = pages[0] ?? (await context.newPage());
}
@@ -1279,6 +1414,12 @@ export class BrowserManager {
this.colorScheme = options.colorScheme;
}
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.
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
@@ -1335,13 +1476,36 @@ export class BrowserManager {
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
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.
const fileAccessArgs = options.allowFileAccess
? ['--allow-file-access-from-files', '--allow-file-access']
: [];
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
? [...implicitArgs, ...options.args]
: implicitArgs.length > 0
@@ -1375,8 +1539,10 @@ export class BrowserManager {
executablePath: options.executablePath,
args: allArgs,
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
extraHTTPHeaders,
userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }),
@@ -1392,8 +1558,10 @@ export class BrowserManager {
executablePath: options.executablePath,
args: baseArgs,
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
extraHTTPHeaders,
userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }),
@@ -1408,6 +1576,14 @@ export class BrowserManager {
});
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)
let storageState:
| string
@@ -1477,16 +1653,18 @@ export class BrowserManager {
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
extraHTTPHeaders,
userAgent: contextUserAgent,
storageState,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }),
});
}
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
@@ -1564,7 +1742,7 @@ export class BrowserManager {
this.cdpEndpoint = cdpEndpoint;
for (const context of contexts) {
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(10000);
this.contexts.push(context);
this.setupContextTracking(context);
@@ -1820,9 +1998,13 @@ export class BrowserManager {
const context = await this.browser.newContext({
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 }),
});
await this.applyStealthIfEnabled(context);
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
@@ -2564,6 +2746,10 @@ export class BrowserManager {
this.colorScheme = null;
this.stealthEnabled = false;
this.stealthConnectionKind = 'local';
this.contextLocale = undefined;
this.contextTimezoneId = undefined;
this.contextHeaders = undefined;
this.contextUserAgent = undefined;
this.refMap = {};
this.lastSnapshot = '';
this.frameCallback = null;