feat(stealth): 进一步降低 creepjs like-headless 指标
This commit is contained in:
@@ -106,6 +106,8 @@ describe('Stealth mode', () => {
|
|||||||
hasCanShare: typeof navigator.canShare === 'function',
|
hasCanShare: typeof navigator.canShare === 'function',
|
||||||
hasConnectionDownlinkMax:
|
hasConnectionDownlinkMax:
|
||||||
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
|
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
|
||||||
|
hasConnectionDownlinkMaxOnProto:
|
||||||
|
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(signals.mimeTypesLength).toBeGreaterThan(0);
|
expect(signals.mimeTypesLength).toBeGreaterThan(0);
|
||||||
@@ -113,5 +115,59 @@ describe('Stealth mode', () => {
|
|||||||
expect(signals.hasShare).toBe(true);
|
expect(signals.hasShare).toBe(true);
|
||||||
expect(signals.hasCanShare).toBe(true);
|
expect(signals.hasCanShare).toBe(true);
|
||||||
expect(signals.hasConnectionDownlinkMax).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
@@ -6,7 +6,7 @@
|
|||||||
* Puppeteer / headless Chrome.
|
* Puppeteer / headless Chrome.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContext } from 'playwright-core';
|
import type { BrowserContext, Page } from 'playwright-core';
|
||||||
|
|
||||||
export interface StealthScriptOptions {
|
export interface StealthScriptOptions {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -27,6 +27,80 @@ export async function applyStealthScripts(
|
|||||||
options: StealthScriptOptions = {}
|
options: StealthScriptOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await context.addInitScript({ content: buildStealthScript(options) });
|
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 {
|
function normalizeLocale(locale?: string): string | undefined {
|
||||||
@@ -65,11 +139,15 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
patchWebGLVendor(),
|
patchWebGLVendor(),
|
||||||
patchCdcProperties(),
|
patchCdcProperties(),
|
||||||
patchWindowDimensions(),
|
patchWindowDimensions(),
|
||||||
|
patchScreenDimensions(),
|
||||||
patchScreenAvailability(),
|
patchScreenAvailability(),
|
||||||
patchNavigatorHardwareConcurrency(),
|
patchNavigatorHardwareConcurrency(),
|
||||||
|
patchNotificationPermission(),
|
||||||
patchNavigatorConnection(),
|
patchNavigatorConnection(),
|
||||||
|
patchWorkerConnection(),
|
||||||
patchNavigatorShare(),
|
patchNavigatorShare(),
|
||||||
patchNavigatorContacts(),
|
patchNavigatorContacts(),
|
||||||
|
patchContentIndex(),
|
||||||
patchPdfViewerEnabled(),
|
patchPdfViewerEnabled(),
|
||||||
patchMediaDevices(),
|
patchMediaDevices(),
|
||||||
patchUserAgentData(),
|
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);
|
* navigator.hardwareConcurrency: headless often reports 2 (CI);
|
||||||
* real desktops typically have >= 4 cores.
|
* 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.
|
* Add missing connection.downlinkMax in Chromium headless environments.
|
||||||
*/
|
*/
|
||||||
@@ -455,12 +581,103 @@ function patchNavigatorConnection(): string {
|
|||||||
if (!navigator.connection) return;
|
if (!navigator.connection) return;
|
||||||
const conn = navigator.connection;
|
const conn = navigator.connection;
|
||||||
if (typeof conn.downlinkMax === 'number') return;
|
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 {
|
try {
|
||||||
Object.defineProperty(conn, 'downlinkMax', {
|
const proto = Object.getPrototypeOf(conn);
|
||||||
get: () => 10,
|
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,
|
configurable: true,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(window, 'Worker', {
|
||||||
|
value: WrappedWorker,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,16 +710,83 @@ function patchNavigatorShare(): string {
|
|||||||
*/
|
*/
|
||||||
function patchNavigatorContacts(): string {
|
function patchNavigatorContacts(): string {
|
||||||
return `(function(){
|
return `(function(){
|
||||||
if (navigator.contacts) return;
|
const ContactsCtor = typeof ContactsManager === 'function'
|
||||||
|
? ContactsManager
|
||||||
|
: function ContactsManager() {};
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(navigator, 'contacts', {
|
Object.defineProperty(window, 'ContactsManager', {
|
||||||
value: {
|
value: ContactsCtor,
|
||||||
select: async () => [],
|
|
||||||
getProperties: () => ['name', 'email', 'tel', 'address', 'icon'],
|
|
||||||
},
|
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
} catch {}
|
} 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');
|
||||||
|
}
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user