fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
This commit is contained in:
@@ -233,12 +233,14 @@ def parse_curl(raw):
|
|||||||
if not url or not body:
|
if not url or not body:
|
||||||
raise ValueError("cURL 里没解析到 URL 或 body")
|
raise ValueError("cURL 里没解析到 URL 或 body")
|
||||||
headers = {k: v for k, v in headers.items() if k.lower() not in STRIP_HEADERS}
|
headers = {k: v for k, v in headers.items() if k.lower() not in STRIP_HEADERS}
|
||||||
|
# 大小写不敏感视图(Reqable 等导出的头键大小写不定:Cookie/COOKIE)
|
||||||
|
lower = {k.lower(): v for k, v in headers.items()}
|
||||||
problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效
|
problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效
|
||||||
if "autoLogin" not in url:
|
if "autoLogin" not in url:
|
||||||
problems.append("URL 不含 autoLogin")
|
problems.append("URL 不含 autoLogin")
|
||||||
if not headers.get("xs"):
|
if not lower.get("xs"):
|
||||||
problems.append("缺 xs 头")
|
problems.append("缺 xs 头")
|
||||||
if not headers.get("cookie"):
|
if not lower.get("cookie"):
|
||||||
problems.append("缺 cookie 头")
|
problems.append("缺 cookie 头")
|
||||||
if not (1300 <= len(body) <= 1700):
|
if not (1300 <= len(body) <= 1700):
|
||||||
problems.append("body 长度 %d 不在 1472/1516±10%% 内" % len(body))
|
problems.append("body 长度 %d 不在 1472/1516±10%% 内" % len(body))
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node
|
|||||||
| `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) |
|
| `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) |
|
||||||
| `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 |
|
| `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 |
|
||||||
| `QM_CARD` | 可选 | `1`/`on` 等价于 `-- 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_URL` | 可选 | 自定义 webhook(如平台 `/api/hooks/ql`)。与 `PUSH_PLUS_TOKEN` 至少配一个 |
|
||||||
| `WEBHOOK_METHOD` | 可选 | 默认 `POST` |
|
| `WEBHOOK_METHOD` | 可选 | 默认 `POST` |
|
||||||
| `WEBHOOK_CONTENT_TYPE` | 可选 | 默认 `application/json` |
|
| `WEBHOOK_CONTENT_TYPE` | 可选 | 默认 `application/json` |
|
||||||
@@ -314,6 +318,7 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN>
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) |
|
| 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) |
|
||||||
| 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) |
|
| 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) |
|
||||||
|
| **金币中心** | 金币签到 + 领当日奖励 + 金币抽奖签到(`QM_COIN_SIGN`,默认 on) | 低(纯签到领奖,不花金币、不伪造行为) |
|
||||||
| VIP 体验券 | 分享卡配额查询 / 建卡(`QM_SHARE_CARD=on`) | 低(只读默认,不消耗配额) |
|
| VIP 体验券 | 分享卡配额查询 / 建卡(`QM_SHARE_CARD=on`) | 低(只读默认,不消耗配额) |
|
||||||
| 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) |
|
| 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) |
|
||||||
| 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 |
|
| 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 |
|
||||||
@@ -321,6 +326,62 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN>
|
|||||||
状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。
|
状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。
|
||||||
错误码处置:`20020` 冷却3s重领 · `30005` 只读探测后补领 · `10006` 写锁即停次日恢复 · `2000` 登录态失效推送提示。
|
错误码处置:`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
|
```bash
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const crypto = require('crypto')
|
|||||||
|
|
||||||
/* ---------------- 协议底座(与 qmcommon.py 逐字节对齐) ---------------- */
|
/* ---------------- 协议底座(与 qmcommon.py 逐字节对齐) ---------------- */
|
||||||
const API = 'https://u6.y.qq.com/cgi-bin/musics.fcg'
|
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 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 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
|
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 校验它)
|
// 回传鉴权 token:与平台 QL_HOOK_TOKEN 同一枚(平台 hookGuard 校验它)
|
||||||
const CARD_TOKEN = String(process.env.QM_CARD_TOKEN || process.env.QL_HOOK_TOKEN || '').trim()
|
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() ----------------
|
/* ---------------- 通知:复用仓库根 common.js 的 notify() ----------------
|
||||||
* 为什么不 require 青龙的 sendNotify.js(2026-11-07 真机定位):
|
* 为什么不 require 青龙的 sendNotify.js(2026-11-07 真机定位):
|
||||||
* shell/update.sh 的 gen_list_repo 复制顺序是三步 ——
|
* shell/update.sh 的 gen_list_repo 复制顺序是三步 ——
|
||||||
@@ -207,6 +228,51 @@ class QMClient {
|
|||||||
return out
|
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 二级救活。 */
|
/** 续期(GET 形态,req1 结构)。一级失败用缓存的 openid+access_token 二级救活。 */
|
||||||
async qqLogin(param) {
|
async qqLogin(param) {
|
||||||
const ds = JSON.stringify({ req1: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param } })
|
const ds = JSON.stringify({ req1: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param } })
|
||||||
@@ -524,6 +590,126 @@ class QMClient {
|
|||||||
return gained
|
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 从体验券落地页
|
/** VIP 体验券(分享卡)—— music.vip.VipFreeCardSvr,2026-11-04 从体验券落地页
|
||||||
* index.036b2e716.js 逆出,接口已在 u6 网关实测路由通(GetCanNumConf 用假 key
|
* index.036b2e716.js 逆出,接口已在 u6 网关实测路由通(GetCanNumConf 用假 key
|
||||||
* 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。
|
* 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。
|
||||||
@@ -664,6 +850,13 @@ class QMClient {
|
|||||||
log(`[福利站] 运行异常: ${e.message}`)
|
log(`[福利站] 运行异常: ${e.message}`)
|
||||||
this.step('福利站', 'fail', 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) {
|
try { await this.shareCard() } catch (e) {
|
||||||
if (e instanceof QmDead) throw e // 登录态失效照常上抛
|
if (e instanceof QmDead) throw e // 登录态失效照常上抛
|
||||||
log(`[分享卡] 运行异常: ${e.message}`) // 接口改版/风控不拖垮主流程
|
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