初步增加, 扫码登录成功

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,132 @@
#!/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', '12000'), 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 captured = [];
const resourceErrors = [];
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')) {
captured.push({ url: this.__yybUrl, body: 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'));
});
},
});
await new Promise(resolve => setTimeout(resolve, Number.isFinite(waitMs) ? waitMs : 12000));
const fp = captured.at(-1);
if (!fp) {
dom.window.close();
fail(`未生成 fp-behv;加载错误: ${resourceErrors.slice(0, 3).join(' | ') || '无'}`);
}
const values = Object.fromEntries(new URLSearchParams(fp.body));
if (!values.SessionID || !values.DeviceFP) {
dom.window.close();
fail('fp-behv 缺少 SessionID 或 DeviceFP');
}
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');
dom.window.close();
console.log(`DeviceFP 已保存: ${outputPath}`);
console.log(`SessionID: ${result.session_id}`);
console.log(`DeviceFP 长度: ${result.device_fp_length}`);
@@ -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,
});
}
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""使用 jsdom DeviceFP 完成一笔已创建 YYB 订单的 web_save。
本脚本不创建 mall 订单;先由 ``main.py mall auto`` 创建订单,再执行本脚本。
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import re
import string
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from pyvm.algorithm import derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.order_status import ( # noqa: E402
completion_summary,
find_completed_order,
get_official_orders,
order_completion_states,
order_ids,
)
APPID = "1450243039"
GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
SAVE_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_save"
FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg"
ORDER_FIELDS = [
"token_id", "openid", "openkey", "session_id", "session_type", "zoneid",
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", "card_value",
"accounttype", "provide_uin", "extend", "from_h5", "webversion",
]
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def parse_url_params(mall_response: dict) -> dict[str, str]:
try:
call_reply = json.loads(mall_response["data"]["call_reply"])
url_params = call_reply["data"]["url_params"]
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
return {key: values[-1] for key, values in urllib.parse.parse_qs(
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()}
def cookie_header(cookies: dict[str, str]) -> str:
pairs = dict(cookies)
if not pairs.get("midas_openid") and pairs.get("openid"):
pairs["midas_openid"] = pairs["openid"]
if not pairs.get("midas_openkey") and pairs.get("accesstoken"):
pairs["midas_openkey"] = pairs["accesstoken"]
return "; ".join(f"{name}={value}" for name, value in pairs.items() if value)
def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) -> str:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"Cookie": cookie_header(cookies),
"Referer": "https://pay.qq.com/",
}
if body is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["Origin"] = "https://pay.qq.com"
request = urllib.request.Request(url, data=body, headers=headers,
method="POST" if body is not None else "GET")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.read().decode("utf-8", "replace")
def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str:
openid = cookies.get("openid") or cookies.get("midas_openid")
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
if not openid or not openkey:
raise ValueError("会话缺少 openid/accesstoken,请先完成授权扫码登录")
login = midas_login_params(cookies)
params = {
"appid": APPID,
"openid": openid,
"openkey": openkey,
"session_id": login["session_id"],
"session_type": login["session_type"],
"sandbox": "",
"wxappid": login["wx_appid"],
"qqAppid": login["qq_appid"],
"pf": pf or "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
"buy_quantity": "1",
"goodstokenurl": order["url_params"],
"zoneid": zone_id,
"supportCloseConfirm": "1",
"t": str(int(time.time() * 1000)),
}
return GOODS_URL + "?" + urllib.parse.urlencode(params)
def extract_goods_state(html: str) -> tuple[list[int], str, str]:
ops = re.search(r"var\s+xMidasOps\s*=\s*\[([^]]+)]", html)
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
if not ops or not token or not anti:
raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效")
xmidas = [int(value) for value in ops.group(1).split(",") if value]
if len(xmidas) != 59640:
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
return xmidas, token.group(1).upper(), anti.group(1).upper()
def load_template_args() -> tuple[str, list]:
runtime_template = ROOT / "replay/live/default"
runtime_args = runtime_template / "args-template.json"
runtime_body = runtime_template / "body.txt"
if runtime_args.exists() and runtime_body.exists():
return runtime_body.read_text(encoding="utf-8"), load_json(runtime_args)
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True)
for path in candidates:
if path.exists():
body = (path.parent / "body.txt").read_text(encoding="utf-8")
return body, load_json(path)
raise FileNotFoundError("缺少归档 goods body.txt/args-template.json")
def replace_form_value(body: str, name: str, value: str) -> str:
return re.sub(rf"({re.escape(name)}=)[^&]*", rf"\g<1>{value}", body, count=1)
def build_save_body(template: str, order: dict[str, str], cookies: dict[str, str], web_token: str,
anti_token: str, encrypt_msg: str) -> str:
login = midas_login_params(cookies)
values = {
"token_id": order.get("token_id", ""),
"transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
"offer_type": order.get("offer_type", "0"),
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
"web_token": web_token,
"anti_auto_script_token_id": anti_token,
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
"r": str(random.random()),
"t": str(int(time.time() * 1000)),
"encrypt_msg": encrypt_msg,
"session_id": login["session_id"],
"session_type": login["session_type"],
"wx_appid": login["wx_appid"],
"qq_appid": login["qq_appid"],
}
body = template
for name, value in values.items():
body = replace_form_value(body, name, value)
return body
def make_qr(sign: str, output: Path) -> None:
try:
import segno
except ImportError as exc:
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc
output.parent.mkdir(parents=True, exist_ok=True)
segno.make(sign).save(str(output), scale=6, border=2)
def node_environment() -> dict[str, str]:
"""Prevent a developer's Node inspector setting from affecting the jsdom worker."""
environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None)
return environment
def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None:
"""Persist a small, non-payment-side-effect status record for this run."""
record = {
"checked_at": int(time.time()),
"baseline_order_states": baseline,
"listed_order_ids": sorted(order_ids(document)),
"matched_completion": completion_summary(matched) if matched else None,
}
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json"))
parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-<timestamp>")
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
parser.add_argument("--wait", type=int, default=12, help="jsdom 等待 DeviceFP 的秒数")
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码")
parser.add_argument("--payment-timeout", type=float, default=300,
help="付款码生成后等待订单完成的最长秒数")
parser.add_argument("--payment-interval", type=float, default=3,
help="订单完成状态检查间隔秒数")
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成")
args = parser.parse_args()
if args.payment_timeout <= 0 or args.payment_interval <= 0:
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
session_path = Path(args.session).resolve()
response_path = Path(args.mall_response).resolve()
session = load_json(session_path)
cookies = dict(session.get("cookies", {}))
if not cookies:
raise ValueError("会话 cookies 为空,请先完成授权扫码登录")
response = load_json(response_path)
order = parse_url_params(response)
if not order.get("token_id"):
raise ValueError("mall 响应未包含 token_id")
try:
call_reply = json.loads(response["data"]["call_reply"])
order["url_params"] = call_reply["data"]["url_params"]
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError("mall 响应无法解析 url_params") from exc
stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
out_dir.mkdir(parents=True, exist_ok=True)
url = goods_page_url(cookies, order, args.zone_id, args.pf)
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
print("[jsdom-pay] 拉取同订单 goods 页面...")
html = request_bytes(url, cookies)
html_path = out_dir / "goods.html"
html_path.write_text(html, encoding="utf-8")
xmidas, web_token, anti_token = extract_goods_state(html)
print(f"[jsdom-pay] goods: xMidasOps={len(xmidas)} web_token={web_token[:12]}...")
fp_path = out_dir / "device-fp.json"
command = [
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path),
"--output", str(fp_path), "--wait", str(args.wait * 1000),
]
print("[jsdom-pay] 运行 jsdom DeviceFP...")
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
fp = load_json(fp_path)
if args.dry_run:
print(f"[jsdom-pay] dry-run 完成,证据目录: {out_dir}")
return 0
print("[jsdom-pay] 上报 fp-behv...")
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
(out_dir / "fp-response.json").write_text(fp_response, encoding="utf-8")
template, web_args = load_template_args()
key16 = [random.randrange(256) for _ in range(16)]
tables = [web_args[index][0] for index in (1, 2, 3, 4)]
key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0])
random_suffix = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + "\x01"
params = {
"token_id": order.get("token_id", ""),
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"session_id": "hy_gameid",
"session_type": "wc_actoken",
"zoneid": "1",
"pay_method": "wechat",
"buy_quantity": "1",
"mb_pwd": "",
"pay_id": "",
"auth_key": "",
"card_value": "",
"accounttype": "",
"provide_uin": "",
"extend": "",
"from_h5": "1",
"webversion": "web_1.0.6",
}
# The archived page body contains server-selected payment fields. Preserve
# them, replacing only values that are tied to the fresh order/session.
for field in ORDER_FIELDS:
match = re.search(rf"(?:^|&){field}=([^&]*)", template)
if match:
params[field] = match.group(1)
params.update(midas_login_params(cookies))
now_seconds = str(int(time.time()))
encrypt_msg = generate_encrypt_msg_offline(
params, "tdrc_session%3D" + fp["session_id"], now_seconds, random_suffix,
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
)
body = build_save_body(template, order, cookies, web_token, anti_token, encrypt_msg)
print("[jsdom-pay] 提交 web_save...")
raw = request_bytes(SAVE_URL, cookies, body.encode())
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8")
try:
response_json = json.loads(raw)
except json.JSONDecodeError:
response_json = {}
if response_json.get("ret") != 0:
print(f"[jsdom-pay] web_save 失败: ret={response_json.get('ret')} {response_json.get('msg', '')}")
print(f"[jsdom-pay] 证据目录: {out_dir}")
return 1
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
if not sign.startswith("weixin://"):
print("[jsdom-pay] web_save 成功,但响应没有微信付款链接")
return 1
qr_path = Path(args.qr) if args.qr else out_dir / "wechat-pay.png"
make_qr(sign, qr_path)
print("[jsdom-pay] 微信付款码已生成")
print(f"[jsdom-pay] PNG: {qr_path}")
print(f"[jsdom-pay] 付款链接: {sign}")
if args.skip_payment_check:
print("[jsdom-pay] 已跳过付款结果检查。")
return 0
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
baseline_document = get_official_orders(cookies)
baseline_states = order_completion_states(baseline_document)
status_path = out_dir / "payment-status.json"
save_payment_status(status_path, baseline_document, baseline_states)
deadline = time.monotonic() + args.payment_timeout
while time.monotonic() < deadline:
time.sleep(args.payment_interval)
document = get_official_orders(cookies)
completed = find_completed_order(document, baseline_states)
save_payment_status(status_path, document, baseline_states, completed)
if completed:
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
print(f"[jsdom-pay] 状态记录: {status_path}")
return 0
print("[jsdom-pay] 在等待期限内未确认到本次订单完成。")
print(f"[jsdom-pay] 状态记录: {status_path}")
return 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"错误: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
from __future__ import annotations
import argparse
import json
import os
import re
import secrets
import tempfile
import time
import uuid
from dataclasses import dataclass
from http.cookiejar import CookieJar
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "102033112"
PT_APPID = "716027609"
PT_DAID = "383"
CALLBACK = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_oauth?login_type=QC"
GRAPH_SHOW = "https://graph.qq.com/oauth2.0/show"
XLOGIN = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin"
QR_SHOW = "https://xui.ptlogin2.qq.com/ssl/ptqrshow"
QR_POLL = "https://xui.ptlogin2.qq.com/ssl/ptqrlogin"
USER_INFO = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_get_user_info"
LOGIN_JUMP = "https://graph.qq.com/oauth2.0/login_jump"
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
class NoRedirect(HTTPRedirectHandler):
"""Keep OAuth redirects visible while CookieJar receives Set-Cookie headers."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
return None
@dataclass
class Response:
status: int
body: bytes
headers: object
@property
def text(self) -> str:
return self.body.decode("utf-8", "replace")
def location(self) -> str:
return str(self.headers.get("Location", ""))
class Client:
def __init__(self) -> None:
self.jar = CookieJar()
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
def request(self, url: str, *, method: str = "GET", body: bytes | None = None,
referer: str = "", headers: dict[str, str] | None = None) -> Response:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer:
request_headers["Referer"] = referer
request_headers.update(headers or {})
request = Request(url, data=body, headers=request_headers, method=method)
try:
response = self.opener.open(request, timeout=30)
except HTTPError as error:
response = error
return Response(response.status, response.read(), response.headers)
def cookie(self, name: str) -> str:
for cookie in self.jar:
if cookie.name == name:
return cookie.value
return ""
def cookies(self) -> dict[str, str]:
return {cookie.name: cookie.value for cookie in self.jar}
def js_int32(value: int) -> int:
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def ptqr_token(qrsig: str) -> int:
"""QQ QR's zero-seeded JS 32-bit hash used as ptqrtoken."""
result = 0
for char in qrsig:
result += (js_int32(result) << 5) + ord(char)
return js_int32(result) & 0x7FFFFFFF
def g_tk(p_skey: str) -> int:
"""QQ OAuth's 5381-seeded hash used as g_tk."""
result = 5381
for char in p_skey:
result += (js_int32(result) << 5) + ord(char)
return js_int32(result) & 0x7FFFFFFF
def parse_poll(body: str) -> tuple[int, str]:
match = re.search(r"ptuiCB\((.*)\)", body, re.S)
if not match:
raise ValueError("QQ 二维码轮询响应缺少 ptuiCB")
values = re.findall(r"'([^']*)'", match.group(1))
if not values or not values[0].lstrip("-").isdigit():
raise ValueError("QQ 二维码轮询响应格式异常")
callback = next((value for value in values[1:] if value.startswith("https://")), "")
return int(values[0]), callback
def login_type_header(value: str) -> str:
try:
return {"QC": "1", "MOBILEQ": "1", "WX": "2"}[value]
except KeyError as exc:
raise ValueError(f"未知应用宝登录类型: {value!r}") from exc
def authorize_params(state: str, p_skey: str) -> dict[str, str]:
return {
"auth_time": str(int(time.time() * 1000)),
"client_id": OPEN_APPID,
"from_ptlogin": "1",
"g_tk": str(g_tk(p_skey)),
"openapi": "1010",
"redirect_uri": CALLBACK,
"response_type": "code",
"scope": "",
"src": "1",
"state": state,
"switch": "",
"ui": str(uuid.uuid4()).upper(),
"update_auth": "1",
}
def write_session(path: Path, cookies: dict[str, str]) -> None:
"""Merge QQ OAuth results while retaining mall data in the session file."""
document: dict = {}
if path.exists():
try:
document = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"会话文件不是有效 JSON: {path}") from exc
prior = document.get("cookies", {})
if not isinstance(prior, dict):
prior = {}
document["cookies"] = {**prior, **cookies}
document["login_type"] = cookies.get("logintype", "QC")
document["login_updated_at"] = int(time.time())
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
os.chmod(path, 0o600)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg")
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
args = parser.parse_args()
if args.timeout <= 0 or args.interval <= 0:
raise ValueError("--timeout 和 --interval 必须为正数")
client = Client()
state = secrets.token_urlsafe(14)
show_query = {
"which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID,
"redirect_uri": CALLBACK, "state": state,
}
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
show = client.request(show_url, referer="https://m.yyb.qq.com/")
if show.status != 200:
raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}")
xlogin_query = {
"appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录",
"hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP,
"pt_3rd_aid": OPEN_APPID,
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
"theme": "2", "verify_theme": "",
}
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
xlogin = client.request(xlogin_url, referer=show_url)
if xlogin.status != 200:
raise RuntimeError(f"QQ 登录页初始化失败: HTTP {xlogin.status}")
login_sig = client.cookie("pt_login_sig")
if not login_sig:
raise RuntimeError("QQ 登录页未写入 pt_login_sig")
qr_query = {
"appid": PT_APPID, "e": "2", "l": "M", "s": "3", "d": "72", "v": "4",
"t": str(secrets.randbelow(1_000_000) / 1_000_000), "daid": PT_DAID,
"pt_3rd_aid": OPEN_APPID, "u1": LOGIN_JUMP,
}
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
image = client.request(qr_url, referer=xlogin_url)
if image.status != 200 or not image.body:
raise RuntimeError(f"QQ 二维码请求失败: HTTP {image.status}")
qrsig = client.cookie("qrsig")
if not qrsig:
raise RuntimeError("QQ 二维码响应未写入 qrsig")
args.qr.parent.mkdir(parents=True, exist_ok=True)
args.qr.write_bytes(image.body)
print(f"QQ 登录二维码: {args.qr}")
print("请用 QQ 扫码并在手机确认;本终端将自动继续。")
deadline = time.monotonic() + args.timeout
callback = ""
o1v_id = secrets.token_hex(16)
while time.monotonic() < deadline:
poll_query = {
"u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "h": "1", "t": "1",
"g": "1", "from_ui": "1", "ptlang": "2052", "action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "26071711", "js_type": "1", "login_sig": login_sig, "pt_uistyle": "40",
"aid": PT_APPID, "daid": PT_DAID, "pt_3rd_aid": OPEN_APPID, "o1vId": o1v_id,
"pt_js_version": "c1987b96",
}
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
if poll.status != 200:
raise RuntimeError(f"QQ 二维码轮询失败: HTTP {poll.status}")
code, callback = parse_poll(poll.text)
if code == 0:
break
if code in (65, 68):
raise RuntimeError("QQ 二维码已失效,请重新执行登录")
if code not in (66, 67):
raise RuntimeError(f"QQ 二维码登录失败: ptuiCB={code}")
time.sleep(args.interval)
else:
raise TimeoutError("QQ 二维码轮询超时")
if not callback:
raise RuntimeError("QQ 登录成功响应缺少 check_sig 回调")
check_sig = client.request(callback, referer=xlogin_url)
login_jump = check_sig.location()
if check_sig.status != 302 or not login_jump.startswith("https://graph.qq.com/oauth2.0/login_jump"):
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
jump = client.request(login_jump, referer=callback)
if jump.status != 200:
raise RuntimeError(f"QQ OAuth login_jump 失败: HTTP {jump.status}")
p_skey = client.cookie("p_skey")
if not p_skey:
raise RuntimeError("QQ check_sig 未写入 p_skey")
authorize = client.request(
"https://graph.qq.com/oauth2.0/authorize", method="POST",
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
oauth_callback = authorize.location()
if authorize.status != 302 or not oauth_callback.startswith(CALLBACK):
raise RuntimeError("QQ OAuth authorize 未跳转到 YYB 回调")
yyb_callback = client.request(oauth_callback, referer="https://graph.qq.com/")
if yyb_callback.status != 302:
raise RuntimeError(f"YYB QQ OAuth 回调失败: HTTP {yyb_callback.status}")
cookies = client.cookies()
if not cookies.get("openid") or not cookies.get("accesstoken"):
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
login_type = cookies.get("logintype", "QC")
info = client.request(USER_INFO, headers={
"Ual-Access-Login-Type": login_type_header(login_type),
"Ual-Access-Access-Token": cookies["accesstoken"],
"Ual-Access-Openid": cookies["openid"],
"Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/",
})
if info.status != 200:
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
try:
value = json.loads(info.text)
except json.JSONDecodeError as exc:
raise RuntimeError("YYB QQ 登录态校验返回非 JSON") from exc
if isinstance(value, dict) and value.get("ret") not in (None, 0):
raise RuntimeError(f"YYB QQ 登录态校验失败: ret={value.get('ret')}")
write_session(args.session, cookies)
print(f"QQ 登录成功,cookies 已写入: {args.session}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TimeoutError, ValueError, RuntimeError) as error:
print(f"QQ 登录失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""应用宝微信二维码登录(纯 Python,无浏览器自动化)。
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import re
import tempfile
import time
from dataclasses import dataclass
from http.cookiejar import CookieJar
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "wxd44977328b36e647"
CALLBACK = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_oauth?login_type=WX"
OPEN_QR = "https://open.weixin.qq.com/connect/qrconnect"
POLL_QR = "https://lp.open.weixin.qq.com/connect/l/qrconnect"
USER_INFO = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_get_user_info"
HREF = "data:text/css;base64,Ci5pbXBvd2VyQm94IC5xcmNvZGUge3dpZHRoOiAxNjBweDttYXJnaW4tdG9wOjI1cHh9"
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
class NoRedirect(HTTPRedirectHandler):
"""保留 OAuth 回调的 302 和 Set-Cookie。"""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
return None
@dataclass
class Response:
status: int
body: bytes
@property
def text(self) -> str:
try:
return gzip.decompress(self.body).decode("utf-8", "replace")
except OSError:
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self) -> None:
self.jar = CookieJar()
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
def request(self, url: str, *, referer: str = "", headers: dict[str, str] | None = None) -> Response:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer:
request_headers["Referer"] = referer
request_headers.update(headers or {})
request = Request(url, headers=request_headers, method="GET")
try:
response = self.opener.open(request, timeout=30)
except HTTPError as error:
response = error
return Response(response.status, response.read())
def cookies(self) -> dict[str, str]:
return {cookie.name: cookie.value for cookie in self.jar}
def extract_uuid(page: str) -> str:
for pattern in (
r"var\s+G\s*=\s*['\"]([A-Za-z0-9_-]{8,64})['\"]",
r"connect/qrcode/([A-Za-z0-9_-]{8,64})",
r"uuid=([A-Za-z0-9_-]{8,64})",
):
match = re.search(pattern, page, re.I)
if match:
return match.group(1)
raise ValueError("微信授权页未找到二维码 UUID")
def parse_poll(body: str) -> tuple[int, str]:
errcode = re.search(r"wx_errcode\s*=\s*(-?\d+)", body)
if not errcode:
raise ValueError("二维码轮询响应缺少 wx_errcode")
code = re.search(r"wx_code\s*=\s*['\"]([^'\"]*)['\"]", body)
return int(errcode.group(1)), code.group(1) if code else ""
def login_type_header(value: str) -> str:
try:
return {"MOBILEQ": "1", "WX": "2"}[value]
except KeyError as exc:
raise ValueError(f"未知应用宝登录类型: {value!r}") from exc
def write_session(path: Path, cookies: dict[str, str]) -> None:
"""合并登录结果,保留 mall 的变换数据与用户已有配置。"""
document: dict = {}
if path.exists():
try:
document = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"会话文件不是有效 JSON: {path}") from exc
prior = document.get("cookies", {})
if not isinstance(prior, dict):
prior = {}
document["cookies"] = {**prior, **cookies}
document["login_type"] = cookies.get("logintype", "WX")
document["login_updated_at"] = int(time.time())
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
os.chmod(path, 0o600)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg")
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
args = parser.parse_args()
if args.timeout <= 0 or args.interval <= 0:
raise ValueError("--timeout 和 --interval 必须为正数")
client = Client()
state = f"{time.time():.6f}"
query = {
"appid": OPEN_APPID,
"fast_login": "0",
"href": HREF,
"redirect_uri": CALLBACK,
"response_type": "code",
"scope": "snsapi_login,snsapi_runtime_pcsdk",
"self_redirect": "true",
"state": state,
}
authorization_url = f"{OPEN_QR}?{urlencode(query)}"
page = client.request(authorization_url, referer="https://m.yyb.qq.com/")
if page.status != 200:
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
uuid = extract_uuid(page.text)
image = client.request(f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url)
if image.status != 200 or not image.body:
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
args.qr.parent.mkdir(parents=True, exist_ok=True)
args.qr.write_bytes(image.body)
print(f"微信登录二维码: {args.qr}")
print("请用微信扫码并在手机确认;本终端将自动继续。")
deadline = time.monotonic() + args.timeout
last = ""
code = ""
while time.monotonic() < deadline:
poll_query = {"uuid": uuid}
if last:
poll_query["last"] = last
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
errcode, code = parse_poll(poll.text)
if errcode == 405:
break
if errcode == 402:
raise RuntimeError("二维码已过期,请重新执行登录")
if errcode == 403:
raise RuntimeError("用户取消了扫码登录")
if errcode == 404:
last = "404"
time.sleep(args.interval)
else:
raise TimeoutError("二维码轮询超时")
if not code:
raise RuntimeError("扫码成功响应缺少 OAuth code")
callback_url = f"{CALLBACK}&{urlencode({'code': code, 'state': state})}"
callback = client.request(callback_url, referer=authorization_url)
if callback.status not in (200, 302):
raise RuntimeError(f"YYB OAuth 回调失败: HTTP {callback.status}")
cookies = client.cookies()
openid = cookies.get("openid", "")
access_token = cookies.get("accesstoken", "")
login_type = cookies.get("logintype", "WX")
if not openid or not access_token:
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
info = client.request(USER_INFO, headers={
"Ual-Access-Login-Type": login_type_header(login_type),
"Ual-Access-Access-Token": access_token,
"Ual-Access-Openid": openid,
"Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/",
})
if info.status != 200:
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
try:
value = json.loads(info.text)
except json.JSONDecodeError as exc:
raise RuntimeError("YYB 登录态校验返回非 JSON") from exc
if isinstance(value, dict) and value.get("ret") not in (None, 0):
raise RuntimeError(f"YYB 登录态校验失败: ret={value.get('ret')}")
write_session(args.session, cookies)
print(f"登录成功,cookies 已写入: {args.session}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TimeoutError, ValueError, RuntimeError) as error:
print(f"登录失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
from __future__ import annotations
import argparse
import json
import random
import re
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import unquote, urlencode
from curl_cffi import requests
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from pyvm.login_profile import midas_login_params # noqa: E402
YYB_APP_ID = 52575843
SOURCE_ID = "24292013"
PRODUCTS_URL = "https://ydd.yyb.qq.com/new_direct_buy_shop/GetTokenMod"
CMALL_URL = "https://storeapi.pay.qq.com/api/unipay/{offer_id}/cmall_query"
PLATFORMS = {
"android": {
"label": "Android",
"query_platform": "pc_android",
"cmall_pf": "mds_storeopen_qb-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
"order_pf": "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
},
"ios": {
"label": "iOS",
"query_platform": "pc_ios",
"cmall_pf": "mds_storeopen_qb-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
"order_pf": "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
},
}
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
def load_cookies(path: Path) -> dict[str, str]:
session = json.loads(path.read_text(encoding="utf-8"))
cookies = {str(key): str(value) for key, value in session.get("cookies", {}).items() if value}
if not cookies.get("openid") or not cookies.get("accesstoken"):
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
return cookies
def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, str | int]]:
response = requests.post(
PRODUCTS_URL,
json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID,
"yyb_app_id": YYB_APP_ID},
headers={"Accept": "application/json, text/plain, */*", "Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", "User-Agent": UA},
cookies=cookies, impersonate="chrome", timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
document = response.json()
if document.get("code") not in (None, 0):
raise RuntimeError(f"点券商品查询失败: {document.get('code')} {document.get('message', '')}")
products = document.get("token_mod", {}).get("products", [])
result: list[dict[str, str | int]] = []
for item in products:
product = item.get("product", {}) if isinstance(item, dict) else {}
match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", "")))
if not match or str(product.get("status")) != "20":
continue
result.append({
"points": int(match.group(1)),
"product_id": str(product.get("product_id", "")),
"price_fen": int(product.get("price", 0)),
"offer_id": str(product.get("res_offer_id", "")),
"name": str(product.get("product_name", "")),
})
result.sort(key=lambda item: int(item["points"]))
if not result or any(not item["product_id"] or not item["offer_id"] for item in result):
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
return result
class Cmall:
def __init__(self, cookies: dict[str, str], offer_id: str, platform: str) -> None:
self.cookies = cookies
self.offer_id = offer_id
self.platform = platform
self.session_token = f"{str(uuid.uuid4()).upper()}{int(time.time() * 1000)}"
def query(self, cmd: str, **extra: str) -> dict:
login = midas_login_params(self.cookies)
params = {
"from_h5": "1", "pf": PLATFORMS[self.platform]["cmall_pf"], "r": str(random.random()), "cmd": cmd,
"session_token": self.session_token,
"pfkey": "pfkey", "webversion": "", **extra,
**login,
}
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
response = requests.post(
CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params),
headers={"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
"User-Agent": UA},
cookies=self.cookies, impersonate="chrome", timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
document = response.json()
if document.get("ret") not in (0, "0"):
raise RuntimeError(f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}")
return document
def zones(self) -> list[dict[str, str]]:
response = self.query("14", use_currency_offerid="1")
zones = response.get("zone_list", [])
return [{"zone_id": str(item.get("zone_id", "")), "name": str(item.get("zone_name", ""))}
for item in zones if isinstance(item, dict) and item.get("zone_id")]
def roles(self, zone_id: str) -> list[dict[str, str]]:
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
roles = response.get("role_info") or response.get("role_list") or []
return [{"role_id": str(item.get("role_id", "")),
"name": unquote(str(item.get("role_name", ""))),
"ban_status": str(item.get("ban_status", ""))}
for item in roles if isinstance(item, dict) and item.get("role_id")]
def choose(label: str, options: list[dict], display) -> dict:
if not options:
raise RuntimeError(f"没有可选择的{label}")
print(f"\n可选{label}:")
for index, item in enumerate(options, start=1):
print(f" {index}. {display(item)}")
while True:
raw = input(f"请选择{label}序号 [1-{len(options)}]: ").strip()
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
print("请输入列表中的有效序号。")
def main() -> int:
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json")
parser.add_argument("--platform", choices=tuple(PLATFORMS), default=None,
help="预选 Android/iOS;不传则显示平台菜单")
parser.add_argument("--points", type=int, default=None, help="预选点券数;不传则显示菜单")
parser.add_argument("--list-products", action="store_true", help="仅列出当前所有点券档位")
args = parser.parse_args()
cookies = load_cookies(args.session)
if args.platform is None:
selected_platform = choose(
"平台", [{"id": key, **value} for key, value in PLATFORMS.items()],
lambda item: item["label"],
)
platform = str(selected_platform["id"])
else:
platform = args.platform
print(f"已选择平台: {PLATFORMS[platform]['label']}")
products = product_options(cookies, platform)
if args.list_products:
for product in products:
print(f"{product['points']}点券\t{product['price_fen'] / 100:g}\t{product['product_id']}")
return 0
if args.points is None:
product = choose("点券档位", products, lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)")
else:
product = next((item for item in products if item["points"] == args.points), None)
if product is None:
available = ", ".join(str(item["points"]) for item in products)
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
cmall = Cmall(cookies, str(product["offer_id"]), platform)
zone = choose("区服", cmall.zones(), lambda item: f"{item['name']}ID {item['zone_id']}")
roles = cmall.roles(zone["zone_id"])
role = choose("角色", roles, lambda item: f"{item['name']}{'禁用' if item['ban_status'] == '1' else '可用'}")
if role["ban_status"] == "1":
raise RuntimeError("所选角色已被封禁,不能充值")
selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"],
"points": product["points"], "product_id": product["product_id"],
"offer_id": product["offer_id"], "price_fen": product["price_fen"],
"zone_id": zone["zone_id"], "zone_name": zone["name"],
"role_id": role["role_id"], "role_name": role["name"]}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}")
print(f"选择已保存: {args.output}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, requests.RequestsError) as error:
print(f"选择失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Small local HTTP worker for the YYB admin integration.
The worker owns per-job sessions and invokes the already verified protocol
scripts. It intentionally exposes QR images and state only; cookies and raw
payment links never leave the worker API.
"""
from __future__ import annotations
import argparse
import base64
import importlib.util
import json
import os
import re
import subprocess
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DATA = ROOT / "config" / "worker-jobs"
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
_jobs: dict[str, dict] = {}
_lock = threading.Lock()
def _load_selector():
path = ROOT / "scripts" / "select-peace-elite.py"
spec = importlib.util.spec_from_file_location("yyb_worker_selector", path)
if not spec or not spec.loader:
raise RuntimeError("无法加载和平精英选择器")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _job_dir(job_id: str) -> Path:
return Path(_jobs[job_id]["directory"])
def _safe_log(job: dict, line: str) -> None:
# Do not persist cookies, payment URI, or long opaque tokens in the worker API.
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
clean = re.sub(r"(订单\s*[:]\s*)\S+", r"\1[订单已隐藏]", clean)
clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I)
clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
"[敏感字段已隐藏]", clean, flags=re.I)
with _lock:
job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:]
def _run_process(job_id: str, command: list[str], phase: str) -> None:
job = _jobs[job_id]
environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None)
with _lock:
job["phase"] = phase
job["status"] = "running"
try:
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True,
bufsize=1, env=environment)
with _lock:
job["process_pid"] = process.pid
assert process.stdout is not None
for line in process.stdout:
_safe_log(job, line)
code = process.wait()
with _lock:
job["process_pid"] = None
if code != 0:
job["status"] = "failed"
job["phase"] = phase
job["message"] = f"{phase}失败(退出码 {code}"
elif phase == "login":
job["status"] = "ready"
job["phase"] = "selection"
job["message"] = "登录成功,请选择平台、点券、区服和角色"
else:
job["status"] = "success"
job["phase"] = "completed"
job["message"] = "付款流程已完成"
except Exception as exc: # noqa: BLE001
with _lock:
job["status"] = "failed"
job["message"] = str(exc)
def _start_login(job_id: str, provider: str, timeout: int) -> None:
job = _jobs[job_id]
directory = _job_dir(job_id)
session = directory / "mall-session.json"
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
command = ["python3", f"scripts/login-{provider}.py", "--session", str(session),
"--qr", str(qr), "--timeout", str(timeout)]
with _lock:
job["provider"] = provider
job["qr_path"] = str(qr)
job["session_path"] = str(session)
job["status"] = "waiting_login"
job["phase"] = "login"
threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start()
def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict:
if job_id not in _jobs:
raise ValueError("任务不存在")
selector = _load_selector()
session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8"))
cookies = session.get("cookies", {})
products = selector.product_options(cookies, platform)
if points is not None and not any(int(item["points"]) == points for item in products):
raise ValueError("当前登录态不支持该点券档位")
product = next((item for item in products if int(item["points"]) == points), None) if points else None
if product is None:
product = products[0]
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
zones = cmall.zones()
selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None)
if zone_id and selected_zone is None:
raise ValueError("区服不存在")
selected_zone = selected_zone or (zones[0] if zones else None)
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
return {"products": products, "zones": zones, "roles": roles,
"default_product": product, "default_zone": selected_zone}
def _start_payment(job_id: str, selection: dict) -> None:
directory = _job_dir(job_id)
session = directory / "mall-session.json"
response = directory / "mall-order-response.json"
output = directory / "jsdom-order"
command = ["python3", "main.py", "mall", "auto", "--session", str(session),
"--order-template", str(ROOT / "config" / "mall-order-template.json"),
"--quantity", "1", "--product-id", str(selection["product_id"]),
"--offer-id", str(selection["offer_id"]),
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]),
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]),
"--output", str(response)]
if selection.get("order_pf"):
command.extend(["--pf", str(selection["order_pf"])])
def run() -> None:
_run_process(job_id, command, "order")
job = _jobs[job_id]
if job.get("status") != "success" or not response.exists():
return
pay_command = ["python3", "scripts/jsdom-pay.py", "--session", str(session),
"--mall-response", str(response), "--out-dir", str(output),
"--zone-id", str(selection["zone_id"]),
"--pf", str(selection.get("order_pf", ""))]
_run_process(job_id, pay_command, "payment")
threading.Thread(target=run, daemon=True).start()
def _stop_job(job_id: str) -> None:
if job_id not in _jobs:
raise ValueError("任务不存在")
pid = _jobs[job_id].get("process_pid")
if pid:
try:
os.kill(int(pid), 15)
except ProcessLookupError:
pass
with _lock:
_jobs[job_id]["status"] = "failed"
_jobs[job_id]["phase"] = "stopped"
_jobs[job_id]["message"] = "任务已停止"
def _public_job(job_id: str) -> dict:
job = _jobs[job_id]
result = {key: value for key, value in job.items()
if key not in {"directory", "session_path", "process_pid"}}
qr_path = job.get("qr_path", "")
if qr_path and Path(qr_path).exists():
qr_bytes = Path(qr_path).read_bytes()
result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii")
result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
output = Path(job["directory"]) / "jsdom-order"
for name in ("wechat-pay.png", "payment-status.json"):
path = None
if output.exists():
direct = output / name
path = direct if direct.exists() else next(output.glob(f"*/{name}"), None)
if path and name.endswith(".png"):
payment_qr_bytes = path.read_bytes()
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii")
result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
elif path:
try:
status = json.loads(path.read_text(encoding="utf-8"))
matched = status.get("matched_completion") if isinstance(status, dict) else None
result["payment_status"] = {
"checked_at": status.get("checked_at") if isinstance(status, dict) else None,
"matched_completion": {
"is_finished": matched.get("is_finished"),
"status": matched.get("status"),
} if isinstance(matched, dict) else None,
}
except (OSError, json.JSONDecodeError):
pass
return result
class Handler(BaseHTTPRequestHandler):
server_version = "YYBWorker/1"
def _json(self, status: int, value: dict) -> None:
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
def _authorized(self) -> bool:
if not WORKER_KEY:
return True
value = self.headers.get("Authorization", "")
return value == f"Bearer {WORKER_KEY}"
def do_POST(self) -> None: # noqa: N802
if not self._authorized():
return self._json(401, {"detail": "未授权"})
path = urlparse(self.path).path.strip("/").split("/")
try:
if path == ["v1", "jobs"]:
job_id = uuid.uuid4().hex[:16]
directory = DEFAULT_DATA / job_id
directory.mkdir(parents=True, exist_ok=True)
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
"logs": [], "directory": str(directory), "created_at": int(time.time())}
return self._json(201, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
job_id = path[2]
body = self._body()
if job_id not in _jobs or body.get("provider") not in {"qq", "wechat"}:
return self._json(400, {"detail": "无效任务或登录方式"})
_start_login(job_id, body["provider"], int(body.get("timeout", 600)))
return self._json(202, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options":
job_id = path[2]
body = self._body()
options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id"))
_jobs[job_id]["selection_options"] = options
return self._json(200, options)
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
job_id = path[2]
body = self._body()
required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id")
if job_id not in _jobs or any(not body.get(key) for key in required):
return self._json(400, {"detail": "选择参数不完整"})
selector = _load_selector()
if body["platform"] not in selector.PLATFORMS:
return self._json(400, {"detail": "不支持的平台"})
session_path = _job_dir(job_id) / "mall-session.json"
cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {})
product = next((item for item in selector.product_options(cookies, body["platform"])
if str(item["product_id"]) == str(body["product_id"])
and int(item["points"]) == int(body["points"])), None)
if product is None:
return self._json(400, {"detail": "商品已失效,请重新选择"})
cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"])
zone = next((item for item in cmall.zones()
if str(item["zone_id"]) == str(body["zone_id"])), None)
if zone is None:
return self._json(400, {"detail": "区服已失效,请重新选择"})
role = next((item for item in cmall.roles(zone["zone_id"])
if str(item["role_id"]) == str(body["role_id"])), None)
if role is None or role.get("ban_status") == "1":
return self._json(400, {"detail": "角色不可充值,请重新选择"})
_jobs[job_id]["selection"] = {
"platform": body["platform"], "points": product["points"],
"product_id": product["product_id"], "offer_id": product["offer_id"],
"zone_id": zone["zone_id"], "zone_name": zone["name"],
"role_id": role["role_id"], "role_name": role["name"],
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
}
_jobs[job_id]["phase"] = "payment"
return self._json(200, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment":
job_id = path[2]
if job_id not in _jobs or not _jobs[job_id].get("selection"):
return self._json(400, {"detail": "请先完成角色选择"})
_start_payment(job_id, _jobs[job_id]["selection"])
return self._json(202, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop":
_stop_job(path[2])
return self._json(200, _public_job(path[2]))
return self._json(404, {"detail": "接口不存在"})
except Exception as exc: # noqa: BLE001
return self._json(500, {"detail": str(exc)})
def do_GET(self) -> None: # noqa: N802
if urlparse(self.path).path == "/health":
return self._json(200, {"status": "ok"})
if not self._authorized():
return self._json(401, {"detail": "未授权"})
path = urlparse(self.path).path.strip("/").split("/")
if len(path) == 3 and path[:2] == ["v1", "jobs"] and path[2] in _jobs:
return self._json(200, _public_job(path[2]))
return self._json(404, {"detail": "接口不存在"})
def log_message(self, fmt: str, *args) -> None:
return
def main() -> int:
global DEFAULT_DATA, WORKER_KEY
parser = argparse.ArgumentParser(description="YYB admin worker")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8810)
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
args = parser.parse_args()
DEFAULT_DATA = args.data_dir
if args.key is not None:
WORKER_KEY = args.key
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
DEFAULT_DATA.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"YYB worker listening on {args.host}:{args.port}", flush=True)
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())