151 lines
5.3 KiB
JavaScript
151 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* 在 jsdom 中执行 goods.shtml 的原始脚本并导出 fp-behv 参数。
|
|
*
|
|
* 该脚本只生成 DeviceFP,不会提交 fp-behv 或 web_save。
|
|
* 调用方必须把同一份 goods HTML、goods URL 与生成的 fp 参数配对使用。
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { JSDOM, ResourceLoader } from 'jsdom';
|
|
import { patchEnvironment } from './jsdom-patch-env.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const argv = process.argv.slice(2);
|
|
const getArg = (name, fallback = '') => {
|
|
const index = argv.indexOf(name);
|
|
return index === -1 ? fallback : argv[index + 1];
|
|
};
|
|
|
|
const htmlPath = getArg('--html');
|
|
const goodsUrl = getArg('--goods-url');
|
|
const cookiePath = getArg('--cookies');
|
|
const outputPath = getArg('--output');
|
|
const waitMs = Number.parseInt(getArg('--wait', '5000'), 10);
|
|
const useArchivedAssets = argv.includes('--archived-assets');
|
|
|
|
function fail(message) {
|
|
console.error(`generate-devicefp-jsdom: ${message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
if (!htmlPath || !goodsUrl || !outputPath) {
|
|
fail('用法: node scripts/generate-devicefp-jsdom.mjs --html <goods.html> --goods-url <URL> --output <fp.json> [--cookies <session.json>]');
|
|
}
|
|
if (!fs.existsSync(htmlPath)) fail(`HTML 不存在: ${htmlPath}`);
|
|
if (cookiePath && !fs.existsSync(cookiePath)) fail(`会话文件不存在: ${cookiePath}`);
|
|
|
|
const archived = {
|
|
'vendor.': path.join(ROOT, 'replay', 'vendor.js'),
|
|
'cgiVendor.': path.join(ROOT, 'replay', 'cgiVendor.js'),
|
|
'goodsBiz.': path.join(ROOT, 'replay', 'goodsBiz.js'),
|
|
'goods.': path.join(ROOT, 'replay', 'goods.js'),
|
|
};
|
|
|
|
class GoodsLoader extends ResourceLoader {
|
|
fetch(url, options) {
|
|
if (!useArchivedAssets) return super.fetch(url, options);
|
|
try {
|
|
const file = new URL(url).pathname.split('/').pop();
|
|
for (const [prefix, localPath] of Object.entries(archived)) {
|
|
if (file.startsWith(prefix)) return Promise.resolve(fs.readFileSync(localPath));
|
|
}
|
|
} catch {
|
|
// jsdom will surface resource errors through its virtual console.
|
|
}
|
|
return Promise.resolve(Buffer.from(''));
|
|
}
|
|
}
|
|
|
|
const session = cookiePath ? JSON.parse(fs.readFileSync(cookiePath, 'utf8')) : {};
|
|
const cookies = session.cookies || {};
|
|
const resourceErrors = [];
|
|
let capturedFp = null;
|
|
let resolveFpCapture = null;
|
|
const startedAt = Date.now();
|
|
|
|
function captureFp(url, body) {
|
|
const values = Object.fromEntries(new URLSearchParams(body));
|
|
if (!values.SessionID || !values.DeviceFP) return;
|
|
capturedFp = { url, body };
|
|
if (resolveFpCapture) resolveFpCapture();
|
|
}
|
|
|
|
const dom = new JSDOM(fs.readFileSync(htmlPath, 'utf8'), {
|
|
url: goodsUrl,
|
|
referrer: 'https://z.iwan.yyb.qq.com/',
|
|
pretendToBeVisual: true,
|
|
runScripts: 'dangerously',
|
|
resources: new GoodsLoader(),
|
|
beforeParse(window) {
|
|
patchEnvironment(window);
|
|
for (const [name, value] of Object.entries(cookies)) {
|
|
if (value) window.document.cookie = `${name}=${value}`;
|
|
}
|
|
|
|
// goods boot only needs an accepted fp-behv response to continue. Keep all
|
|
// traffic local so this process cannot accidentally submit a payment.
|
|
const NativeXHR = window.XMLHttpRequest;
|
|
window.XMLHttpRequest = class extends NativeXHR {
|
|
open(method, url, async = true) {
|
|
this.__yybMethod = method;
|
|
this.__yybUrl = String(url);
|
|
return super.open(method, url, async);
|
|
}
|
|
|
|
send(body) {
|
|
const requestBody = String(body || '');
|
|
if (this.__yybUrl.includes('fp-behv.fcg')) {
|
|
captureFp(this.__yybUrl, requestBody);
|
|
}
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = '{"ret":0,"msg":"","interval":10,"page_num":8,"report_flag":0}';
|
|
if (typeof this.onreadystatechange === 'function') this.onreadystatechange();
|
|
if (typeof this.onload === 'function') this.onload();
|
|
}
|
|
};
|
|
window.addEventListener('error', event => {
|
|
resourceErrors.push(String(event.error || event.message || 'unknown error'));
|
|
});
|
|
},
|
|
});
|
|
|
|
const timeoutMs = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 5000;
|
|
let timeoutId;
|
|
await Promise.race([
|
|
new Promise(resolve => {
|
|
resolveFpCapture = resolve;
|
|
if (capturedFp) resolve();
|
|
}),
|
|
new Promise(resolve => { timeoutId = setTimeout(resolve, timeoutMs); }),
|
|
]);
|
|
clearTimeout(timeoutId);
|
|
if (!capturedFp) {
|
|
dom.window.close();
|
|
fail(`未生成 fp-behv;加载错误: ${resourceErrors.slice(0, 3).join(' | ') || '无'}`);
|
|
}
|
|
|
|
const fp = capturedFp;
|
|
const values = Object.fromEntries(new URLSearchParams(fp.body));
|
|
const result = {
|
|
generated_at: new Date().toISOString(),
|
|
goods_url: goodsUrl,
|
|
fp_url: new URL(fp.url, goodsUrl).href,
|
|
fp_body: fp.body,
|
|
session_id: values.SessionID,
|
|
device_fp_length: values.DeviceFP.length,
|
|
archived_assets: useArchivedAssets,
|
|
};
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n');
|
|
fs.chmodSync(path.dirname(outputPath), 0o700);
|
|
fs.chmodSync(outputPath, 0o600);
|
|
dom.window.close();
|
|
console.log(`DeviceFP 已保存: ${outputPath}`);
|
|
console.log(`SessionID: ${result.session_id}`);
|
|
console.log(`DeviceFP 长度: ${result.device_fp_length}`);
|
|
console.log(`DeviceFP 捕获耗时: ${Date.now() - startedAt}ms`);
|