fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
This commit is contained in:
@@ -16,6 +16,10 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node
|
||||
| `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) |
|
||||
| `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 |
|
||||
| `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) |
|
||||
| `QM_COIN_SIGN` | 可选 | 金币中心签到开关,**默认 on**。`off` 整体跳过(纯签到+领奖,不花金币、不伪造行为) |
|
||||
| `QM_COIN_ACT_ID` | 可选 | 金币签到 ActID,默认 `Z25hHGi`。**活动换季会变**,失效时抓包改这里 |
|
||||
| `QM_COIN_SCENE_ID` | 可选 | 金币签到 ScenesID,默认 `2` |
|
||||
| `QM_COIN_LOTTERY_ACT_ID` | 可选 | 金币抽奖签到 ActID,默认 `Z156KEu`,同样会换季 |
|
||||
| `WEBHOOK_URL` | 可选 | 自定义 webhook(如平台 `/api/hooks/ql`)。与 `PUSH_PLUS_TOKEN` 至少配一个 |
|
||||
| `WEBHOOK_METHOD` | 可选 | 默认 `POST` |
|
||||
| `WEBHOOK_CONTENT_TYPE` | 可选 | 默认 `application/json` |
|
||||
@@ -314,6 +318,7 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN>
|
||||
|---|---|---|
|
||||
| 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) |
|
||||
| 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) |
|
||||
| **金币中心** | 金币签到 + 领当日奖励 + 金币抽奖签到(`QM_COIN_SIGN`,默认 on) | 低(纯签到领奖,不花金币、不伪造行为) |
|
||||
| VIP 体验券 | 分享卡配额查询 / 建卡(`QM_SHARE_CARD=on`) | 低(只读默认,不消耗配额) |
|
||||
| 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) |
|
||||
| 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 |
|
||||
@@ -321,6 +326,62 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN>
|
||||
状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。
|
||||
错误码处置:`20020` 冷却3s重领 · `30005` 只读探测后补领 · `10006` 写锁即停次日恢复 · `2000` 登录态失效推送提示。
|
||||
|
||||
## 金币中心(第三套体系)
|
||||
|
||||
**与任务中心、福利站完全独立**:金币体系走 `musicu.fcg` + 小程序 comm,另外两套走 `u6.y.qq.com` + zzc 签名。
|
||||
给的是**金币**,不是成长值,所以不计入通知里的「成长值 +N」。
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 网关 | `https://u.y.qq.com/cgi-bin/musicu.fcg`(**不签名**,裸 JSON) |
|
||||
| 模块 | `music.actCenter.ActCenterSignNewSvr` |
|
||||
| comm | `{ uin, authst, mina:1, appid:'wxada7aab80ba27074', ct:29, cv:0, format:'json' }` |
|
||||
| 方法 | `GetSignInSummary` / `GetSignInTaskList` / `SignIn` / `AwardPrize` |
|
||||
|
||||
`authst` 就是 `qm_keyst`,放在 body 里(不走 Cookie)。**没有 `g_tk`,也不签名。**
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
① GetSignInSummary + GetSignInTaskList → info.IsSignIn / ContinueTaskList
|
||||
② 未签 → SignIn { ActID, ScenesID }
|
||||
③ 找 State===2 的 TaskList 项 → AwardPrize { ActID, TaskID }
|
||||
```
|
||||
|
||||
`retCode` 口径:`0` 成功 · `100004` 已领过(幂等,也算成功)· `1000` 未登录 · 其他失败。
|
||||
|
||||
### 不做的部分
|
||||
|
||||
| 功能 | 为什么不做 |
|
||||
|---|---|
|
||||
| **金币抽奖**(`drawCoinLottery`) | 消耗抽奖券,属于「花金币」不是「赚金币」 |
|
||||
| **红包雨** | 要卡 6 个时段各一次,青龙 cron 得加 6 条 |
|
||||
| **每日任务列表**(浏览页 +3 豆那类) | 完成上报发生在跳转页内部埋点,要逆向各 H5 协议 |
|
||||
| **定时金币** | 服务端按各自间隔下发,要维护下次查询时间,收益低 |
|
||||
|
||||
### ActID 换季
|
||||
|
||||
`ActID` 是活动实例 ID,**活动换季就变**。当前硬编码值:
|
||||
|
||||
```
|
||||
COIN_ACT_ID = Z25hHGi (金币中心签到)
|
||||
COIN_SCENE_ID = 2
|
||||
LOTTERY_ACT_ID = Z156KEu (金币抽奖签到)
|
||||
```
|
||||
|
||||
失效时脚本会打:
|
||||
|
||||
```
|
||||
[金币] 状态查询失败 code=... ret=...
|
||||
[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID
|
||||
```
|
||||
|
||||
并记 `warn`(不是 `fail` —— 不是登录态问题,也不是脚本 bug)。
|
||||
|
||||
**修法**:App 抓包看 `ActCenterSignNewSvr` 请求里的 `ActID` 参数,改环境变量 `QM_COIN_ACT_ID`,
|
||||
不用改代码。
|
||||
|
||||
|
||||
## 本地测试
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,6 +36,10 @@ const crypto = require('crypto')
|
||||
|
||||
/* ---------------- 协议底座(与 qmcommon.py 逐字节对齐) ---------------- */
|
||||
const API = 'https://u6.y.qq.com/cgi-bin/musics.fcg'
|
||||
// 金币中心走另一套网关:musicu.fcg 用小程序 comm(authst 在 body 里,不签名、不带 Cookie)
|
||||
const API_U = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
|
||||
const MINA_APPID = 'wxada7aab80ba27074' // QQ音乐微信小程序 appid(金币体系的 comm.appid)
|
||||
const M_COIN = 'music.actCenter.ActCenterSignNewSvr' // 金币签到/领奖的唯一模块
|
||||
const UA = 'QQMusic 20090008(android 15)'
|
||||
const SCRAMBLE = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
|
||||
const APP_COMM = { ct: '11', cv: '20090008' } // cosmeticcgi 系专用 comm
|
||||
@@ -50,6 +54,23 @@ const CARD_CALLBACK = String(process.env.QM_CARD_CALLBACK || '').trim()
|
||||
// 回传鉴权 token:与平台 QL_HOOK_TOKEN 同一枚(平台 hookGuard 校验它)
|
||||
const CARD_TOKEN = String(process.env.QM_CARD_TOKEN || process.env.QL_HOOK_TOKEN || '').trim()
|
||||
|
||||
/* ---------------- 金币中心(music.actCenter.ActCenterSignNewSvr) ----------------
|
||||
* 2026-11-07 从 MaYIHEI/paperclip 的 qqmusic.js 逆出并实测校准。
|
||||
* 与任务中心/福利站完全独立:这是「金币」体系,签到给金币不给成长值,因此不计入 total。
|
||||
*
|
||||
* ActID 会随活动换季变化。硬编码值只是兜底,优先用环境变量覆盖:
|
||||
* QM_COIN_ACT_ID 默认 Z25hHGi(金币中心签到)
|
||||
* QM_COIN_SCENE_ID 默认 2
|
||||
* 换季时从 App 抓包看 `music.actCenter.ActCenterSignNewSvr` 的 `ActID` 参数,改 env 即可,
|
||||
* 不用改代码。查询失败会打日志提示改哪个变量。 */
|
||||
const COIN_ACT_ID = String(process.env.QM_COIN_ACT_ID || 'Z25hHGi').trim()
|
||||
const COIN_SCENE_ID = String(process.env.QM_COIN_SCENE_ID || '2').trim()
|
||||
// 金币抽奖签到是另一个 ActID(独立活动,同样随季节变)
|
||||
const LOTTERY_ACT_ID = String(process.env.QM_COIN_LOTTERY_ACT_ID || 'Z156KEu').trim()
|
||||
// 默认开启:纯签到 + 领奖,不消耗金币、不伪造行为,风险等价 App 点击。
|
||||
// 置 off 可整体跳过(比如不想让日志变长)
|
||||
const COIN_SIGN = String(process.env.QM_COIN_SIGN || 'on').toLowerCase() !== 'off'
|
||||
|
||||
/* ---------------- 通知:复用仓库根 common.js 的 notify() ----------------
|
||||
* 为什么不 require 青龙的 sendNotify.js(2026-11-07 真机定位):
|
||||
* shell/update.sh 的 gen_list_repo 复制顺序是三步 ——
|
||||
@@ -207,6 +228,51 @@ class QMClient {
|
||||
return out
|
||||
}
|
||||
|
||||
/** 金币中心的 api:走 musicu.fcg,comm 用小程序形态(authst 在 body 里,不签名、不带 Cookie)。
|
||||
* 与上面 api() 是**两套完全不同的协议**,别混用 —— 金币模块在 u6 网关下会返回空。
|
||||
* reqs 形如 [['req_0', method, param], ...],一次可并发多个 method(查询用两个)。 */
|
||||
async coinApi(reqs) {
|
||||
const comm = {
|
||||
uin: Number(this.uin), authst: this.key, mina: 1, appid: MINA_APPID,
|
||||
ct: 29, cv: 0, format: 'json',
|
||||
}
|
||||
const body = { comm }
|
||||
for (const [key, method, param] of reqs) body[key] = { module: M_COIN, method, param }
|
||||
const payload = JSON.stringify(body)
|
||||
// 这一路不签名(musicu.fcg 接受裸 JSON),headers 也只带 JSON 头
|
||||
const r = await request('POST', API_U, {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': UA,
|
||||
}, payload)
|
||||
let d
|
||||
try { d = JSON.parse(r.text) } catch { throw new Error(`金币响应非 JSON(http ${r.status}): ${r.text.slice(0, 120)}`) }
|
||||
const first = d && (d.req_0 || d.req1)
|
||||
if (first && (first.code === 2000 || first.code === 1000)) {
|
||||
throw new QmDead('金币中心返回未登录,请重抓 qm_keyst')
|
||||
}
|
||||
return d || {}
|
||||
}
|
||||
|
||||
/** 金币签到状态查询:一次拿 Summary(IsSignIn)+ TaskList(ContinueTaskList 里 State===2 可领)。 */
|
||||
async coinSignState(actId, sceneId) {
|
||||
const d = await this.coinApi([
|
||||
['req_0', 'GetSignInSummary', sceneId ? { ActID: actId, ScenesID: sceneId } : { ActID: actId }],
|
||||
['req_1', 'GetSignInTaskList', sceneId ? { ActID: actId, ScenesID: sceneId } : { ActID: actId }],
|
||||
])
|
||||
const s = (d.req_0 || {}), t = (d.req_1 || {})
|
||||
const sd = s.data || {}, td = t.data || {}
|
||||
// retCode 各接口口径不同:0 是成功,部分接口缺这个字段也算成功
|
||||
const bad = v => v != null && Number(v) !== 0
|
||||
if (d.code !== 0 || s.code !== 0 || t.code !== 0 || bad(sd.retCode) || bad(td.retCode)) {
|
||||
return { ok: false, sCode: s.code, tCode: t.code, sRet: sd.retCode, tRet: td.retCode }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
info: td.Info || sd.Info || {},
|
||||
taskList: (((td.TaskListInfo || {}).TaskList || {}).ContinueTaskList) || {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 续期(GET 形态,req1 结构)。一级失败用缓存的 openid+access_token 二级救活。 */
|
||||
async qqLogin(param) {
|
||||
const ds = JSON.stringify({ req1: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param } })
|
||||
@@ -524,6 +590,126 @@ class QMClient {
|
||||
return gained
|
||||
}
|
||||
|
||||
/** 金币中心签到 + 领奖 —— music.actCenter.ActCenterSignNewSvr,走 musicu.fcg 小程序协议。
|
||||
* 与任务中心/福利站完全独立:给的是**金币**,不是成长值,所以返回值恒 0(不计入 total)。
|
||||
*
|
||||
* 流程(与 paperclip/qqmusic.js 的 checkCoinSignIn 同构):
|
||||
* ① GetSignInSummary + GetSignInTaskList → info.IsSignIn / ContinueTaskList
|
||||
* ② 未签 → SignIn(ActID + ScenesID)
|
||||
* ③ 找 State===2 的 TaskList 项 → AwardPrize(领当日奖励)
|
||||
*
|
||||
* 不做的:金币抽奖(drawCoinLottery,消耗金币抽奖券)、红包雨、每日任务列表、
|
||||
* 定时金币 —— 那些要么花金币要么要伪造播放/浏览行为,风险与收益不成比例。 */
|
||||
async coinCenter() {
|
||||
if (!COIN_SIGN) { this.step('金币签到', 'skip', 'QM_COIN_SIGN=off'); return 0 }
|
||||
|
||||
let st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID)
|
||||
if (!st.ok) {
|
||||
// ActID 会随活动换季失效。这里不打 fail(不是登录态问题,也不是脚本 bug),
|
||||
// 用 warn 并把「改哪个 env」写进 detail,看到通知就知道怎么修。
|
||||
log(`[金币] 状态查询失败 code=${st.sCode}/${st.tCode} ret=${st.sRet}/${st.tRet}`)
|
||||
log(`[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID`)
|
||||
this.step('金币签到', 'warn', `查询失败 code ${st.sCode}/${st.tCode}(可能 ActID 过期)`)
|
||||
return 0
|
||||
}
|
||||
|
||||
let signedNow = false
|
||||
if (!st.info.IsSignIn) {
|
||||
const d = await this.coinApi([['req_0', 'SignIn', { ActID: COIN_ACT_ID, ScenesID: COIN_SCENE_ID }]])
|
||||
const req = d.req_0 || {}, data = req.data || {}
|
||||
const okSign = d.code === 0 && req.code === 0 && Number(data.retCode ?? 0) === 0
|
||||
&& data.Info && data.Info.IsSignIn
|
||||
if (!okSign) {
|
||||
log(`[金币] 签到失败 code=${req.code} ret=${data.retCode}`)
|
||||
this.step('金币签到', 'warn', `签到失败 code ${req.code} ret ${data.retCode}`)
|
||||
return 0
|
||||
}
|
||||
signedNow = true
|
||||
log('[金币] 签到成功')
|
||||
st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID)
|
||||
if (!st.ok) { this.step('金币签到', 'done', '已签(状态复查失败)'); return 0 }
|
||||
}
|
||||
|
||||
// 连续签到第几天 → 用于在 TaskList 里定位「今天该领的那个」
|
||||
const day = Number(st.info.ContinueSignInCount || 0)
|
||||
const map = st.taskList || {}
|
||||
const task = Object.values(map).find(x => x && Number(x.State) === 2) || map[String(day)]
|
||||
const rewardName = (() => {
|
||||
const p = task && Array.isArray(task.PrizeList) ? task.PrizeList[0] : null
|
||||
if (!p) return ''
|
||||
if (p.Name) return p.Name
|
||||
if (p.Value) return `${p.Value} 金币`
|
||||
return ''
|
||||
})()
|
||||
|
||||
if (task && Number(task.State) === 2) {
|
||||
const d = await this.coinApi([['req_0', 'AwardPrize', { ActID: COIN_ACT_ID, TaskID: task.ID }]])
|
||||
const req = d.req_0 || {}, data = req.data || {}
|
||||
const ret = Number(data.retCode ?? 0)
|
||||
// 100004 = 已领过(幂等),算成功
|
||||
const okAward = d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)
|
||||
if (okAward) {
|
||||
log(`[金币] ${signedNow ? '签到并领取' : '补领'}成功${rewardName ? `:${rewardName}` : ''}`)
|
||||
this.step('金币签到', 'done', `${signedNow ? '已签 + ' : ''}${rewardName || '已领'}`)
|
||||
} else {
|
||||
log(`[金币] 领奖失败 code=${req.code} ret=${ret}`)
|
||||
this.step('金币签到', 'warn', `已签,领奖失败 ret ${ret}`)
|
||||
}
|
||||
} else if (st.info.IsSignIn) {
|
||||
const nth = day ? `连续第 ${day} 天` : ''
|
||||
log(`[金币] 今日已签到${nth ? `(${nth})` : ''}`)
|
||||
this.step('金币签到', 'skip', `今日已签${nth ? `(${nth})` : ''}`)
|
||||
} else {
|
||||
log('[金币] 签到状态未确认')
|
||||
this.step('金币签到', 'warn', '签到状态未确认')
|
||||
}
|
||||
|
||||
// ---- 金币抽奖签到(另一个 ActID,独立活动)----
|
||||
// 只做「签到 + 领奖」,不碰 drawCoinLottery(那个要消耗抽奖券去抽,属于花金币)
|
||||
try {
|
||||
let ls = await this.coinSignState(LOTTERY_ACT_ID, '')
|
||||
if (!ls.ok) {
|
||||
log(`[金币抽奖] 状态查询失败 code=${ls.sCode}/${ls.tCode},跳过`)
|
||||
} else {
|
||||
let lotterySigned = false
|
||||
if (!ls.info.IsSignIn) {
|
||||
const d = await this.coinApi([['req_0', 'SignIn', { ActID: LOTTERY_ACT_ID }]])
|
||||
const req = d.req_0 || {}
|
||||
if (d.code === 0 && req.code === 0) {
|
||||
lotterySigned = true
|
||||
ls = await this.coinSignState(LOTTERY_ACT_ID, '')
|
||||
}
|
||||
}
|
||||
const lt = ls.ok
|
||||
? Object.values(ls.taskList || {}).find(x => x && Number(x.State) === 2)
|
||||
: null
|
||||
if (lt) {
|
||||
const d = await this.coinApi([['req_0', 'AwardPrize', { ActID: LOTTERY_ACT_ID, TaskID: lt.ID }]])
|
||||
const req = d.req_0 || {}, data = req.data || {}
|
||||
const ret = Number(data.retCode ?? 0)
|
||||
if (d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)) {
|
||||
this.step('金币抽奖签到', 'done', lotterySigned ? '已签 + 已领' : '已领')
|
||||
} else {
|
||||
this.step('金币抽奖签到', 'warn', `领奖失败 ret ${ret}`)
|
||||
}
|
||||
} else if (lotterySigned) {
|
||||
// 签了但当场没查到可领项(接口有延迟)→ 也记一笔,别让「做过的事」在通知里消失
|
||||
this.step('金币抽奖签到', 'done', '已签(奖励待下次领)')
|
||||
} else if (ls.ok && ls.info.IsSignIn) {
|
||||
this.step('金币抽奖签到', 'skip', '今日已签')
|
||||
} else {
|
||||
this.step('金币抽奖签到', 'skip', '无需处理')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof QmDead) throw e
|
||||
log(`[金币抽奖] 异常: ${e.message}`)
|
||||
this.step('金币抽奖签到', 'warn', e.message)
|
||||
}
|
||||
|
||||
return 0 // 金币不计入成长值合计
|
||||
}
|
||||
|
||||
/** VIP 体验券(分享卡)—— music.vip.VipFreeCardSvr,2026-11-04 从体验券落地页
|
||||
* index.036b2e716.js 逆出,接口已在 u6 网关实测路由通(GetCanNumConf 用假 key
|
||||
* 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。
|
||||
@@ -664,6 +850,13 @@ class QMClient {
|
||||
log(`[福利站] 运行异常: ${e.message}`)
|
||||
this.step('福利站', 'fail', e.message)
|
||||
}
|
||||
// 金币中心:独立协议(musicu.fcg + 小程序 comm),给金币不给成长值。
|
||||
// 放在福利站之后、分享卡之前 —— 金币体系挂了不影响前两套,也不拖累取卡。
|
||||
try { await this.coinCenter() } catch (e) {
|
||||
if (e instanceof QmDead) throw e
|
||||
log(`[金币] 运行异常: ${e.message}`)
|
||||
this.step('金币签到', 'fail', e.message)
|
||||
}
|
||||
try { await this.shareCard() } catch (e) {
|
||||
if (e instanceof QmDead) throw e // 登录态失效照常上抛
|
||||
log(`[分享卡] 运行异常: ${e.message}`) // 接口改版/风控不拖垮主流程
|
||||
|
||||
243
qqmusic/test_coin.js
Normal file
243
qqmusic/test_coin.js
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/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)
|
||||
})()
|
||||
Reference in New Issue
Block a user