fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)

号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
This commit is contained in:
2026-11-07 18:50:04 +08:00
parent a9bdd048f5
commit 57a49ab7f5
4 changed files with 501 additions and 2 deletions

View File

@@ -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}`) // 接口改版/风控不拖垮主流程