feat(qqmusic): 通知改成逐项明细 + 结论标题

之前通知只有一行「本次新增成长值 N」,看不出这轮到底做了什么、
哪些没做成、要不要人工介入。

改动:
- QMClient 新增 steps 数组与 step(name,status,detail) 记录点,
  status 四态:done=做到 / warn=部分完成留待下次 / fail=失败 /
  skip=无需处理(今日已完成或未开放)
- verdict() 汇总:有 fail→fail,有 warn→warn,其余 done
- 每个动作补上 step():任务中心签到、挂件、装扮、听书行为、
  节目开始标记、四项领奖、福利站签到/礼券/旅程激励/月里程碑/
  月度好礼/臻享抽奖、分享卡
- 新增 renderAccount():done 逐行列,warn/fail 逐行列,skip 折叠成
  一行(避免十几个「今日已完成」淹没有效信息),分享卡链接单独一行
- 通知标题按结论给:✅ 全部完成 / ⚠️ 有未完成项
- 登录态失效保持 🔴 单列并直接给处理指引,不进明细列表

设计取舍:skip 折叠而不是省略 —— 折叠能看出「今天该做的都做了」,
省略会让人怀疑是不是漏跑了。done/warn/fail 永不折叠。

验证:node --check / 签名对拍 6 组 PASS / verdict 三态用例 /
渲染两例(含卡片链接与不含)输出符合预期 / _probe_meta 三元数据 PASS
This commit is contained in:
2026-11-05 00:00:44 +08:00
parent c7c341e10a
commit 2d29abb440
2 changed files with 167 additions and 22 deletions

View File

@@ -124,6 +124,21 @@ class QMClient {
this.rescue = { openid: '', access_token: '' } // QQ 互联二级救活凭证
this.key = key
this.loadCache() // 缓存 key 优先于 env(qmcommon 同语义)
this.steps = [] // 本次执行明细,供汇总通知渲染
this.shareCardUrl = ''
}
/** 记录一步执行结果。status: done=做到 / skip=无需做(已完成或未开放)
* warn=部分完成(留待下次)/ fail=失败。只进通知,不替代 log。 */
step(name, status, detail) {
this.steps.push({ name, status, detail: String(detail ?? '') })
}
/** 本次是否「完成」:有 fail 就是未完成,有 warn 是部分完成,其余算完成 */
verdict() {
if (this.steps.some(s => s.status === 'fail')) return 'fail'
if (this.steps.some(s => s.status === 'warn')) return 'warn'
return 'done'
}
loadCache() {
@@ -235,9 +250,11 @@ class QMClient {
const gotN = Number(data.Total)
const got = Number.isFinite(gotN) && gotN > 0 ? gotN : null
log(`[签到] 签到成功${got ? ` 成长值+${got}` : '(成长值实值见 App 明细)'}`)
this.step('任务中心签到', 'done', got ? `+${got}` : '已签')
return got || 0
}
log(`[签到] Ret:${data.Ret} ${data.Msg || ''}`)
this.step('任务中心签到', 'fail', `Ret ${data.Ret} ${data.Msg || ''}`.trim())
return 0
}
@@ -245,7 +262,7 @@ class QMClient {
async pendant() {
const d = await this.api('music.vip.PendantUserSvr', 'QueryUserPendant', {})
const pid = (d.data || {}).pendant && d.data.pendant.id
if (!pid) { log('[挂件] 无挂件库存,任务8跳过'); return }
if (!pid) { log('[挂件] 无挂件库存,任务8跳过'); this.step('挂件', 'skip', '无库存'); return }
let r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) })
let rc = Number((r.data || {}).retCode)
if (rc === 42502) { // 已佩戴 → Cancel→Use 成对制造当日动作
@@ -254,6 +271,7 @@ class QMClient {
rc = Number((r.data || {}).retCode)
}
log(`[挂件] UsePendant retCode:${rc}`)
this.step('挂件', rc === 0 ? 'done' : 'warn', `retCode ${rc}`)
}
/** 任务9 装扮:UseCosmetic 当前使用中的装扮(cosmeticcgi 专用 comm) */
@@ -265,10 +283,11 @@ class QMClient {
const d = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'GetUserCosmetic', { category: 'subject' }, comm)
const items = ((d.data || {}).cosmetics) || []
const cur = items.find(c => Number(c.state) === 1) || items[0]
if (!cur) { log('[装扮] subject 类目无装扮,任务9跳过'); return }
if (!cur) { log('[装扮] subject 类目无装扮,任务9跳过'); this.step('装扮', 'skip', '无 subject 装扮'); return }
const r = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'UseCosmetic',
{ category: 'subject', itemID: cur.id, manufacture: 'vivo' }, comm)
log(`[装扮] UseCosmetic code:${r.code} (id=${cur.id})`)
this.step('装扮', r.code === 0 ? 'done' : 'warn', `code ${r.code}`)
}
/** 领取循环:20020 冷却3s重领 / 30005 任务20只读探测一次 / 10006 写锁即停 / 30009 已领 */
@@ -278,7 +297,7 @@ class QMClient {
for (const [tid, name] of CLAIMS) {
const k = String(tid)
const v = stNow[k]
if (v === 'Y') { log(`[任务] ${name}: 今日已完成`); continue }
if (v === 'Y') { log(`[任务] ${name}: 今日已完成`); this.step(`领奖·${name}`, 'skip', '今日已完成'); continue }
if (tid === 20 && v === 'W') {
await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' }) // 领取钥匙
}
@@ -301,6 +320,7 @@ class QMClient {
code = 30009
} else {
log(`[任务] ${name}: state 仍 ${v2 || 'N'},留待下次运行`)
this.step(`领奖·${name}`, 'warn', '未计次,待 App 内收听')
continue
}
}
@@ -308,14 +328,18 @@ class QMClient {
const got = (d.data && d.data.DrawScores) || 0
log(`[任务] ${name}: 领取成功 +${got}`)
total += got
this.step(`领奖·${name}`, 'done', `+${got}`)
await sleep(3000) // 成功发放后 ~2s 冷却,拉开下一个
} else if (code === 30009) {
log(`[任务] ${name}: 今日已完成`)
this.step(`领奖·${name}`, 'skip', '今日已完成')
} else if (code === 10006) {
log(`[任务] ${name}: 10006 写操作锁,停止本账号领取(次日自动恢复)`)
this.step(`领奖·${name}`, 'warn', '10006 写锁,次日恢复')
break
} else {
log(`[任务] ${name}: code:${code}(未识别,留待下次运行)`)
this.step(`领奖·${name}`, 'warn', `code ${code}`)
}
}
return total
@@ -339,6 +363,7 @@ class QMClient {
const today = days.find(x => x.day === day)
if (today && today.signInStatus === 2) {
log(`[福利站] 今日已签到(本月 ${monthDays} 天)`)
this.step('福利站签到', 'skip', `今日已签(本月 ${monthDays} 天)`)
} else {
const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'RenewalPlaySignIn', {})
if (r.code === 0) {
@@ -347,6 +372,7 @@ class QMClient {
log(`[福利站] 签到成功:${name}(本月 ${monthDays + 1} 天)`)
const m = /^(\d+)成长值/.exec(name)
if (m) gained += Number(m[1])
this.step('福利站签到', 'done', `${name}(本月 ${monthDays + 1} 天)`)
for (const mk of ['weekMilestoneIncentive', 'monthMilestoneIncentive']) {
const inc = data[mk]
if (inc && inc.incentiveName) {
@@ -355,8 +381,10 @@ class QMClient {
}
} else if (r.code === 14822150) {
log(`[福利站] 今日已签到(幂等码,本月 ${monthDays} 天)`)
this.step('福利站签到', 'skip', '今日已签')
} else {
log(`[福利站] 签到异常 code:${r.code}`)
this.step('福利站签到', 'fail', `code ${r.code}`)
}
}
@@ -375,14 +403,23 @@ class QMClient {
if (ok.length) {
const pick = ok[day % ok.length]
const r = await this.api('music.vipcgi.VipRenewalCouponCgi', 'ClaimVipRenewCoupon', { couponID: pick.couponID })
if (r.code === 0) log(`[礼券] ${pick.couponName} 领取成功 +${t502.taskReward || 10}心愿值`)
else if (r.code === 14816772) log('[礼券] 今日已领取')
else log(`[礼券] code:${r.code}`)
if (r.code === 0) {
log(`[礼券] ${pick.couponName} 领取成功 +${t502.taskReward || 10}心愿值`)
this.step('福利站礼券', 'done', pick.couponName)
} else if (r.code === 14816772) {
log('[礼券] 今日已领取')
this.step('福利站礼券', 'skip', '今日已领')
} else {
log(`[礼券] code:${r.code}`)
this.step('福利站礼券', 'warn', `code ${r.code}`)
}
} else {
log(`[礼券] 券池今日无可领券(${infos.length} 张全非可领态)`)
this.step('福利站礼券', 'skip', '券池无可领')
}
} else if (t502) {
log('[礼券] 今日已完成')
this.step('福利站礼券', 'skip', '今日已完成')
}
// ---- 旅程激励:心愿值达标档位实时领取(ClaimIncentives 幂等)----
@@ -390,11 +427,21 @@ class QMClient {
const unlock = (idata.incentives || []).filter(x => x.status === 1)
if (unlock.length) {
const r = await this.api('music.vipcgi.StarWishLotteryCgi', 'ClaimIncentives', { seasonID: season })
if (r.code === 0) log(`[激励] 领取成功:${unlock.map(x => `${x.name}(${x.score}分)`).join('、')}`)
else log(`[激励] code:${r.code}`)
if (r.code === 0) {
log(`[激励] 领取成功:${unlock.map(x => `${x.name}(${x.score}分)`).join('、')}`)
this.step('旅程激励', 'done', unlock.map(x => x.name).join('、'))
} else {
log(`[激励] code:${r.code}`)
this.step('旅程激励', 'warn', `code ${r.code}`)
}
} else {
const nxt = (idata.incentives || []).find(x => x.status === 5)
if (nxt) log(`[激励] 心愿值${idata.currentScore || 0}/${nxt.score}(下档 ${nxt.name})`)
if (nxt) {
log(`[激励] 心愿值${idata.currentScore || 0}/${nxt.score}(下档 ${nxt.name})`)
this.step('旅程激励', 'skip', `心愿值 ${idata.currentScore || 0}/${nxt.score}`)
} else {
this.step('旅程激励', 'skip', '无待领档位')
}
}
// ---- 月度里程碑:签满目标天数即领(探针先行,14822144=未达标幂等)----
@@ -406,43 +453,59 @@ class QMClient {
const status = target.incentiveStatus
if (signedCnt >= need || status === 2 || status === 3) {
const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'ClaimMilestoneIncentive', { month })
if (r.code === 0) log(`[月里程碑] 领取:${target.incentiveName}`)
else log(`[月里程碑] code:${r.code}(${signedCnt} 天达标待领)`)
if (r.code === 0) {
log(`[月里程碑] 领取:${target.incentiveName}`)
this.step('月里程碑', 'done', target.incentiveName)
} else {
log(`[月里程碑] code:${r.code}(${signedCnt} 天达标待领)`)
this.step('月里程碑', 'warn', `code ${r.code}(${signedCnt} 天达标)`)
}
} else {
log(`[月里程碑] ${signedCnt}/${need} 天(${target.incentiveName})`)
this.step('月里程碑', 'skip', `${signedCnt}/${need} 天`)
}
}
// ---- 月度好礼:poolStatus 3=可领 且库存>0 才领;补货时刻实读打印 ----
const gpData = (await this.api('music.vipcgi.VipRenewalMonthGiftCgi', 'QueryVipRenewalMonthlyGiftPool', {})).data || {}
const gotGifts = [], noStock = []
for (const p of gpData.giftPools || []) {
const g = p.gift || {}
if (p.poolStatus === 3 && (g.availableStock || 0) > 0) {
const r = await this.api('music.vipcgi.VipRenewalMonthGiftCgi', 'ReceiveVipRenewalMonthlyGift',
{ giftPoolType: p.poolType, tab: 'cjhy' })
if (r.code === 0) log(`[月礼] 领取:${g.giftName}`)
else log(`[月礼] code:${r.code}`)
if (r.code === 0) { log(`[月礼] 领取:${g.giftName}`); gotGifts.push(g.giftName) }
else { log(`[月礼] code:${r.code}`); noStock.push(`${g.giftName}(code ${r.code})`) }
} else if (p.poolStatus === 3) {
const ts = g.nextStockRefreshTime || 0
const nxt = ts ? new Date(ts * 1000).toTimeString().slice(0, 5) : '未知'
log(`[月礼] ${g.giftName} 今日库存0(${nxt} 补货)`)
noStock.push(`${g.giftName}(库存0)`)
}
}
if (gotGifts.length) this.step('月度好礼', 'done', gotGifts.join('、'))
else if (noStock.length) this.step('月度好礼', 'skip', noStock.join('、'))
else this.step('月度好礼', 'skip', '无可用礼品池')
// ---- 臻享抽奖:有抽奖券才抽,抽到清零或失败即停 ----
const dd = (await this.api('music.vipcgi.VipRenewalLotteryCgi', 'QueryVipRenewDrawInfo', {})).data || {}
let draws = dd.lotteryCount || 0
const prizes = []
while (draws > 0) {
const r = await this.api('music.vipcgi.VipRenewalLotteryCgi', 'DrawVipRenewActivity', { lotteryPageType: 1 })
if (r.code === 0) {
const prize = ((r.data || {}).prize || {}).prizeName
log(`[抽奖] ${prize || '成功'}`)
prizes.push(prize || '成功')
draws -= 1
} else {
log(`[抽奖] code:${r.code},停止`)
break
}
}
if (prizes.length) this.step('臻享抽奖', 'done', `${prizes.length} 次:${prizes.join('、')}`)
else this.step('臻享抽奖', 'skip', '无抽奖券')
log(`[福利站] ${ymd} 巡检完成`)
return gained
}
@@ -486,7 +549,6 @@ class QMClient {
log(hasCard
? `[分享卡] 本月配额 ${quota} 张,已被领 ${sentList.length} 张,还能送 ${canSend} 张`
: `[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`)
// ③ 复用优先:扫本月每张卡,cardLeft>0 的直接返回链接(不建新卡)
for (const c of cards) {
const cid = c.cardID || c.cardId
@@ -506,16 +568,19 @@ class QMClient {
log(`[分享卡] 复用本月未领完的卡:还能被领 ${left} 次(不新建、不占新配额)`)
log(`[分享卡] 分享链接:${url}`)
this.shareCardUrl = url
this.step('分享卡', 'done', `复用本月卡,还能被领 ${left} 次`)
return 0
}
}
if (!force && !SHARE_CARD) {
log('[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡')
this.step('分享卡', 'skip', `只读,还能送 ${canSend} 张(QM_SHARE_CARD 未开)`)
return 0
}
if (canSend <= 0) {
log('[分享卡] 本月配额已用完,下月再送')
this.step('分享卡', 'skip', '本月配额已用完')
return 0
}
@@ -524,6 +589,7 @@ class QMClient {
const cardId = created.cardID
if (!cardId) {
log('[分享卡] CreatCard 未返回 cardID,跳过')
this.step('分享卡', 'warn', 'CreatCard 未返回 cardID')
return 0
}
const left = (created.cardInfo || {}).left
@@ -531,6 +597,7 @@ class QMClient {
log(`[分享卡] 已新建:还能被领 ${left != null ? left : '?'} 次`)
log(`[分享卡] 分享链接:${url}`)
this.shareCardUrl = url // 供汇总输出
this.step('分享卡', 'done', `新建 1 张,还能被领 ${left != null ? left : '?'} 次`)
return 0 // 不产成长值,不计入 total
}
@@ -543,15 +610,20 @@ class QMClient {
const signed = await this.signQry()
log(`[状态] 签到:${signed ? '已签' : '未签'} 挂件:${st['8']} 装扮:${st['9']} 听书:${st['30']} 节目:${st['20']}`)
if (signed) log('[签到] 今日已签到')
if (signed) { log('[签到] 今日已签到'); this.step('任务中心签到', 'skip', '今日已签') }
else out.total += await this.signDaily()
// 动作语义:N=执行动作 / W=只领(能否领取决于行为已计次)/ Y=零请求(防风控)
if (st['8'] === 'N') await this.pendant()
else this.step('挂件', 'skip', '今日无需动作')
if (st['9'] === 'N') await this.cosmetic()
else this.step('装扮', 'skip', '今日无需动作')
if (st['30'] === 'N') {
await this.api('music.lvz.LevelConfigSvr', 'RecordBehavior', { ID: '30' })
log('[听书] 行为上报完成')
this.step('听书行为', 'done', '已上报')
} else {
this.step('听书行为', 'skip', '今日无需动作')
}
if (st['20'] === 'N' || !signed) {
// 任务20跨天不重置:当日未签时 20:Y 是昨日残留,仍挂开始标记
@@ -561,9 +633,13 @@ class QMClient {
? '[节目] 开始标记已挂(播放上报未实现于本版,App 内正常收听计入)'
: '[节目] 开始标记已挂(播放上报未启用,App 内正常收听计入)')
if (r.count != null) log(`[节目] 当前播放计数: ${r.count}`)
this.step('节目开始标记', 'done', r.count != null ? `计数 ${r.count}` : '已挂')
} else {
log(`[节目] StartTask 返回 code:${r.code},无法开始`)
this.step('节目开始标记', 'warn', `code ${r.code}`)
}
} else {
this.step('节目开始标记', 'skip', '今日无需动作')
}
const stNow = await this.state()
@@ -572,16 +648,22 @@ class QMClient {
try { out.total += await this.vipStation() } 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}`) // 接口改版/风控不拖垮主流程
this.step('分享卡', 'fail', e.message)
}
out.cardUrl = this.shareCardUrl || ''
out.steps = this.steps
out.verdict = this.verdict()
log(`[QQ音乐] 本次新增成长值合计: ${out.total}`)
} catch (e) {
if (e instanceof QmDead) {
out.dead = true
out.steps = this.steps
out.verdict = 'dead'
log(`[QQ音乐] 登录态失效(2000): ${e.message}`)
return out
}
@@ -691,6 +773,23 @@ function pushCardResult(results) {
})
}
/** 把一次执行的 steps 渲染成通知正文。只列「做到了」和「没做到」,
* 全部 skip 的项折叠成一行,避免通知被十几行「今日已完成」淹没。 */
function renderAccount(uin, r) {
const icon = { done: '✓', warn: '△', fail: '✗', skip: '·' }
const did = r.steps.filter(s => s.status === 'done')
const warn = r.steps.filter(s => s.status === 'warn' || s.status === 'fail')
const skip = r.steps.filter(s => s.status === 'skip')
const lines = [`${r.verdict === 'done' ? '🟢' : '🟡'} ${uin}:成长值 +${r.total}`]
for (const s of did) lines.push(` ${icon.done} ${s.name}${s.detail ? ` ${s.detail}` : ''}`)
for (const s of warn) lines.push(` ${icon[s.status]} ${s.name}${s.detail ? ` ${s.detail}` : ''}`)
if (skip.length) {
lines.push(` ${icon.skip} 无需处理 ${skip.length} 项:${skip.map(s => s.name).join('、')}`)
}
if (r.cardUrl) lines.push(` 🎁 分享卡 ${r.cardUrl}`)
return lines.join('\n')
}
async function main() {
// ---- 手动取卡模式(青龙「运行」按钮直接点,给人送卡用)----
// 触发:task qqmusic/scrip_qqmusic_checkin.js -- card
@@ -736,22 +835,29 @@ async function main() {
}
const blocks = []
let allDone = true
for (const a of accounts) {
log(`\n===== 账号 ${a.uin} =====`)
try {
const cli = new QMClient(a.uin, a.key, path.join(dir, `qm_${a.uin}.json`))
const r = await cli.run()
blocks.push(r.dead
? `🔴 ${a.uin}:登录态失效,请重新在网页抓 qm_keyst 更新 env`
: `🟢 ${a.uin}:本次新增成长值 ${r.total}`)
if (r.cardUrl) blocks.push(`🎁 ${a.uin}:分享卡 ${r.cardUrl}`)
if (r.dead) {
allDone = false
blocks.push(`🔴 ${a.uin}:登录态失效\n └ 请重新在网页抓 qm_keyst 更新 env`)
continue
}
if (r.verdict === 'fail') allDone = false
blocks.push(renderAccount(a.uin, r))
} catch (e) {
allDone = false
log(`[QQ音乐] 账号 ${a.uin} 运行异常: ${e.message}`)
blocks.push(`🔴 ${a.uin}:运行异常 ${e.message}`)
blocks.push(`🔴 ${a.uin}:运行异常\n └ ${e.message}`)
}
}
const desp = blocks.join('\n\n')
+ `\n\n轮次说明: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底)`
const head = allDone ? '✅ QQ音乐签到 · 全部完成' : '⚠️ QQ音乐签到 · 有未完成项'
const desp = [head, '', ...blocks].join('\n')
+ `\n\n———`
+ `\n轮次: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底)`
+ `\n节目任务(20)依赖 App 内正常收听计入(播放上报未启用)`
+ `\n时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`
let sendNotifyFn = null