feat(stealth): 进一步降低 creepjs like-headless 指标

This commit is contained in:
leeguooooo
2026-02-24 14:36:56 +09:00
parent 3a91aef4c9
commit 2ed0c6f8ec
2 changed files with 349 additions and 9 deletions
+56
View File
@@ -106,6 +106,8 @@ describe('Stealth mode', () => {
hasCanShare: typeof navigator.canShare === 'function',
hasConnectionDownlinkMax:
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
hasConnectionDownlinkMaxOnProto:
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
}));
expect(signals.mimeTypesLength).toBeGreaterThan(0);
@@ -113,5 +115,59 @@ describe('Stealth mode', () => {
expect(signals.hasShare).toBe(true);
expect(signals.hasCanShare).toBe(true);
expect(signals.hasConnectionDownlinkMax).toBe(true);
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
});
it('exposes contacts manager and content index APIs', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
hasContacts: 'contacts' in navigator,
contactsManagerCtor: typeof (window as any).ContactsManager === 'function',
hasContentIndexCtor: typeof (window as any).ContentIndex === 'function',
hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined',
hasContentIndexOnSWR:
typeof ServiceWorkerRegistration !== 'undefined' &&
('contentIndex' in ServiceWorkerRegistration.prototype ||
'index' in ServiceWorkerRegistration.prototype),
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null,
screenMatchesViewport:
screen.width === window.innerWidth && screen.height === window.innerHeight,
}));
expect(signals.hasContacts).toBe(true);
expect(signals.contactsManagerCtor).toBe(true);
expect(signals.hasContentIndexCtor).toBe(true);
if (signals.hasServiceWorkerRegistration) {
expect(signals.hasContentIndexOnSWR).toBe(true);
}
expect(signals.notificationPermission).toBe('default');
expect(signals.screenMatchesViewport).toBe(false);
});
it('exposes downlinkMax inside dedicated workers', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const workerSignals = await browser.getPage().evaluate(async () => {
return new Promise<{
hasConnection: boolean;
hasDownlinkMax: boolean;
hasDownlinkMaxOnProto: boolean;
downlinkMax: unknown;
}>((resolve) => {
const source =
"postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});";
const blob = new Blob([source], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = (event) => resolve(event.data);
});
});
expect(workerSignals.hasConnection).toBe(true);
expect(workerSignals.hasDownlinkMax).toBe(true);
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
expect(typeof workerSignals.downlinkMax).toBe('number');
});
});
+293 -9
View File
@@ -6,7 +6,7 @@
* Puppeteer / headless Chrome.
*/
import type { BrowserContext } from 'playwright-core';
import type { BrowserContext, Page } from 'playwright-core';
export interface StealthScriptOptions {
locale?: string;
@@ -27,6 +27,80 @@ export async function applyStealthScripts(
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);
}
context.on('page', (page: Page) => applyCDPStealthToPage(page));
}
async function applyCDPStealthToPage(page: Page): Promise<void> {
try {
const cdp = await page.context().newCDPSession(page);
const ua = await cdp.send('Browser.getVersion').catch(() => null);
const rawUA = ua?.userAgent ?? '';
const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome');
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`;
await cdp.send('Emulation.setUserAgentOverride', {
userAgent: patchedUA,
acceptLanguage: 'en-US,en;q=0.9',
platform: getPlatformString(),
userAgentMetadata: {
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,
},
});
await cdp.detach();
} catch {
// CDP not available (non-Chromium) -- silently skip
}
}
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 {
@@ -65,11 +139,15 @@ function buildStealthScript(options: StealthScriptOptions): string {
patchWebGLVendor(),
patchCdcProperties(),
patchWindowDimensions(),
patchScreenDimensions(),
patchScreenAvailability(),
patchNavigatorHardwareConcurrency(),
patchNotificationPermission(),
patchNavigatorConnection(),
patchWorkerConnection(),
patchNavigatorShare(),
patchNavigatorContacts(),
patchContentIndex(),
patchPdfViewerEnabled(),
patchMediaDevices(),
patchUserAgentData(),
@@ -432,6 +510,37 @@ function patchScreenAvailability(): string {
})();`;
}
/**
* 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.
@@ -447,6 +556,23 @@ function patchNavigatorHardwareConcurrency(): string {
})();`;
}
/**
* 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 {}
})();`;
}
/**
* Add missing connection.downlinkMax in Chromium headless environments.
*/
@@ -455,12 +581,103 @@ function patchNavigatorConnection(): string {
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 {
Object.defineProperty(conn, 'downlinkMax', {
get: () => 10,
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 {}
})();`;
}
@@ -493,16 +710,83 @@ function patchNavigatorShare(): string {
*/
function patchNavigatorContacts(): string {
return `(function(){
if (navigator.contacts) return;
const ContactsCtor = typeof ContactsManager === 'function'
? ContactsManager
: function ContactsManager() {};
try {
Object.defineProperty(navigator, 'contacts', {
value: {
select: async () => [],
getProperties: () => ['name', 'email', 'tel', 'address', 'icon'],
},
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');
}
})();`;
}