初步增加, 扫码登录成功
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user