Files
ql-scripts/qqmusic/test_coin.js
zhuhjay 57a49ab7f5 fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
2026-11-07 18:50:04 +08:00

244 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* test_coin.js —— 金币中心签到逻辑的离线验证(mock https,不联网)
*
* 覆盖 coinCenter() 的六条分支:
* ① 未签 → SignIn 成功 → AwardPrize 成功
* ② 未签 → SignIn 成功 → 无 State=2 的 task(只签不领)
* ③ 已签 → 有 State=2 的 task(补领)
* ④ 已签 → 无 State=2 的 task(skip)
* ⑤ 状态查询失败(ActID 过期)→ warn,不 fail
* ⑥ QM_COIN_SIGN=off → skip
* ⑦ 领奖返回 100004(幂等)→ 算成功
* ⑧ QmDead 透传(code 2000)
*
* 用法: node qqmusic/test_coin.js
*/
'use strict'
const Module = require('module')
const path = require('path')
const FILE = path.join(__dirname, 'scrip_qqmusic_checkin.js')
let pass = 0, fail = 0
const ok = (n, c, e = '') => {
if (c) { pass++; console.log(` ✓ ${n}`) }
else { fail++; console.log(` ✗ ${n}${e ? ' — ' + e : ''}`) }
}
/* ── mock https.request ── */
let respond = () => ({ status: 200, text: '{}' })
const origRequest = require('https').request
require('https').request = function (opts, cb) {
const chunks = []
const req = {
on() { return req },
write(d) { chunks.push(Buffer.from(d)) },
end() {
const body = Buffer.concat(chunks).toString('utf8')
const r = respond(opts, body)
const res = {
statusCode: r.status,
on(ev, fn) {
if (ev === 'data') fn(Buffer.from(r.text))
if (ev === 'end') setImmediate(fn)
return res
},
}
setImmediate(() => cb(res))
},
destroy() {},
}
return req
}
const { QMClient } = require(FILE)
/* 构造一个直接可用(跳过 refresh/缓存)的 client */
function mkClient() {
const c = new QMClient('1006764347', 'Q_H_L_faketoken_for_shape', '/tmp/__nonexistent_qm_cache.json')
c.key = 'Q_H_L_faketoken_for_shape'
return c
}
const STATE_OK = (isSignIn, day, tasks) => JSON.stringify({
code: 0,
req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: isSignIn } } },
req_1: {
code: 0,
data: {
retCode: 0,
Info: { IsSignIn: isSignIn, ContinueSignInCount: day },
TaskListInfo: { TaskList: { ContinueTaskList: tasks } },
},
},
})
const SIGN_OK = JSON.stringify({
code: 0,
req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: true } } },
})
const AWARD_OK = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 0 } } })
const AWARD_DUP = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 100004 } } })
/* 按 method 分派的 mock:金币主活动与抽奖活动用不同 ActID 区分 */
function route(table) {
return (opts, body) => {
let j = {}
try { j = JSON.parse(body) } catch { /* */ }
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
const act = acts[0]
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
const key = `${act}:${methods.join('+')}`
const hit = table[key]
return { status: 200, text: hit !== undefined ? hit : '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}
}
const COIN = 'Z25hHGi'
const LOT = 'Z156KEu'
const cases = [
{
name: '① 未签 → 签到成功 → 领奖成功',
table: {
[`${COIN}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(false, 0, {}),
[`${COIN}:SignIn`]: SIGN_OK,
[`${COIN}:AwardPrize`]: AWARD_OK,
[`${LOT}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(true, 5, {}),
},
// 第二次查询(签完后复查)也要返回带 task 的状态 → 用序列化响应
seq: true,
want: { status: 'done', has: '已签' },
},
]
/* 由于「签完复查」需要同一 key 返回不同响应,用计数器实现 */
async function runCase(name, script, want) {
const calls = []
respond = (opts, body) => {
let j = {}
try { j = JSON.parse(body) } catch { /* */ }
calls.push({
url: opts.host + opts.path,
reqs: [j.req_0, j.req_1].filter(Boolean).map(r => `${(r.param || {}).ActID}:${r.method}`),
comm: j.comm,
})
return script(j, calls.length)
}
const c = mkClient()
const t = await c.coinCenter()
console.log(`\n${name}`)
ok(`返回 0(金币不计入成长值)`, t === 0, String(t))
for (const [k, v] of Object.entries(want)) {
if (k === 'status') {
const s = c.steps.find(x => x.name === '金币签到')
ok(`step 存在且 status=${v}`, s && s.status === v, JSON.stringify(c.steps.map(x => `${x.name}:${x.status}`)))
} else if (k === 'has') {
const s = c.steps.find(x => x.name === '金币签到')
ok(`detail 含「${v}」`, s && s.detail.includes(v), s && s.detail)
} else if (k === 'calls') {
ok(`请求次数 = ${v}`, calls.length === v, String(calls.length))
} else if (k === 'comm') {
const c0 = calls[0] && calls[0].comm
ok(`comm 是小程序形态(mina=1 appid=…)`, c0 && c0.mina === 1 && String(c0.appid).startsWith('wx'), JSON.stringify(c0))
ok(`comm.authst = key`, c0 && c0.authst === 'Q_H_L_faketoken_for_shape')
ok(`comm 无 g_tk(金币体系不签名)`, c0 && c0.g_tk === undefined, JSON.stringify(c0))
}
}
return calls
}
;(async () => {
/* ① 未签 → 签 → 领 */
await runCase('── ① 未签 → 签到 → 领奖 ──', (j, n) => {
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
const act = acts[0]
if (act === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
// 第一次查询:未签;第二次(签后复查):已签 + 有可领 task
return { status: 200, text: n === 1
? STATE_OK(false, 0, {})
: STATE_OK(true, 1, { '1': { ID: 'T1', State: 2, PrizeList: [{ Value: 30 }] } }) }
}
if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK }
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}, { status: 'done', has: '已签', comm: true })
/* ② 已签 + 有 task → 补领 */
await runCase('── ② 已签 + 有可领 task → 补领 ──', (j) => {
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Name: '10金币' }] } }) }
}
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}, { status: 'done', has: '10金币' })
/* ③ 已签 + 无可领 → skip */
await runCase('── ③ 已签 + 无可领 → skip ──', (j) => {
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
return { status: 200, text: STATE_OK(true, 4, { '1': { ID: 'T1', State: 1 } }) }
}, { status: 'skip', has: '连续第 4 天' })
/* ④ 状态查询失败 → warn(不是 fail) */
await runCase('── ④ ActID 过期(查询失败)→ warn ──', () => ({
status: 200,
text: JSON.stringify({ code: 0, req_0: { code: -1, data: { retCode: 100001 } }, req_1: { code: -1, data: {} } }),
}), { status: 'warn', has: 'ActID' })
/* ⑤ 领奖返回 100004(幂等)→ 算成功 */
await runCase('── ⑤ 领奖 retCode=100004(已领过)→ done ──', (j) => {
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Value: 20 }] } }) }
}
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_DUP }
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}, { status: 'done' })
/* ⑥ 金币抽奖签到(独立 ActID) */
{
console.log('\n── ⑥ 金币抽奖签到(独立 ActID)──')
respond = (opts, body) => {
const j = JSON.parse(body)
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
if (acts[0] === COIN) {
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') return { status: 200, text: STATE_OK(true, 5, {}) }
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}
// 抽奖活动:未签 → 签 → 有 task 可领
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
return { status: 200, text: STATE_OK(false, 0, {}) }
}
if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK }
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
}
const c = mkClient()
await c.coinCenter()
const s = c.steps.find(x => x.name === '金币抽奖签到')
ok('金币抽奖签到有独立 step', !!s, JSON.stringify(c.steps.map(x => x.name)))
}
/* ⑦ QmDead 透传 */
{
console.log('\n── ⑦ 未登录(code 2000)→ 抛 QmDead ──')
respond = () => ({ status: 200, text: JSON.stringify({ code: 0, req_0: { code: 2000, data: {} } }) })
const c = mkClient()
let threw = null
try { await c.coinCenter() } catch (e) { threw = e }
ok('抛了 QmDead', threw && threw.constructor.name === 'QmDead', threw && threw.constructor.name)
}
require('https').request = origRequest
console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`)
process.exit(fail === 0 ? 0 : 1)
})()