fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
This commit is contained in:
@@ -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}`) // 接口改版/风控不拖垮主流程
|
||||
|
||||
Reference in New Issue
Block a user