初步增加, 扫码登录成功

This commit is contained in:
yml2213
2026-08-12 16:22:55 +08:00
parent 7db3b2cd4f
commit d19c0902f2
60 changed files with 7413 additions and 315 deletions
@@ -0,0 +1,405 @@
/**
* jsdom 环境补丁(skill 路径 B 第 4 步,最小化:只补 kepler 实际读取项)。
* 数据源:scripts/capture-kepler-env.mjs 采集的真实浏览器快照。
*
* 补丁项(capture-kepler-env 证明读取):
* canvas 2d + webgl(指纹核心,jsdom 无)
* navigator.platform/languages/maxTouchPoints/vendor/webdriver/...
* screen 尺寸/色深,window 尺寸,plugins/mimeTypes,perf.now
*/
import fs from 'node:fs';
const BROWSER_ENV = {
navigator: {
platform: 'MacIntel',
languages: ['zh-CN', 'zh'],
hardwareConcurrency: 10,
deviceMemory: 16,
maxTouchPoints: 0,
webdriver: false,
vendor: 'Google Inc.',
vendorSub: '',
productSub: '20030107',
onLine: true,
pdfViewerEnabled: true,
},
screen: { width: 1440, height: 900, availWidth: 1440, availHeight: 900, colorDepth: 24, pixelDepth: 24 },
window: { devicePixelRatio: 1, innerWidth: 1440, innerHeight: 900, outerWidth: 1442, outerHeight: 1026 },
plugins: [
['PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'],
],
mimeTypes: [
['application/pdf', 'Portable Document Format', ['pdf']],
['text/pdf', 'Portable Document Format', ['pdf']],
],
// 常见 macOS 字体(measureText 字体探测用)
fonts: ['Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana', 'Georgia',
'Trebuchet MS', 'Comic Sans MS', 'Palatino Linotype', 'Book Antiqua', 'Tahoma',
'PingFang SC', 'PingFang HK', 'PingFang TC', 'Microsoft YaHei', 'SimSun', 'SimHei',
'Hiragino Sans GB', 'Hiragino Kaku Gothic ProN', 'Menlo', 'Monaco', 'Consolas',
'Lucida Console', 'Arial Black', 'Impact', 'Lucida Sans Unicode'],
};
// 浏览器真实 canvas 64x64 像素 + toDataURL(capture 自真实 Chrome,注入 mock 用)
let BROWSER_CANVAS = null;
try {
BROWSER_CANVAS = JSON.parse(fs.readFileSync('/tmp/browser-canvas64.json', 'utf8'));
} catch (e) {}
// 固定 canvas 输出(浏览器 240x60 指纹画布 toDataURL 约 13762 字符,生成一个同量级固定 PNG)
function makeCanvasDataURL() {
// 构造浏览器同量级、高熵(类真实 PNG)的 data URL,长度 13762 附近
const target = 13762;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let s = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAAD';
while (s.length < target) {
// 用随机高熵块(基于可复现伪随机,避免每次全变导致调试困难)
let seed = s.length * 2654435761 >>> 0;
for (let i = 0; i < 16 && s.length < target; i++) {
seed = (seed * 1103515245 + 12345) >>> 0;
s += chars[seed % 64];
}
}
s = s.slice(0, target);
return s;
}
export function patchEnvironment(window) {
const klog = (k, v) => { try { if (window.__klog && window.__klog.length < 5000) window.__klog.push(k + '=' + String(v).slice(0, 80)); } catch(e){} };
// Encoding API:kepler 探测 typeof window.TextEncoder(浏览器 "function",jsdom 缺)
if (typeof window.TextEncoder === 'undefined' && typeof globalThis.TextEncoder === 'function') {
window.TextEncoder = globalThis.TextEncoder;
try { window.TextEncoder.prototype = globalThis.TextEncoder.prototype; } catch (e) {}
}
if (typeof window.TextDecoder === 'undefined' && typeof globalThis.TextDecoder === 'function') {
window.TextDecoder = globalThis.TextDecoder;
try { window.TextDecoder.prototype = globalThis.TextDecoder.prototype; } catch (e) {}
}
// Web Audio mock:kepler 仅探测 OfflineAudioContext 存在 + sampleRate + destination.maxChannelCount + createOscillator(浏览器实测 8 条调用)
if (typeof window.OfflineAudioContext === 'undefined') {
const mkAudioParam = (defaultValue, minValue, maxValue, writable) => {
const p = {
defaultValue, minValue, maxValue, automationRate: 'a-rate',
cancelScheduledValues() { return this; }, setValueAtTime() { return this; },
linearRampToValueAtTime() { return this; }, exponentialRampToValueAtTime() { return this; },
setTargetAtTime() { return this; }, setValueCurveAtTime() { return this; },
cancelAndHoldAtTime() { return this; },
};
if (writable) {
// Chrome 150 实测:DynamicsCompressorNode 的 AudioParam.value 可写且钳制到 [min,max]
let val = defaultValue;
Object.defineProperty(p, 'value', {
get() { return val; },
set(v) { val = Math.min(maxValue, Math.max(minValue, v)); },
enumerable: true, configurable: true,
});
} else {
// Chrome 150 实测:OscillatorNode 的 frequency/detune 为 getter-only,严格模式赋值抛 TypeError
Object.defineProperty(p, 'value', {
get() { return defaultValue; },
set() { throw new TypeError('Cannot set property value of #<AudioParam> which has only a getter'); },
enumerable: true, configurable: true,
});
}
return p;
};
const mockNode = () => ({
type: 'sine', value: 0, gain: { value: 1 },
frequency: mkAudioParam(440, -22050, 22050),
detune: mkAudioParam(0, -153600, 153600),
connect() {}, disconnect() {}, start() {}, stop() {}, addEventListener() {}, removeEventListener() {},
});
class MockOfflineAudioContext {
constructor(channels, length, sampleRate) {
this.channels = channels;
this.length = length;
this.sampleRate = sampleRate || 44100;
this.destination = { maxChannelCount: 2, channelCount: 2, channelCountMode: 'explicit' };
this.currentTime = 0;
this.__listeners = {};
// 浏览器实测:startRendering() 后 renderedBuffer 为 AudioBuffer;kepler 读它做音频指纹
Object.defineProperty(this, 'renderedBuffer', {
get: () => ({
getChannelData: () => new Float32Array(1),
length: 1, numberOfChannels: 1, sampleRate: this.sampleRate || 44100, duration: 1 / (this.sampleRate || 44100),
}),
configurable: true,
});
}
addEventListener(type, fn) {
(this.__listeners[type] = this.__listeners[type] || []).push(fn);
}
removeEventListener(type, fn) {
const arr = this.__listeners[type] || [];
const i = arr.indexOf(fn);
if (i !== -1) arr.splice(i, 1);
}
dispatchEvent(ev) {
const arr = this.__listeners[ev.type] || [];
for (const fn of arr.slice()) fn.call(this, ev);
return true;
}
createOscillator() { return mockNode(); }
createGain() { return mockNode(); }
createDynamicsCompressor() {
// Chrome 150 实测值(capture-kepler-env 采集)
return {
threshold: mkAudioParam(-24, -100, 0, true),
knee: mkAudioParam(30, 0, 40, true),
ratio: mkAudioParam(12, 1, 20, true),
attack: mkAudioParam(0.003000000026077032, 0, 1, true),
release: mkAudioParam(0.25, 0, 1, true),
connect() {}, disconnect() {}, addEventListener() {}, removeEventListener() {},
};
}
createAnalyser() { return mockNode(); }
createBiquadFilter() { return mockNode(); }
createBuffer() { return { getChannelData: () => new Float32Array(0), numberOfChannels: 1, length: 0, sampleRate: this.sampleRate }; }
startRendering() {
// Chrome 行为:渲染完成后派发 oncomplete 事件(OfflineAudioCompletionEvent,带 renderedBuffer)
const buf = this.renderedBuffer;
Promise.resolve().then(() => {
const ev = { renderedBuffer: buf, target: this, currentTarget: this, type: 'complete', timeStamp: Date.now() };
if (typeof this.oncomplete === 'function') {
try {
this.oncomplete(ev);
} catch (e) {}
}
try { this.dispatchEvent(ev); } catch (e) {}
});
return Promise.resolve(buf);
}
suspend() { return Promise.resolve(); }
resume() { return Promise.resolve(); }
close() { return Promise.resolve(); }
}
window.OfflineAudioContext = MockOfflineAudioContext;
window.AudioContext = MockOfflineAudioContext;
window.webkitOfflineAudioContext = MockOfflineAudioContext;
}
const nav = window.navigator;
// 带读取日志的 getter(kepler 读取时记录,便于与浏览器对比)
for (const [k, v] of Object.entries(BROWSER_ENV.navigator)) {
try { Object.defineProperty(nav, k, { get: () => { klog('navigator.' + k, v); return v; }, configurable: true }); } catch (e) {}
}
const scr = window.screen;
for (const k of ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth']) {
try { Object.defineProperty(scr, k, { get: () => { klog('screen.' + k, BROWSER_ENV.screen[k]); return BROWSER_ENV.screen[k]; }, configurable: true }); } catch (e) {}
}
for (const k of ['devicePixelRatio', 'innerWidth', 'innerHeight', 'outerWidth', 'outerHeight']) {
try { Object.defineProperty(window, k, { get: () => { klog('window.' + k, BROWSER_ENV.window[k]); return BROWSER_ENV.window[k]; }, configurable: true }); } catch (e) {}
}
// plugins / mimeTypes(只读对象,支持索引与 length)
const mkPluginArray = (items) => {
const arr = items.map(([name, filename, description], i) => {
const p = {
name, filename, description,
length: 0, item: (j) => arr[j] || null, namedItem: (n) => arr.find(x => x.name === n) || null,
[Symbol.iterator]: function* () { yield* arr; },
refresh: () => {},
};
p.index = i;
return p;
});
Object.defineProperty(arr, 'length', { value: items.length });
arr.item = (j) => arr[j] || null;
arr.namedItem = (n) => arr.find(x => x.name === n) || null;
arr.refresh = () => {};
arr[Symbol.iterator] = function* () { yield* arr; };
return arr;
};
const plugins = mkPluginArray(BROWSER_ENV.plugins.map(([n, f, d]) => [n, f, d]));
const mimes = mkPluginArray(BROWSER_ENV.mimeTypes.map(([t, d, s]) => [t, d, s.join(',')]));
Object.defineProperty(nav, 'plugins', { value: plugins });
Object.defineProperty(nav, 'mimeTypes', { value: mimes });
// document.fonts(FontFaceSet 简化实现,支持 entries/keys/values/forEach/check/size)
const fontList = BROWSER_ENV.fonts.map(f => ({
family: f, weight: '400', style: 'normal', status: 'loaded',
}));
const fontSet = {
size: fontList.length,
status: 'loaded',
ready: new Promise((res) => res({})),
entries: () => fontList.entries(),
keys: function* () { for (const f of fontList) yield f.family; },
values: function* () { for (const f of fontList) yield f; },
forEach: (cb) => fontList.forEach(cb),
check: (font, text) => true,
load: (font, text) => Promise.resolve([]),
add: () => {}, delete: () => {},
[Symbol.iterator]: function* () { yield* fontList; },
};
try { Object.defineProperty(window.document, 'fonts', { value: fontSet, configurable: true }); } catch (e) {}
// ---- performance 资源条目(kepler 编码资源时序;jsdom 默认无) ----
const perfEntries = [
['https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml', 'navigation', 132, 0],
['https://pub.idqqimg.com/pc/misc/sentry/raven.min.js', 'script', 3, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/vendor.35cb3bc7d38036fed653.js', 'script', 85, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/cgiVendor.53275fa063461d28e404.js', 'script', 41, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/app/goodsBiz.47e6a80d478ce0bdfd50..js', 'script', 156, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/app/goods.e87ce812409f000bf518.js', 'script', 210, 0],
['https://midas.gtimg.cn/midas/minipay_v2/css/goods.e87ce812409f000bf518.css', 'link', 24, 0],
['https://midas.gtimg.cn/midas/minipay_v2/css/vendor.35cb3bc7d38036fed653.css', 'link', 12, 0],
['https://api.unipay.qq.com/v1/r/1450243039/web_page_info', 'xmlhttprequest', 88, 0],
['https://api.unipay.qq.com/cgi-bin/fp-behv.fcg', 'xmlhttprequest', 2, 0],
['https://xui.ptlogin2.qq.com/js/ptlogin_v1.js', 'script', 67, 0],
['https://midas.gtimg.cn/store_config/1561715149975dieRkvjp.png', 'img', 18, 0],
].map(([name, initiatorType, duration, transferSize]) => ({
name, initiatorType, duration, transferSize, startTime: 0, responseEnd: duration, connectEnd: duration, domContentLoadedEventEnd: 0, loadEventEnd: 0,
}));
const perf = window.performance;
try {
perf.getEntries = () => perfEntries.slice();
perf.getEntriesByType = (t) => t === 'resource' ? perfEntries.filter(e => e.initiatorType !== 'navigation') : (t === 'navigation' ? perfEntries.filter(e => e.initiatorType === 'navigation') : []);
perf.timeOrigin = 1786000000000;
if (!('getEntries' in perf) ) { /* ensure own prop */ }
} catch (e) {}
// ---- canvas 2d mock(记录绘制,提供固定 measureText/toDataURL/getImageData) ----
const makeCtx = (canvas) => {
const mark = (m, ...a) => klog('mock2d.' + m, a.map(x => String(x).slice(0, 30)).join('|'));
const ctx = {
canvas,
fillStyle: '#000', strokeStyle: '#000', font: '10px sans-serif', textBaseline: 'alphabetic',
globalAlpha: 1, globalCompositeOperation: 'source-over', lineWidth: 1, lineCap: 'butt',
shadowBlur: 0, shadowColor: 'rgba(0,0,0,0)', shadowOffsetX: 0, shadowOffsetY: 0,
measureText: (t) => {
mark('measureText', t);
// 字体相关宽度(kepler 字体枚举依赖:不同字体宽度不同)
const font = ctx.font || '10px sans-serif';
const fam = String(font).split(/\s+/).pop() || 'sans-serif';
let seed = 5381;
for (const ch of fam) seed = ((seed * 33) ^ ch.charCodeAt(0)) >>> 0;
const fontDelta = (seed % 73) / 2; // 0..36px,显著差异
const hasEmoji = /[\uD800-\uDBFF]|[\u00C0-\u024F]|¯/.test(String(t));
const emojiDelta = hasEmoji ? (seed % 17) : 0;
return { width: String(t).length * 7.2 + fontDelta + emojiDelta, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 };
},
getImageData: (x, y, w, h) => {
mark('getImageData', x, y, w, h);
try { if (window.__itrace) window.__gdAt = window.__itrace.length - 1; } catch (e) {}
if (BROWSER_CANVAS && BROWSER_CANVAS.data && w === 64 && h === 64) {
return { width: 64, height: 64, data: new Uint8ClampedArray(BROWSER_CANVAS.data) };
}
// 浏览器式像素:平滑全渐变(每像素颜色不同,模拟真实渲染的色彩丰富度)
const data = new Uint8ClampedArray(w * h * 4);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const i = (py * w + px) * 4;
// 平滑渐变:每像素的 RGB 随位置连续变化,产生大量不同颜色
const r = (px * 255 / w) & 0xff;
const g = (py * 255 / h) & 0xff;
const b = ((px + py) * 255 / (w + h)) & 0xff;
data[i] = r; data[i+1] = g; data[i+2] = b;
data[i+3] = 255 - ((px * 3 + py) % 80); // alpha 也变化
}
}
const img = { width: w, height: h, data };
return new Proxy(img, {
get(t, k) {
if (k === 'data') klog('imgData.data', 'len=' + t.data.length);
else if (k === 'width' || k === 'height') klog('imgData.' + k, t[k]);
return t[k];
},
});
},
createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
createLinearGradient: () => ({ addColorStop: () => {} }),
createRadialGradient: () => ({ addColorStop: () => {} }),
createPattern: () => ({ setTransform: () => {} }),
toDataURL: (type, q) => {
mark('toDataURL');
if (BROWSER_CANVAS && BROWSER_CANVAS.toDataURL) return BROWSER_CANVAS.toDataURL;
const c = (canvas.__tdc = (canvas.__tdc || 0) + 1);
const cc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[c % 32];
return canvas.__dataURL.slice(0, canvas.__dataURL.length - 1 - (c % 16)) + cc + canvas.__dataURL.slice(canvas.__dataURL.length - (c % 16));
},
getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
setTransform: (...a) => { mark('setTransform'); }, resetTransform: () => { mark('resetTransform'); }, transform: () => { mark('transform'); }, translate: (...a) => { mark('translate', a[0], a[1]); },
rotate: (a) => { mark('rotate', a); }, scale: (...a) => { mark('scale', a[0], a[1]); }, save: () => { mark('save'); }, restore: () => { mark('restore'); }, beginPath: () => { mark('beginPath'); },
closePath: () => { mark('closePath'); }, moveTo: (...a) => { mark('moveTo', a[0], a[1]); }, lineTo: (...a) => { mark('lineTo', a[0], a[1]); }, bezierCurveTo: () => { mark('bezierCurveTo'); },
quadraticCurveTo: () => { mark('quadraticCurveTo'); }, arc: (...a) => { mark('arc', a[0], a[1], a[2]); }, arcTo: () => { mark('arcTo'); }, rect: (...a) => { mark('rect', a[0], a[1], a[2], a[3]); }, fill: (...a) => { mark('fill', a[0]); },
stroke: () => { mark('stroke'); }, clip: () => { mark('clip'); }, fillRect: (...a) => { mark('fillRect', a[0], a[1], a[2], a[3]); }, strokeRect: () => { mark('strokeRect'); }, clearRect: () => { mark('clearRect'); },
fillText: (...a) => { mark('fillText', a[0], a[1], a[2]); }, strokeText: (...a) => { mark('strokeText', a[0]); }, drawImage: () => { mark('drawImage'); }, putImageData: () => { mark('putImageData'); },
isPointInPath: () => false, isPointInStroke: () => false,
};
return ctx;
};
// ---- webgl mock(kepler 调 getContext('webgl') + toDataURL) ----
const makeWebGL = (canvas) => ({
canvas,
drawingBufferWidth: 240, drawingBufferHeight: 60,
getParameter: (p) => {
klog('webgl.getParameter', p);
const map = {
7936: 'WebKit', 7937: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)', 7938: 'WebGL 1.0',
35724: 'WebGL', 37445: 'Google Inc. (NVIDIA)', 37446: 'ANGLE (Apple, Apple M1, OpenGL 4.1)',
3379: 64, 34076: 16384, 34921: 0, 36347: 8192,
};
return map[p] !== undefined ? map[p] : 0;
},
getExtension: (name) => {
klog('webgl.getExtension', name);
return name === 'WEBGL_debug_renderer_info' ? { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 } : null;
},
getSupportedExtensions: () => ['ANGLE_instanced_arrays', 'EXT_blend_minmax', 'EXT_texture_filter_anisotropic', 'OES_element_index_uint', 'OES_standard_derivatives', 'WEBGL_debug_renderer_info'],
getContextAttributes: () => ({ alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: false, powerPreference: 'default' }),
readPixels: () => {}, getUniformLocation: () => ({}), createBuffer: () => ({}),
createShader: () => ({}), createProgram: () => ({}), getShaderParameter: () => true,
getProgramParameter: () => true, bindBuffer: () => {}, bufferData: () => {},
shaderSource: () => {}, compileShader: () => {}, attachShader: () => {},
linkProgram: () => {}, useProgram: () => {}, vertexAttribPointer: () => {},
enableVertexAttribArray: () => {}, drawArrays: () => {}, viewport: () => {},
clearColor: () => {}, clear: () => {}, enable: () => {}, disable: () => {},
texImage2D: () => {}, texParameteri: () => {}, activeTexture: () => {}, bindTexture: () => {},
uniform1f: () => {}, uniform2f: () => {}, uniform3f: () => {}, uniform1i: () => {},
getAttribLocation: () => 0, getError: () => 0, getShaderInfoLog: () => '', getProgramInfoLog: () => '',
pixelStorei: () => {}, colorMask: () => {}, depthMask: () => {}, depthFunc: () => {},
blendFunc: () => {}, cullFace: () => {}, frontFace: () => {}, lineWidth: () => {},
getFramebufferAttachmentParameter: () => null, isContextLost: () => false,
getContextAttributes: () => ({}), loseContext: () => {}, restoreContext: () => {},
getParameterWithDefault: () => 0,
});
const canvasDataURL = makeCanvasDataURL();
const getContextOrig = window.HTMLCanvasElement.prototype.getContext;
// jsdom canvas 默认尺寸改 64x64(浏览器 kepler 指纹画布尺寸)
try {
Object.defineProperty(window.HTMLCanvasElement.prototype, 'width', {
get() { return 64; }, set() {}, configurable: true,
});
Object.defineProperty(window.HTMLCanvasElement.prototype, 'height', {
get() { return 64; }, set() {}, configurable: true,
});
} catch (e) {}
window.HTMLCanvasElement.prototype.getContext = function (type) {
if (type === '2d') {
if (!this.__ctx2d) {
this.__ctx2d = makeCtx(this);
Object.defineProperty(this, '__dataURL', { value: canvasDataURL, configurable: true });
}
return this.__ctx2d;
}
if (type === 'webgl' || type === 'experimental-webgl') {
if (!this.__webgl) {
this.__webgl = makeWebGL(this);
Object.defineProperty(this, '__dataURL', { value: canvasDataURL, configurable: true });
}
return this.__webgl;
}
return getContextOrig ? getContextOrig.apply(this, arguments) : null;
};
Object.defineProperty(window.HTMLCanvasElement.prototype, 'toDataURL', {
value: function () { return canvasDataURL; },
configurable: true,
});
}