Files
hello_js_reverse_skill/references/jsdom-env-patches.md
T
白夜 b5cf8b121a feat(v2.4.0): JSVMP 双路径攻克体系 + jsdom 环境补丁知识库
基于实战经验重构 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
2026-04-02 18:10:05 +08:00

33 KiB
Raw Blame History

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 生成签名 → 真实接口请求 → 确认数据有效