基于实战经验重构 JSVMP 分析框架: - SKILL.md: 新增路径选择决策树(路径 A 算法追踪 vs 路径 B 环境伪装) - SKILL.md: 路径 B「环境伪装六步法」完整方法论(采集→对比→分级→补丁→验证→端到端) - SKILL.md: 新增场景 10「JSVMP + jsdom 环境伪装」、解法模式 E - SKILL.md: 标注三板斧适用边界与局限,经验法则扩充至 22 条 - references/jsdom-env-patches.md: 新增 jsdom 环境补丁知识库(Function.toString 三层防御 / plugins 完整模拟 / DOM 布局属性 / 30+ API 存根) - cases/: 新增经验库系统(模板 + 首个 JSVMP 实战案例含 58 项环境检测差异全表) - references/jsvmp-analysis.md: JSVMP 专项分析详细指南 - references/troubleshooting.md: 常见问题排查指南 - templates/python-request/: Python 项目模板 - 各 references 文档增强、Python 技术栈支持、MCP v1.1 适配(44→52 工具) Made-with: Cursor
33 KiB
jsdom 环境补丁知识库
概述
当 JSVMP 选择路径 B(环境伪装)在 jsdom 中运行时,jsdom 与真实浏览器存在大量差异。 本文档提供系统化的环境补丁方案,按检测影响分级,所有代码模板可直接复用。
使用方法
const { JSDOM } = require('jsdom');
function createPatchedJsdom(url, options = {}) {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: url,
referrer: options.referrer || '',
contentType: 'text/html',
pretendToBeVisual: true,
runScripts: 'dangerously',
});
const win = dom.window;
const doc = win.document;
const nav = win.navigator;
patchEnvironment(win, doc, nav, options);
return dom;
}
致命级补丁(缺失即被服务端拒绝)
1. Function.prototype.toString 三层防御
问题:jsdom 所有 DOM 方法都是 JS 实现,toString() 会暴露完整源码(如 createElement(localName) { const esValue = ...),
JSVMP 通过此检测判断是否在真实浏览器中。
难度:★★★★★ — 这是 jsdom 环境伪装的第一杀手
const _origFnToString = win.Function.prototype.toString;
const nativeFnSet = new WeakSet();
// 第一层:精确标记 — WeakSet 注册已知 jsdom 内置函数
function markNative(fn) {
if (typeof fn === 'function') {
nativeFnSet.add(fn);
try {
const name = fn.name || '';
// 第三层:实例级覆写 — 直接在函数对象上定义 toString
Object.defineProperty(fn, 'toString', {
value: function () { return `function ${name}() { [native code] }`; },
writable: true, configurable: true,
});
} catch (e) {}
}
return fn;
}
// 第二层:源码模式正则 — 捕获 WeakSet 遗漏的 jsdom 函数
const jsdomPatterns = [
/^\s*\w+\s*\([^)]*\)\s*\{[\s\S]*?const\s+esValue\s*=/, // jsdom 接口方法模式
/^\s*function\s*\([^)]*\)\s*\{[\s\S]*?this\._globalObject/, // jsdom 全局对象访问模式
/^\s*\w+\s*\([^)]*\)\s*\{\s*const\s+\w+\s*=\s*this\s*!==/, // jsdom this 检查模式
];
// 覆写全局 Function.prototype.toString
win.Function.prototype.toString = function () {
// 第一层:WeakSet 精确匹配
if (nativeFnSet.has(this)) {
return `function ${this.name || ''}() { [native code] }`;
}
let src;
try { src = _origFnToString.call(this); } catch (e) {
return 'function () { [native code] }';
}
// 第二层:源码模式正则检测
for (const pat of jsdomPatterns) {
if (pat.test(src)) return `function ${this.name || ''}() { [native code] }`;
}
return src;
};
// 深度扫描原型链,批量标记所有 jsdom 内置方法
function scanPrototypeChain(obj, maxDepth) {
let proto = obj;
for (let d = 0; d < (maxDepth || 10) && proto; d++) {
for (const name of Object.getOwnPropertyNames(proto)) {
try {
const desc = Object.getOwnPropertyDescriptor(proto, name);
if (desc && typeof desc.value === 'function') markNative(desc.value);
if (desc && typeof desc.get === 'function') markNative(desc.get);
if (desc && typeof desc.set === 'function') markNative(desc.set);
} catch (e) {}
}
proto = Object.getPrototypeOf(proto);
if (proto === Object.prototype || proto === null) break;
}
}
// 扫描 50+ 原型链
const protoTargets = [
win.Document?.prototype, win.HTMLDocument?.prototype,
win.Element?.prototype, win.HTMLElement?.prototype,
win.Node?.prototype, win.EventTarget?.prototype,
win.XMLHttpRequest?.prototype, win.HTMLCanvasElement?.prototype,
win.HTMLInputElement?.prototype, win.HTMLFormElement?.prototype,
win.HTMLAnchorElement?.prototype, win.HTMLImageElement?.prototype,
win.HTMLDivElement?.prototype, win.HTMLSpanElement?.prototype,
win.HTMLBodyElement?.prototype, win.HTMLHeadElement?.prototype,
win.HTMLScriptElement?.prototype, win.HTMLStyleElement?.prototype,
win.HTMLLinkElement?.prototype, win.HTMLMetaElement?.prototype,
win.Window?.prototype, win.Location?.prototype,
win.DOMParser?.prototype, win.URL?.prototype,
win.Event?.prototype, win.CustomEvent?.prototype,
win.MutationObserver?.prototype, win.ResizeObserver?.prototype,
win.DOMTokenList?.prototype, win.NamedNodeMap?.prototype,
win.NodeList?.prototype, win.HTMLCollection?.prototype,
win.CSSStyleDeclaration?.prototype, win.Text?.prototype,
win.Comment?.prototype, win.DocumentFragment?.prototype,
win.Range?.prototype, win.Selection?.prototype,
win.TreeWalker?.prototype, win.NodeIterator?.prototype,
win.Attr?.prototype, win.CharacterData?.prototype,
win.Storage?.prototype, win.FormData?.prototype,
win.Headers?.prototype,
];
for (const proto of protoTargets) {
if (proto) scanPrototypeChain(proto, 3);
}
scanPrototypeChain(doc, 5);
scanPrototypeChain(nav, 5);
2. navigator.webdriver
问题:jsdom 中 navigator.webdriver 为 undefined,真实浏览器为 false(Camoufox 在 C++ 层已处理)。
Object.defineProperty(nav, 'webdriver', {
get: () => false, configurable: true, enumerable: true,
});
3. navigator.plugins 完整结构模拟
问题:jsdom plugins.length = 0,且无 item()/namedItem() 方法。JSVMP 不只检查长度,还会遍历结构和检查 Symbol.toStringTag。
const pluginData = [
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
];
const mimeData = [
{ type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
{ type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
];
function buildPluginArray(win) {
const mimeTypes = mimeData.map((m, i) => {
const mt = Object.create(null);
Object.defineProperties(mt, {
type: { get: markNative(function type() { return m.type; }), enumerable: true },
description: { get: markNative(function description() { return m.description; }), enumerable: true },
suffixes: { get: markNative(function suffixes() { return m.suffixes; }), enumerable: true },
enabledPlugin: { get: markNative(function enabledPlugin() { return plugins[0]; }), enumerable: true },
});
mt[Symbol.toStringTag] = 'MimeType';
return mt;
});
const plugins = pluginData.map((p, i) => {
const plugin = Object.create(null);
Object.defineProperties(plugin, {
name: { get: markNative(function name() { return p.name; }), enumerable: true },
filename: { get: markNative(function filename() { return p.filename; }), enumerable: true },
description: { get: markNative(function description() { return p.description; }), enumerable: true },
length: { get: markNative(function length() { return mimeTypes.length; }), enumerable: true },
});
mimeTypes.forEach((mt, mi) => { plugin[mi] = mt; });
plugin.item = markNative(function item(idx) { return mimeTypes[idx] || null; });
plugin.namedItem = markNative(function namedItem(name) {
return mimeTypes.find(m => m.type === name) || null;
});
plugin[Symbol.toStringTag] = 'Plugin';
plugin[Symbol.iterator] = markNative(function* () { yield* mimeTypes; });
return plugin;
});
const pluginArray = Object.create(null);
Object.defineProperty(pluginArray, 'length', {
get: markNative(function length() { return plugins.length; }),
enumerable: true,
});
plugins.forEach((p, i) => { pluginArray[i] = p; });
pluginArray.item = markNative(function item(idx) { return plugins[idx] || null; });
pluginArray.namedItem = markNative(function namedItem(name) {
return plugins.find(p => p.name === name) || null;
});
pluginArray.refresh = markNative(function refresh() {});
pluginArray[Symbol.toStringTag] = 'PluginArray';
pluginArray[Symbol.iterator] = markNative(function* () { yield* plugins; });
Object.defineProperty(nav, 'plugins', {
get: markNative(function plugins() { return pluginArray; }),
configurable: true, enumerable: true,
});
// mimeTypes
const mimeTypeArray = Object.create(null);
Object.defineProperty(mimeTypeArray, 'length', {
get: markNative(function length() { return mimeTypes.length; }),
enumerable: true,
});
mimeTypes.forEach((m, i) => { mimeTypeArray[i] = m; });
mimeTypeArray.item = markNative(function item(idx) { return mimeTypes[idx] || null; });
mimeTypeArray.namedItem = markNative(function namedItem(name) {
return mimeTypes.find(m => m.type === name) || null;
});
mimeTypeArray[Symbol.toStringTag] = 'MimeTypeArray';
mimeTypeArray[Symbol.iterator] = markNative(function* () { yield* mimeTypes; });
Object.defineProperty(nav, 'mimeTypes', {
get: markNative(function mimeTypes() { return mimeTypeArray; }),
configurable: true, enumerable: true,
});
}
4. document.hasFocus()
问题:jsdom 的 document.hasFocus() 始终返回 false,真实浏览器活动标签页返回 true。
doc.hasFocus = markNative(function hasFocus() { return true; });
5. DOM 布局属性(offsetHeight/Width/getBoundingClientRect)
问题:jsdom 无渲染引擎,所有布局属性返回 0。JSVMP 创建不可见元素测量布局值来检测。
function patchDOMLayout(win) {
const HTMLElement = win.HTMLElement;
if (!HTMLElement) return;
function parseStyleDimension(el, prop) {
try {
const style = el.getAttribute && el.getAttribute('style');
if (style) {
const m = style.match(new RegExp(prop + '\\s*:\\s*(\\d+)'));
if (m) return parseInt(m[1], 10);
}
} catch (e) {}
return 0;
}
const layoutProps = ['offsetHeight', 'offsetWidth', 'clientHeight', 'clientWidth',
'scrollHeight', 'scrollWidth', 'offsetTop', 'offsetLeft'];
for (const prop of layoutProps) {
Object.defineProperty(HTMLElement.prototype, prop, {
get: markNative(function () {
const dim = prop.includes('Height') || prop.includes('Top') ? 'height' : 'width';
const fromStyle = parseStyleDimension(this, dim);
if (fromStyle > 0) return fromStyle;
const defaults = { offsetHeight: 150, offsetWidth: 300, clientHeight: 150,
clientWidth: 300, scrollHeight: 150, scrollWidth: 300, offsetTop: 0, offsetLeft: 0 };
return defaults[prop] || 0;
}),
configurable: true, enumerable: true,
});
}
HTMLElement.prototype.getBoundingClientRect = markNative(function getBoundingClientRect() {
const w = parseStyleDimension(this, 'width') || 300;
const h = parseStyleDimension(this, 'height') || 150;
return { x: 0, y: 0, top: 0, left: 0, right: w, bottom: h, width: w, height: h,
toJSON: markNative(function toJSON() { return this; }) };
});
HTMLElement.prototype.getClientRects = markNative(function getClientRects() {
return [this.getBoundingClientRect()];
});
}
高危级补丁(可能参与指纹哈希)
6. Object.prototype.toString 标签(Symbol.toStringTag)
问题:jsdom 的 document 原型链是 Document 而非 HTMLDocument,screen/performance 是普通对象。
function patchToStringTags(win, doc, nav) {
const tagMap = [
[doc, 'HTMLDocument'],
[nav, 'Navigator'],
[win.screen, 'Screen'],
[win.performance, 'Performance'],
[win.location, 'Location'],
];
for (const [obj, tag] of tagMap) {
if (obj) {
try { obj[Symbol.toStringTag] = tag; } catch (e) {}
}
}
// 修复 document 的原型链构造函数名称
try {
const docProto = Object.getPrototypeOf(doc);
if (docProto && docProto.constructor && docProto.constructor.name !== 'HTMLDocument') {
Object.defineProperty(docProto.constructor, 'name', { value: 'HTMLDocument' });
}
} catch (e) {}
}
7. window.chrome 对象
问题:jsdom 中 window.chrome 为 undefined,Chrome 浏览器必定存在此对象。
function patchChrome(win) {
win.chrome = {
app: {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
getDetails: markNative(function getDetails() { return null; }),
getIsInstalled: markNative(function getIsInstalled() { return false; }),
},
runtime: {
OnInstalledReason: {
CHROME_UPDATE: 'chrome_update', INSTALL: 'install',
SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update',
},
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
PlatformOs: { ANDROID: 'android', CROS: 'cros', FUCHSIA: 'fuchsia', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },
RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' },
connect: markNative(function connect() { return { onMessage: { addListener: () => {} }, onDisconnect: { addListener: () => {} } }; }),
sendMessage: markNative(function sendMessage() {}),
id: undefined,
},
csi: markNative(function csi() { return {}; }),
loadTimes: markNative(function loadTimes() {
return { commitLoadTime: Date.now() / 1000, connectionInfo: 'h2', finishDocumentLoadTime: 0,
finishLoadTime: 0, firstPaintAfterLoadTime: 0, firstPaintTime: 0,
navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: Date.now() / 1000 - 0.5,
startLoadTime: Date.now() / 1000 - 0.3, wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true, wasNpnNegotiated: true };
}),
};
}
8. navigator.userAgentData
问题:jsdom 中不存在 NavigatorUAData,Chrome 86+ 浏览器默认提供。
function patchUserAgentData(nav, uaBrands) {
const brands = uaBrands || [
{ brand: 'Chromium', version: '120' },
{ brand: 'Google Chrome', version: '120' },
{ brand: 'Not_A Brand', version: '8' },
];
const uaData = {
brands: brands,
mobile: false,
platform: 'macOS',
getHighEntropyValues: markNative(function getHighEntropyValues(hints) {
return Promise.resolve({
brands, mobile: false, platform: 'macOS',
architecture: 'arm', bitness: '64', model: '',
platformVersion: '14.2.0', uaFullVersion: '120.0.0.0',
fullVersionList: brands.map(b => ({ ...b })),
wow64: false,
});
}),
toJSON: markNative(function toJSON() { return { brands, mobile: false, platform: 'macOS' }; }),
};
uaData[Symbol.toStringTag] = 'NavigatorUAData';
Object.defineProperty(nav, 'userAgentData', {
get: markNative(function userAgentData() { return uaData; }),
configurable: true, enumerable: true,
});
}
9. performance.timing / navigation / memory
问题:jsdom 中 performance.timing 和 performance.navigation 为 undefined。
function patchPerformance(win) {
const perf = win.performance;
if (!perf) return;
const now = Date.now();
const timing = {
navigationStart: now - 1200, unloadEventStart: now - 1100, unloadEventEnd: now - 1099,
redirectStart: 0, redirectEnd: 0, fetchStart: now - 1050,
domainLookupStart: now - 1000, domainLookupEnd: now - 990,
connectStart: now - 980, connectEnd: now - 900, secureConnectionStart: now - 950,
requestStart: now - 890, responseStart: now - 500, responseEnd: now - 400,
domLoading: now - 380, domInteractive: now - 200, domContentLoadedEventStart: now - 190,
domContentLoadedEventEnd: now - 185, domComplete: now - 50,
loadEventStart: now - 40, loadEventEnd: now - 30,
};
timing[Symbol.toStringTag] = 'PerformanceTiming';
Object.defineProperty(perf, 'timing', {
get: markNative(function timing() { return timing; }),
configurable: true, enumerable: true,
});
const navigation = { type: 0, redirectCount: 0 };
navigation[Symbol.toStringTag] = 'PerformanceNavigation';
Object.defineProperty(perf, 'navigation', {
get: markNative(function navigation() { return navigation; }),
configurable: true, enumerable: true,
});
const memory = { jsHeapSizeLimit: 4294705152, totalJSHeapSize: 35194260, usedJSHeapSize: 24486084 };
Object.defineProperty(perf, 'memory', {
get: markNative(function memory() { return memory; }),
configurable: true, enumerable: true,
});
}
10. navigator 基础属性覆盖
function patchNavigatorBasics(nav, options) {
const overrides = {
platform: options.platform || 'MacIntel',
language: options.language || 'zh-CN',
languages: options.languages || ['zh-CN', 'zh'],
vendor: options.vendor || 'Google Inc.',
productSub: '20030107',
appVersion: options.appVersion || '5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
hardwareConcurrency: options.hardwareConcurrency || 8,
maxTouchPoints: options.maxTouchPoints || 0,
deviceMemory: options.deviceMemory || 8,
pdfViewerEnabled: true,
cookieEnabled: true,
onLine: true,
doNotTrack: null,
};
for (const [key, value] of Object.entries(overrides)) {
try {
Object.defineProperty(nav, key, {
get: markNative(function () { return value; }),
configurable: true, enumerable: true,
});
} catch (e) {}
}
}
11. document 属性补丁
function patchDocument(doc) {
Object.defineProperty(doc, 'hidden', {
get: markNative(function hidden() { return false; }),
configurable: true, enumerable: true,
});
Object.defineProperty(doc, 'visibilityState', {
get: markNative(function visibilityState() { return 'visible'; }),
configurable: true, enumerable: true,
});
// readyState 在 jsdom 中默认为 'loading',JSVMP 运行时应为 'complete'
Object.defineProperty(doc, 'readyState', {
get: markNative(function readyState() { return 'complete'; }),
configurable: true, enumerable: true,
});
Object.defineProperty(doc, 'fullscreenEnabled', {
get: markNative(function fullscreenEnabled() { return true; }),
configurable: true, enumerable: true,
});
}
12. screen 对象重建
问题:jsdom 的 screen 是普通对象,缺少 orientation、availLeft/availTop,且 colorDepth 可能不正确。
function patchScreen(win, options) {
const screenData = {
width: options.width || 1920, height: options.height || 1080,
availWidth: options.availWidth || 1920, availHeight: options.availHeight || 1055,
availLeft: 0, availTop: 25,
colorDepth: options.colorDepth || 30, pixelDepth: options.pixelDepth || 30,
orientation: {
type: 'landscape-primary', angle: 0,
onchange: null,
[Symbol.toStringTag]: 'ScreenOrientation',
},
};
screenData[Symbol.toStringTag] = 'Screen';
for (const [key, value] of Object.entries(screenData)) {
try {
Object.defineProperty(win.screen, key, {
get: markNative(function () { return value; }),
configurable: true, enumerable: true,
});
} catch (e) {}
}
}
中危级补丁(API 存在性检测)
JSVMP 可能通过 typeof window.XXX 检测 API 是否存在。以下是 jsdom 缺失但浏览器存在的 API 存根。
核心原则:每个存根函数必须经过 markNative() 处理,否则 toString() 检测会暴露。
13. 批量 API 存根
function patchWindowAPIs(win) {
// Notification
if (!win.Notification) {
win.Notification = markNative(function Notification() {});
win.Notification.permission = 'default';
win.Notification.requestPermission = markNative(function requestPermission() {
return Promise.resolve('default');
});
win.Notification.maxActions = 2;
}
// Worker / SharedWorker
if (!win.Worker) {
win.Worker = markNative(function Worker() {
return { postMessage: markNative(function() {}), terminate: markNative(function() {}),
addEventListener: markNative(function() {}), onmessage: null, onerror: null };
});
}
if (!win.SharedWorker) {
win.SharedWorker = markNative(function SharedWorker() {
return { port: { postMessage: markNative(function() {}), start: markNative(function() {}),
addEventListener: markNative(function() {}), onmessage: null } };
});
}
// RTCPeerConnection
if (!win.RTCPeerConnection) {
win.RTCPeerConnection = markNative(function RTCPeerConnection() {
return { createDataChannel: markNative(function() { return {}; }),
createOffer: markNative(function() { return Promise.resolve({}); }),
setLocalDescription: markNative(function() { return Promise.resolve(); }),
close: markNative(function() {}), onicecandidate: null };
});
}
// AudioContext / OfflineAudioContext
if (!win.AudioContext) {
win.AudioContext = markNative(function AudioContext() {
return { sampleRate: 44100, state: 'suspended', currentTime: 0, destination: {},
createOscillator: markNative(function() {
return { type: 'sine', frequency: { value: 440 },
connect: markNative(function() {}), start: markNative(function() {}),
stop: markNative(function() {}) };
}),
createDynamicsCompressor: markNative(function() {
return { threshold: { value: -24 }, knee: { value: 30 },
ratio: { value: 12 }, reduction: 0,
connect: markNative(function() {}) };
}),
createAnalyser: markNative(function() { return { connect: markNative(function() {}) }; }),
createGain: markNative(function() { return { gain: { value: 1 }, connect: markNative(function() {}) }; }),
close: markNative(function() { return Promise.resolve(); }),
resume: markNative(function() { return Promise.resolve(); }) };
});
}
if (!win.OfflineAudioContext) {
win.OfflineAudioContext = markNative(function OfflineAudioContext(channels, length, sampleRate) {
const ctx = new win.AudioContext();
ctx.startRendering = markNative(function startRendering() {
return Promise.resolve({ duration: length / sampleRate, length: length,
numberOfChannels: channels, sampleRate: sampleRate,
getChannelData: markNative(function() { return new Float32Array(length); }) });
});
return ctx;
});
}
// fetch + Request/Response/Headers/AbortController
if (!win.fetch) {
win.fetch = markNative(function fetch() { return Promise.reject(new Error('Network error')); });
win.Request = markNative(function Request() {});
win.Response = markNative(function Response() {});
if (!win.AbortController) {
win.AbortController = markNative(function AbortController() {
return { signal: { aborted: false }, abort: markNative(function() {}) };
});
}
}
// matchMedia
if (!win.matchMedia) {
win.matchMedia = markNative(function matchMedia(query) {
return { matches: false, media: query, onchange: null,
addListener: markNative(function() {}), removeListener: markNative(function() {}),
addEventListener: markNative(function() {}), removeEventListener: markNative(function() {}) };
});
}
// requestIdleCallback / cancelIdleCallback
if (!win.requestIdleCallback) {
win.requestIdleCallback = markNative(function requestIdleCallback(cb) {
return win.setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
});
win.cancelIdleCallback = markNative(function cancelIdleCallback(id) { win.clearTimeout(id); });
}
// requestAnimationFrame(jsdom pretendToBeVisual=true 已提供,但检查一下)
if (!win.requestAnimationFrame) {
win.requestAnimationFrame = markNative(function requestAnimationFrame(cb) {
return win.setTimeout(() => cb(Date.now()), 16);
});
win.cancelAnimationFrame = markNative(function cancelAnimationFrame(id) { win.clearTimeout(id); });
}
// indexedDB
if (!win.indexedDB) {
win.indexedDB = { open: markNative(function open() { return {}; }) };
win.indexedDB[Symbol.toStringTag] = 'IDBFactory';
}
// caches
if (!win.caches) {
win.caches = {
open: markNative(function open() { return Promise.resolve({}); }),
has: markNative(function has() { return Promise.resolve(false); }),
delete: markNative(function _delete() { return Promise.resolve(false); }),
keys: markNative(function keys() { return Promise.resolve([]); }),
match: markNative(function match() { return Promise.resolve(undefined); }),
};
win.caches[Symbol.toStringTag] = 'CacheStorage';
}
// speechSynthesis
if (!win.speechSynthesis) {
win.speechSynthesis = {
speaking: false, pending: false, paused: false,
getVoices: markNative(function getVoices() { return []; }),
speak: markNative(function speak() {}),
cancel: markNative(function cancel() {}),
};
win.speechSynthesis[Symbol.toStringTag] = 'SpeechSynthesis';
}
// customElements
if (!win.customElements) {
win.customElements = {
define: markNative(function define() {}),
get: markNative(function get() { return undefined; }),
whenDefined: markNative(function whenDefined() { return Promise.resolve(); }),
};
win.customElements[Symbol.toStringTag] = 'CustomElementRegistry';
}
// visualViewport
if (!win.visualViewport) {
win.visualViewport = {
width: win.innerWidth || 1920, height: win.innerHeight || 1080,
offsetLeft: 0, offsetTop: 0, pageLeft: 0, pageTop: 0, scale: 1,
onresize: null, onscroll: null,
addEventListener: markNative(function() {}), removeEventListener: markNative(function() {}),
};
win.visualViewport[Symbol.toStringTag] = 'VisualViewport';
}
// isSecureContext
if (win.isSecureContext === undefined) {
Object.defineProperty(win, 'isSecureContext', {
get: markNative(function isSecureContext() { return true; }),
configurable: true, enumerable: true,
});
}
// clientInformation
if (!win.clientInformation) {
Object.defineProperty(win, 'clientInformation', {
get: markNative(function clientInformation() { return nav; }),
configurable: true, enumerable: true,
});
}
}
14. navigator 扩展 API 存根
function patchNavigatorAPIs(nav) {
// connection (NetworkInformation)
if (!nav.connection) {
const conn = {
effectiveType: '4g', downlink: 10, rtt: 50, saveData: false,
onchange: null, type: 'wifi',
addEventListener: markNative(function() {}), removeEventListener: markNative(function() {}),
};
conn[Symbol.toStringTag] = 'NetworkInformation';
Object.defineProperty(nav, 'connection', {
get: markNative(function connection() { return conn; }),
configurable: true, enumerable: true,
});
}
// getBattery
if (!nav.getBattery) {
nav.getBattery = markNative(function getBattery() {
return Promise.resolve({
charging: true, chargingTime: 0, dischargingTime: Infinity, level: 1,
onchargingchange: null, onchargingtimechange: null,
ondischargingtimechange: null, onlevelchange: null,
});
});
}
}
15. document 扩展 API 存根
function patchDocumentAPIs(doc) {
// fonts (FontFaceSet)
if (!doc.fonts) {
const fonts = {
ready: Promise.resolve(),
status: 'loaded',
check: markNative(function check() { return true; }),
load: markNative(function load() { return Promise.resolve([]); }),
forEach: markNative(function forEach() {}),
entries: markNative(function entries() { return [][Symbol.iterator](); }),
keys: markNative(function keys() { return [][Symbol.iterator](); }),
values: markNative(function values() { return [][Symbol.iterator](); }),
[Symbol.iterator]: markNative(function () { return [][Symbol.iterator](); }),
size: 0,
};
fonts[Symbol.toStringTag] = 'FontFaceSet';
Object.defineProperty(doc, 'fonts', {
get: markNative(function fonts() { return fonts; }),
configurable: true, enumerable: true,
});
}
// timeline (DocumentTimeline)
if (!doc.timeline) {
const timeline = { currentTime: win.performance?.now?.() || 0 };
timeline[Symbol.toStringTag] = 'DocumentTimeline';
Object.defineProperty(doc, 'timeline', {
get: markNative(function timeline() { return timeline; }),
configurable: true, enumerable: true,
});
}
}
window 尺寸属性
function patchWindowDimensions(win, options) {
const dims = {
innerWidth: options.innerWidth || 1920,
innerHeight: options.innerHeight || 969,
outerWidth: options.outerWidth || 1920,
outerHeight: options.outerHeight || 1055,
devicePixelRatio: options.devicePixelRatio || 2,
scrollX: 0, scrollY: 0, pageXOffset: 0, pageYOffset: 0,
};
for (const [key, value] of Object.entries(dims)) {
try {
Object.defineProperty(win, key, {
get: markNative(function () { return value; }),
configurable: true, enumerable: true,
});
} catch (e) {}
}
}
Node.js 特征清除
在 jsdom 的 window 上下文中隐藏 Node.js 运行时特征:
function hideNodeFeatures(win) {
const nodeGlobals = ['process', 'module', 'exports', 'require',
'global', '__filename', '__dirname', 'Buffer'];
for (const g of nodeGlobals) {
try { delete win[g]; } catch (e) {
try { win[g] = undefined; } catch (e2) {}
}
}
}
完整 patchEnvironment 调用顺序
顺序很重要:markNative 必须在所有补丁之前定义,所有补丁函数必须经过 markNative 处理。
function patchEnvironment(win, doc, nav, options = {}) {
// ① 定义 markNative(三层防御)— 必须最先
setupMarkNative(win);
// ② 扫描并标记所有 jsdom 内置原型链方法
scanAllPrototypes(win, doc, nav);
// ③ 致命级补丁
patchNavigatorBasics(nav, options); // webdriver / platform / language
buildPluginArray(win); // plugins 完整结构
doc.hasFocus = markNative(function hasFocus() { return true; });
patchDOMLayout(win); // offsetHeight/Width/getBoundingClientRect
// ④ 高危级补丁
patchToStringTags(win, doc, nav); // Symbol.toStringTag
patchChrome(win); // window.chrome
patchUserAgentData(nav); // userAgentData
patchPerformance(win); // timing/navigation/memory
patchDocument(doc); // hasFocus/readyState/visibility
patchScreen(win, options); // colorDepth/orientation
patchWindowDimensions(win, options); // innerWidth/outerWidth/devicePixelRatio
// ⑤ 中危级补丁
patchWindowAPIs(win); // 30+ API 存根
patchNavigatorAPIs(nav); // connection/getBattery
patchDocumentAPIs(doc); // fonts/timeline
// ⑥ 清除 Node.js 特征
hideNodeFeatures(win);
// ⑦ 最后一次全局扫描,标记新增的所有函数
scanAllPrototypes(win, doc, nav);
}
环境采集对比方法
浏览器端采集(通过 Camoufox MCP)
分 4 批次通过 evaluate_js 在真实浏览器中执行采集脚本,
采集代码模板参考 cases/ 中的「浏览器指纹采集方法」段。
jsdom 端采集
在 Node.js 中用 win.eval() 执行完全相同的采集代码:
const dom = createPatchedJsdom('https://target.com');
const win = dom.window;
const jsdomResult = JSON.parse(win.eval(`JSON.stringify({
nav_userAgent: navigator.userAgent,
nav_plugins_length: navigator.plugins ? navigator.plugins.length : -1,
nav_webdriver: navigator.webdriver,
// ... 与浏览器端完全相同的采集代码
})`));
Diff 生成
function diffEnv(browserData, jsdomData) {
const diffs = [];
for (const key of Object.keys(browserData)) {
const bVal = JSON.stringify(browserData[key]);
const jVal = JSON.stringify(jsdomData[key]);
if (bVal !== jVal) {
diffs.push({ key, browser: browserData[key], jsdom: jsdomData[key] });
}
}
return diffs;
}
MCP 辅助工作流
1. compare_env → 快速采集浏览器基准环境数据(覆盖主流检测项)
→ 适合快速判断差异范围
2. evaluate_js → 分批采集细粒度环境值(4-5 批次)
→ 覆盖 Function.toString / Symbol.toStringTag / DOM 布局等 compare_env 不包含的项
→ 每批代码不要太长,避免执行超时
3. 本地 Node.js 脚本 → 在 jsdom 中运行相同采集代码 + 生成 diff 报告
4. 迭代修复 → 每次修复一批差异 → 重新 diff → 直到差异归零
5. 端到端验证 → jsdom 生成签名 → 真实接口请求 → 确认数据有效