feat(qqmusic): 日志与通知按体系分组,每条都标清是什么签到

原来 63 处 step 和 60+ 处 log 全平铺,看日志分不清哪条属于哪套体系:
[签到] / [金币] / [福利站] 混在一起,通知里也是「✓ 签到 +15」这种没有上下文的行。

日志前缀统一成「体系·子项」两段:
  [任务中心·签到] [任务中心·挂件] [任务中心·装扮] [任务中心·听书] [任务中心·节目]
  [任务中心·领奖]  (原 [任务])
  [福利站·签到] [福利站·礼券] [福利站·激励] [福利站·月里程碑] [福利站·月礼]
  [福利站·抽奖] [福利站·周里程碑]
  [金币·签到] [金币·抽奖签到]

run() 里加四段分隔标题,日志一眼能看出走到哪套:
  ───── ① 任务中心(成长值)─────
  ───── ② 会员福利站(成长值 + 权益)─────
  ───── ③ 金币中心(金币,不计入成长值)─────
  ───── ④ VIP 体验券(分享卡)─────

末尾补一行说明:金币中心为独立体系,不产成长值(明细见上方 ③ 段)。
之前只有「本次新增成长值合计: 45」,看不出金币那部分跑没跑。

step 名同样统一成「体系·子项」(63 处),renderAccount 按体系分组渲染:
  🟢 1006764347:成长值 +45
     📈 任务中心(成长值)
        ✓ 签到 +15
        ✓ 挂件 已佩戴
        · 无需处理 1 项:装扮
     🎁 会员福利站(成长值 + 权益)
        ✓ 签到 15成长值(本月 8 天)
        ✓ 礼券 +10 心愿值
     💰 金币中心(金币,不计成长值)
        ✓ 签到 已签 + 30 金币
        · 无需处理 1 项:抽奖签到

分组规则:
- 组内每条都是「子项名 + detail」,不再出现只有 detail 的行
- 整组全 skip 时收成一行「💰 金币中心(金币,不计成长值):签到、抽奖签到 今日无需处理」
- 未知前缀落到「📌 其他」组,名字原样保留
- 分享卡链接改成「🔗 领卡链接 <url>」单独一行

coinCenter / 金币抽奖签到开头各加一行开始日志,带 ActID 值 —— 换季排查时
直接能看到脚本用的是哪个 ActID。

新增 test_render.js:24 条断言冻结分组契约
  ① 三套体系各自成组 + 组标题 emoji + 金币标注不计成长值
  ② 组内每条都带子项名(断言没有「只有 detail」的行)
  ③ 整组 skip 收成一行
  ④ ActID 过期 → warn 单独列且 detail 含 ActID
  ⑤ 分享卡链接单独一行
  ⑥ 空 steps 不崩
  ⑦ 未知前缀 → 其他组

test_coin.js 里 3 处 step 名断言同步改成新名(金币·签到 / 金币·抽奖签到)。
This commit is contained in:
2026-11-07 18:58:15 +08:00
parent a9acafa3ba
commit d081ac66da
3 changed files with 266 additions and 126 deletions

View File

@@ -329,12 +329,12 @@ class QMClient {
if (data.Ret === 0) { if (data.Ret === 0) {
const gotN = Number(data.Total) const gotN = Number(data.Total)
const got = Number.isFinite(gotN) && gotN > 0 ? gotN : null const got = Number.isFinite(gotN) && gotN > 0 ? gotN : null
log(`[签到] 签到成功${got ? ` 成长值+${got}` : '(成长值实值见 App 明细)'}`) log(`[任务中心·签到] 签到成功${got ? ` 成长值+${got}` : '(成长值实值见 App 明细)'}`)
this.step('任务中心签到', 'done', got ? `+${got}` : '已签') this.step('任务中心·签到', 'done', got ? `+${got}` : '已签')
return got || 0 return got || 0
} }
log(`[签到] Ret:${data.Ret} ${data.Msg || ''}`) log(`[任务中心·签到] Ret:${data.Ret} ${data.Msg || ''}`)
this.step('任务中心签到', 'fail', `Ret ${data.Ret} ${data.Msg || ''}`.trim()) this.step('任务中心·签到', 'fail', `Ret ${data.Ret} ${data.Msg || ''}`.trim())
return 0 return 0
} }
@@ -342,7 +342,7 @@ class QMClient {
async pendant() { async pendant() {
const d = await this.api('music.vip.PendantUserSvr', 'QueryUserPendant', {}) const d = await this.api('music.vip.PendantUserSvr', 'QueryUserPendant', {})
const pid = (d.data || {}).pendant && d.data.pendant.id const pid = (d.data || {}).pendant && d.data.pendant.id
if (!pid) { log('[挂件] 无挂件库存,任务8跳过'); this.step('挂件', 'skip', '无库存'); return } if (!pid) { log('[任务中心·挂件] 无挂件库存,任务8跳过'); this.step('任务中心·挂件', 'skip', '无库存'); return }
let r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) }) let r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) })
let rc = Number((r.data || {}).retCode) let rc = Number((r.data || {}).retCode)
if (rc === 42502) { // 已佩戴 → Cancel→Use 成对制造当日动作 if (rc === 42502) { // 已佩戴 → Cancel→Use 成对制造当日动作
@@ -350,8 +350,8 @@ class QMClient {
r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) }) r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) })
rc = Number((r.data || {}).retCode) rc = Number((r.data || {}).retCode)
} }
log(`[挂件] UsePendant retCode:${rc}`) log(`[任务中心·挂件] UsePendant retCode:${rc}`)
this.step('挂件', rc === 0 ? 'done' : 'warn', `retCode ${rc}`) this.step('任务中心·挂件', rc === 0 ? 'done' : 'warn', `retCode ${rc}`)
} }
/** 任务9 装扮:UseCosmetic 当前使用中的装扮(cosmeticcgi 专用 comm) */ /** 任务9 装扮:UseCosmetic 当前使用中的装扮(cosmeticcgi 专用 comm) */
@@ -363,11 +363,11 @@ class QMClient {
const d = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'GetUserCosmetic', { category: 'subject' }, comm) const d = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'GetUserCosmetic', { category: 'subject' }, comm)
const items = ((d.data || {}).cosmetics) || [] const items = ((d.data || {}).cosmetics) || []
const cur = items.find(c => Number(c.state) === 1) || items[0] const cur = items.find(c => Number(c.state) === 1) || items[0]
if (!cur) { log('[装扮] subject 类目无装扮,任务9跳过'); this.step('装扮', 'skip', '无 subject 装扮'); return } if (!cur) { log('[任务中心·装扮] subject 类目无装扮,任务9跳过'); this.step('任务中心·装扮', 'skip', '无 subject 装扮'); return }
const r = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'UseCosmetic', const r = await this.api('music.cosmeticcgi.UserCosmeticCgi', 'UseCosmetic',
{ category: 'subject', itemID: cur.id, manufacture: 'vivo' }, comm) { category: 'subject', itemID: cur.id, manufacture: 'vivo' }, comm)
log(`[装扮] UseCosmetic code:${r.code} (id=${cur.id})`) log(`[任务中心·装扮] UseCosmetic code:${r.code} (id=${cur.id})`)
this.step('装扮', r.code === 0 ? 'done' : 'warn', `code ${r.code}`) this.step('任务中心·装扮', r.code === 0 ? 'done' : 'warn', `code ${r.code}`)
} }
/** 领取循环:20020 冷却3s重领 / 30005 任务20只读探测一次 / 10006 写锁即停 / 30009 已领 */ /** 领取循环:20020 冷却3s重领 / 30005 任务20只读探测一次 / 10006 写锁即停 / 30009 已领 */
@@ -377,19 +377,19 @@ class QMClient {
for (const [tid, name] of CLAIMS) { for (const [tid, name] of CLAIMS) {
const k = String(tid) const k = String(tid)
const v = stNow[k] const v = stNow[k]
if (v === 'Y') { log(`[任务] ${name}: 今日已完成`); this.step(`领奖·${name}`, 'skip', '今日已完成'); continue } if (v === 'Y') { log(`[任务中心·领奖] ${name}: 今日已完成`); this.step(`领奖·${name}`, 'skip', '今日已完成'); continue }
if (tid === 20 && v === 'W') { if (tid === 20 && v === 'W') {
await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' }) // 领取钥匙 await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' }) // 领取钥匙
} }
let d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k }) let d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k })
let code = d.code let code = d.code
if (code === 20020) { if (code === 20020) {
log(`[任务] ${name}: 20020 冷却,3s 后原地重领`) log(`[任务中心·领奖] ${name}: 20020 冷却,3s 后原地重领`)
await sleep(3000) await sleep(3000)
d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k }) d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k })
code = d.code code = d.code
} else if (code === 30005 && tid === 20) { } else if (code === 30005 && tid === 20) {
log(`[任务] ${name}: 30005 未计次(本版无播放上报,仅 ${POLL_PROBE_MS / 1000}s 只读探测一次)`) log(`[任务中心·领奖] ${name}: 30005 未计次(本版无播放上报,仅 ${POLL_PROBE_MS / 1000}s 只读探测一次)`)
await sleep(POLL_PROBE_MS) await sleep(POLL_PROBE_MS)
const v2 = (await this.state())[k] const v2 = (await this.state())[k]
if (v2 === 'W') { if (v2 === 'W') {
@@ -399,26 +399,26 @@ class QMClient {
} else if (v2 === 'Y') { } else if (v2 === 'Y') {
code = 30009 code = 30009
} else { } else {
log(`[任务] ${name}: state 仍 ${v2 || 'N'},留待下次运行`) log(`[任务中心·领奖] ${name}: state 仍 ${v2 || 'N'},留待下次运行`)
this.step(`领奖·${name}`, 'warn', '未计次,待 App 内收听') this.step(`领奖·${name}`, 'warn', '未计次,待 App 内收听')
continue continue
} }
} }
if (code === 0) { if (code === 0) {
const got = (d.data && d.data.DrawScores) || 0 const got = (d.data && d.data.DrawScores) || 0
log(`[任务] ${name}: 领取成功 +${got}`) log(`[任务中心·领奖] ${name}: 领取成功 +${got}`)
total += got total += got
this.step(`领奖·${name}`, 'done', `+${got}`) this.step(`领奖·${name}`, 'done', `+${got}`)
await sleep(3000) // 成功发放后 ~2s 冷却,拉开下一个 await sleep(3000) // 成功发放后 ~2s 冷却,拉开下一个
} else if (code === 30009) { } else if (code === 30009) {
log(`[任务] ${name}: 今日已完成`) log(`[任务中心·领奖] ${name}: 今日已完成`)
this.step(`领奖·${name}`, 'skip', '今日已完成') this.step(`领奖·${name}`, 'skip', '今日已完成')
} else if (code === 10006) { } else if (code === 10006) {
log(`[任务] ${name}: 10006 写操作锁,停止本账号领取(次日自动恢复)`) log(`[任务中心·领奖] ${name}: 10006 写操作锁,停止本账号领取(次日自动恢复)`)
this.step(`领奖·${name}`, 'warn', '10006 写锁,次日恢复') this.step(`领奖·${name}`, 'warn', '10006 写锁,次日恢复')
break break
} else { } else {
log(`[任务] ${name}: code:${code}(未识别,留待下次运行)`) log(`[任务中心·领奖] ${name}: code:${code}(未识别,留待下次运行)`)
this.step(`领奖·${name}`, 'warn', `code ${code}`) this.step(`领奖·${name}`, 'warn', `code ${code}`)
} }
} }
@@ -442,29 +442,29 @@ class QMClient {
monthDays = (d.data || {}).signInDays || signedCnt monthDays = (d.data || {}).signInDays || signedCnt
const today = days.find(x => x.day === day) const today = days.find(x => x.day === day)
if (today && today.signInStatus === 2) { if (today && today.signInStatus === 2) {
log(`[福利站] 今日已签到(本月 ${monthDays} 天)`) log(`[福利站·签到] 今日已签到(本月 ${monthDays} 天)`)
this.step('福利站签到', 'skip', `今日已签(本月 ${monthDays} 天)`) this.step('福利站·签到', 'skip', `今日已签(本月 ${monthDays} 天)`)
} else { } else {
const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'RenewalPlaySignIn', {}) const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'RenewalPlaySignIn', {})
if (r.code === 0) { if (r.code === 0) {
const data = r.data || {} const data = r.data || {}
const name = (data.todayIncentive || {}).incentiveName || '奖励' const name = (data.todayIncentive || {}).incentiveName || '奖励'
log(`[福利站] 签到成功:${name}(本月 ${monthDays + 1} 天)`) log(`[福利站·签到] 签到成功:${name}(本月 ${monthDays + 1} 天)`)
const m = /^(\d+)成长值/.exec(name) const m = /^(\d+)成长值/.exec(name)
if (m) gained += Number(m[1]) if (m) gained += Number(m[1])
this.step('福利站签到', 'done', `${name}(本月 ${monthDays + 1} 天)`) this.step('福利站·签到', 'done', `${name}(本月 ${monthDays + 1} 天)`)
for (const mk of ['weekMilestoneIncentive', 'monthMilestoneIncentive']) { for (const mk of ['weekMilestoneIncentive', 'monthMilestoneIncentive']) {
const inc = data[mk] const inc = data[mk]
if (inc && inc.incentiveName) { if (inc && inc.incentiveName) {
log(`[福利站] ${mk.startsWith('week') ? '周' : '月'}里程碑达成:${inc.incentiveName}`) log(`[福利站·${mk.startsWith('week') ? '周' : '月'}里程碑] 达成:${inc.incentiveName}`)
} }
} }
} else if (r.code === 14822150) { } else if (r.code === 14822150) {
log(`[福利站] 今日已签到(幂等码,本月 ${monthDays} 天)`) log(`[福利站·签到] 今日已签到(幂等码,本月 ${monthDays} 天)`)
this.step('福利站签到', 'skip', '今日已签') this.step('福利站·签到', 'skip', '今日已签')
} else { } else {
log(`[福利站] 签到异常 code:${r.code}`) log(`[福利站·签到] 异常 code:${r.code}`)
this.step('福利站签到', 'fail', `code ${r.code}`) this.step('福利站·签到', 'fail', `code ${r.code}`)
} }
} }
@@ -484,22 +484,22 @@ class QMClient {
const pick = ok[day % ok.length] const pick = ok[day % ok.length]
const r = await this.api('music.vipcgi.VipRenewalCouponCgi', 'ClaimVipRenewCoupon', { couponID: pick.couponID }) const r = await this.api('music.vipcgi.VipRenewalCouponCgi', 'ClaimVipRenewCoupon', { couponID: pick.couponID })
if (r.code === 0) { if (r.code === 0) {
log(`[礼券] ${pick.couponName} 领取成功 +${t502.taskReward || 10}心愿值`) log(`[福利站·礼券] ${pick.couponName} 领取成功 +${t502.taskReward || 10}心愿值`)
this.step('福利站礼券', 'done', pick.couponName) this.step('福利站·礼券', 'done', pick.couponName)
} else if (r.code === 14816772) { } else if (r.code === 14816772) {
log('[礼券] 今日已领取') log('[福利站·礼券] 今日已领取')
this.step('福利站礼券', 'skip', '今日已领') this.step('福利站·礼券', 'skip', '今日已领')
} else { } else {
log(`[礼券] code:${r.code}`) log(`[福利站·礼券] code:${r.code}`)
this.step('福利站礼券', 'warn', `code ${r.code}`) this.step('福利站·礼券', 'warn', `code ${r.code}`)
} }
} else { } else {
log(`[礼券] 券池今日无可领券(${infos.length} 张全非可领态)`) log(`[福利站·礼券] 券池今日无可领券(${infos.length} 张全非可领态)`)
this.step('福利站礼券', 'skip', '券池无可领') this.step('福利站·礼券', 'skip', '券池无可领')
} }
} else if (t502) { } else if (t502) {
log('[礼券] 今日已完成') log('[福利站·礼券] 今日已完成')
this.step('福利站礼券', 'skip', '今日已完成') this.step('福利站·礼券', 'skip', '今日已完成')
} }
// ---- 旅程激励:心愿值达标档位实时领取(ClaimIncentives 幂等)---- // ---- 旅程激励:心愿值达标档位实时领取(ClaimIncentives 幂等)----
@@ -508,19 +508,19 @@ class QMClient {
if (unlock.length) { if (unlock.length) {
const r = await this.api('music.vipcgi.StarWishLotteryCgi', 'ClaimIncentives', { seasonID: season }) const r = await this.api('music.vipcgi.StarWishLotteryCgi', 'ClaimIncentives', { seasonID: season })
if (r.code === 0) { if (r.code === 0) {
log(`[激励] 领取成功:${unlock.map(x => `${x.name}(${x.score}分)`).join('、')}`) log(`[福利站·激励] 领取成功:${unlock.map(x => `${x.name}(${x.score}分)`).join('、')}`)
this.step('旅程激励', 'done', unlock.map(x => x.name).join('、')) this.step('福利站·旅程激励', 'done', unlock.map(x => x.name).join('、'))
} else { } else {
log(`[激励] code:${r.code}`) log(`[福利站·激励] code:${r.code}`)
this.step('旅程激励', 'warn', `code ${r.code}`) this.step('福利站·旅程激励', 'warn', `code ${r.code}`)
} }
} else { } else {
const nxt = (idata.incentives || []).find(x => x.status === 5) const nxt = (idata.incentives || []).find(x => x.status === 5)
if (nxt) { if (nxt) {
log(`[激励] 心愿值${idata.currentScore || 0}/${nxt.score}(下档 ${nxt.name})`) log(`[福利站·激励] 心愿值${idata.currentScore || 0}/${nxt.score}(下档 ${nxt.name})`)
this.step('旅程激励', 'skip', `心愿值 ${idata.currentScore || 0}/${nxt.score}`) this.step('福利站·旅程激励', 'skip', `心愿值 ${idata.currentScore || 0}/${nxt.score}`)
} else { } else {
this.step('旅程激励', 'skip', '无待领档位') this.step('福利站·旅程激励', 'skip', '无待领档位')
} }
} }
@@ -534,15 +534,15 @@ class QMClient {
if (signedCnt >= need || status === 2 || status === 3) { if (signedCnt >= need || status === 2 || status === 3) {
const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'ClaimMilestoneIncentive', { month }) const r = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'ClaimMilestoneIncentive', { month })
if (r.code === 0) { if (r.code === 0) {
log(`[月里程碑] 领取:${target.incentiveName}`) log(`[福利站·月里程碑] 领取:${target.incentiveName}`)
this.step('月里程碑', 'done', target.incentiveName) this.step('福利站·月里程碑', 'done', target.incentiveName)
} else { } else {
log(`[月里程碑] code:${r.code}(${signedCnt} 天达标待领)`) log(`[福利站·月里程碑] code:${r.code}(${signedCnt} 天达标待领)`)
this.step('月里程碑', 'warn', `code ${r.code}(${signedCnt} 天达标)`) this.step('福利站·月里程碑', 'warn', `code ${r.code}(${signedCnt} 天达标)`)
} }
} else { } else {
log(`[月里程碑] ${signedCnt}/${need} 天(${target.incentiveName})`) log(`[福利站·月里程碑] ${signedCnt}/${need} 天(${target.incentiveName})`)
this.step('月里程碑', 'skip', `${signedCnt}/${need} 天`) this.step('福利站·月里程碑', 'skip', `${signedCnt}/${need} 天`)
} }
} }
@@ -554,18 +554,18 @@ class QMClient {
if (p.poolStatus === 3 && (g.availableStock || 0) > 0) { if (p.poolStatus === 3 && (g.availableStock || 0) > 0) {
const r = await this.api('music.vipcgi.VipRenewalMonthGiftCgi', 'ReceiveVipRenewalMonthlyGift', const r = await this.api('music.vipcgi.VipRenewalMonthGiftCgi', 'ReceiveVipRenewalMonthlyGift',
{ giftPoolType: p.poolType, tab: 'cjhy' }) { giftPoolType: p.poolType, tab: 'cjhy' })
if (r.code === 0) { log(`[月礼] 领取:${g.giftName}`); gotGifts.push(g.giftName) } if (r.code === 0) { log(`[福利站·月礼] 领取:${g.giftName}`); gotGifts.push(g.giftName) }
else { log(`[月礼] code:${r.code}`); noStock.push(`${g.giftName}(code ${r.code})`) } else { log(`[福利站·月礼] code:${r.code}`); noStock.push(`${g.giftName}(code ${r.code})`) }
} else if (p.poolStatus === 3) { } else if (p.poolStatus === 3) {
const ts = g.nextStockRefreshTime || 0 const ts = g.nextStockRefreshTime || 0
const nxt = ts ? new Date(ts * 1000).toTimeString().slice(0, 5) : '未知' const nxt = ts ? new Date(ts * 1000).toTimeString().slice(0, 5) : '未知'
log(`[月礼] ${g.giftName} 今日库存0(${nxt} 补货)`) log(`[福利站·月礼] ${g.giftName} 今日库存0(${nxt} 补货)`)
noStock.push(`${g.giftName}(库存0)`) noStock.push(`${g.giftName}(库存0)`)
} }
} }
if (gotGifts.length) this.step('月度好礼', 'done', gotGifts.join('、')) if (gotGifts.length) this.step('福利站·月礼', 'done', gotGifts.join('、'))
else if (noStock.length) this.step('月度好礼', 'skip', noStock.join('、')) else if (noStock.length) this.step('福利站·月礼', 'skip', noStock.join('、'))
else this.step('月度好礼', 'skip', '无可用礼品池') else this.step('福利站·月礼', 'skip', '无可用礼品池')
// ---- 臻享抽奖:有抽奖券才抽,抽到清零或失败即停 ---- // ---- 臻享抽奖:有抽奖券才抽,抽到清零或失败即停 ----
const dd = (await this.api('music.vipcgi.VipRenewalLotteryCgi', 'QueryVipRenewDrawInfo', {})).data || {} const dd = (await this.api('music.vipcgi.VipRenewalLotteryCgi', 'QueryVipRenewDrawInfo', {})).data || {}
@@ -575,18 +575,18 @@ class QMClient {
const r = await this.api('music.vipcgi.VipRenewalLotteryCgi', 'DrawVipRenewActivity', { lotteryPageType: 1 }) const r = await this.api('music.vipcgi.VipRenewalLotteryCgi', 'DrawVipRenewActivity', { lotteryPageType: 1 })
if (r.code === 0) { if (r.code === 0) {
const prize = ((r.data || {}).prize || {}).prizeName const prize = ((r.data || {}).prize || {}).prizeName
log(`[抽奖] ${prize || '成功'}`) log(`[福利站·抽奖] ${prize || '成功'}`)
prizes.push(prize || '成功') prizes.push(prize || '成功')
draws -= 1 draws -= 1
} else { } else {
log(`[抽奖] code:${r.code},停止`) log(`[福利站·抽奖] code:${r.code},停止`)
break break
} }
} }
if (prizes.length) this.step('臻享抽奖', 'done', `${prizes.length} 次:${prizes.join('、')}`) if (prizes.length) this.step('福利站·臻享抽奖', 'done', `${prizes.length} 次:${prizes.join('、')}`)
else this.step('臻享抽奖', 'skip', '无抽奖券') else this.step('福利站·臻享抽奖', 'skip', '无抽奖券')
log(`[福利站] ${ymd} 巡检完成`) log(`[福利站] ${ymd} 巡检完成(签到/礼券/激励/里程碑/月礼/抽奖)`)
return gained return gained
} }
@@ -601,15 +601,16 @@ class QMClient {
* 不做的:金币抽奖(drawCoinLottery,消耗金币抽奖券)、红包雨、每日任务列表、 * 不做的:金币抽奖(drawCoinLottery,消耗金币抽奖券)、红包雨、每日任务列表、
* 定时金币 —— 那些要么花金币要么要伪造播放/浏览行为,风险与收益不成比例。 */ * 定时金币 —— 那些要么花金币要么要伪造播放/浏览行为,风险与收益不成比例。 */
async coinCenter() { async coinCenter() {
if (!COIN_SIGN) { this.step('金币签到', 'skip', 'QM_COIN_SIGN=off'); return 0 } if (!COIN_SIGN) { this.step('金币·签到', 'skip', 'QM_COIN_SIGN=off'); return 0 }
log(`[金币·签到] 开始:ActID=${COIN_ACT_ID} ScenesID=${COIN_SCENE_ID}`)
let st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID) let st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID)
if (!st.ok) { if (!st.ok) {
// ActID 会随活动换季失效。这里不打 fail(不是登录态问题,也不是脚本 bug), // ActID 会随活动换季失效。这里不打 fail(不是登录态问题,也不是脚本 bug),
// 用 warn 并把「改哪个 env」写进 detail,看到通知就知道怎么修。 // 用 warn 并把「改哪个 env」写进 detail,看到通知就知道怎么修。
log(`[金币] 状态查询失败 code=${st.sCode}/${st.tCode} ret=${st.sRet}/${st.tRet}`) log(`[金币·签到] 状态查询失败 code=${st.sCode}/${st.tCode} ret=${st.sRet}/${st.tRet}`)
log(`[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID`) log(`[金币·签到] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID`)
this.step('金币签到', 'warn', `查询失败 code ${st.sCode}/${st.tCode}(可能 ActID 过期)`) this.step('金币·签到', 'warn', `查询失败 code ${st.sCode}/${st.tCode}(可能 ActID 过期)`)
return 0 return 0
} }
@@ -620,14 +621,14 @@ class QMClient {
const okSign = d.code === 0 && req.code === 0 && Number(data.retCode ?? 0) === 0 const okSign = d.code === 0 && req.code === 0 && Number(data.retCode ?? 0) === 0
&& data.Info && data.Info.IsSignIn && data.Info && data.Info.IsSignIn
if (!okSign) { if (!okSign) {
log(`[金币] 签到失败 code=${req.code} ret=${data.retCode}`) log(`[金币·签到] 签到失败 code=${req.code} ret=${data.retCode}`)
this.step('金币签到', 'warn', `签到失败 code ${req.code} ret ${data.retCode}`) this.step('金币·签到', 'warn', `签到失败 code ${req.code} ret ${data.retCode}`)
return 0 return 0
} }
signedNow = true signedNow = true
log('[金币] 签到成功') log('[金币·签到] 签到成功')
st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID) st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID)
if (!st.ok) { this.step('金币签到', 'done', '已签(状态复查失败)'); return 0 } if (!st.ok) { this.step('金币·签到', 'done', '已签(状态复查失败)'); return 0 }
} }
// 连续签到第几天 → 用于在 TaskList 里定位「今天该领的那个」 // 连续签到第几天 → 用于在 TaskList 里定位「今天该领的那个」
@@ -649,27 +650,28 @@ class QMClient {
// 100004 = 已领过(幂等),算成功 // 100004 = 已领过(幂等),算成功
const okAward = d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004) const okAward = d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)
if (okAward) { if (okAward) {
log(`[金币] ${signedNow ? '签到并领取' : '补领'}成功${rewardName ? `:${rewardName}` : ''}`) log(`[金币·签到] ${signedNow ? '签到并领取' : '补领'}成功${rewardName ? `:${rewardName}` : ''}`)
this.step('金币签到', 'done', `${signedNow ? '已签 + ' : ''}${rewardName || '已领'}`) this.step('金币·签到', 'done', `${signedNow ? '已签 + ' : ''}${rewardName || '已领'}`)
} else { } else {
log(`[金币] 领奖失败 code=${req.code} ret=${ret}`) log(`[金币·签到] 领奖失败 code=${req.code} ret=${ret}`)
this.step('金币签到', 'warn', `已签,领奖失败 ret ${ret}`) this.step('金币·签到', 'warn', `已签,领奖失败 ret ${ret}`)
} }
} else if (st.info.IsSignIn) { } else if (st.info.IsSignIn) {
const nth = day ? `连续第 ${day} 天` : '' const nth = day ? `连续第 ${day} 天` : ''
log(`[金币] 今日已签到${nth ? `(${nth})` : ''}`) log(`[金币·签到] 今日已签到${nth ? `(${nth})` : ''}`)
this.step('金币签到', 'skip', `今日已签${nth ? `(${nth})` : ''}`) this.step('金币·签到', 'skip', `今日已签${nth ? `(${nth})` : ''}`)
} else { } else {
log('[金币] 签到状态未确认') log('[金币·签到] 签到状态未确认')
this.step('金币签到', 'warn', '签到状态未确认') this.step('金币·签到', 'warn', '签到状态未确认')
} }
// ---- 金币抽奖签到(另一个 ActID,独立活动)---- // ---- 金币抽奖签到(另一个 ActID,独立活动)----
// 只做「签到 + 领奖」,不碰 drawCoinLottery(那个要消耗抽奖券去抽,属于花金币) // 只做「签到 + 领奖」,不碰 drawCoinLottery(那个要消耗抽奖券去抽,属于花金币)
try { try {
log(`[金币·抽奖签到] 开始:ActID=${LOTTERY_ACT_ID}`)
let ls = await this.coinSignState(LOTTERY_ACT_ID, '') let ls = await this.coinSignState(LOTTERY_ACT_ID, '')
if (!ls.ok) { if (!ls.ok) {
log(`[金币抽奖] 状态查询失败 code=${ls.sCode}/${ls.tCode},跳过`) log(`[金币·抽奖签到] 状态查询失败 code=${ls.sCode}/${ls.tCode},跳过`)
} else { } else {
let lotterySigned = false let lotterySigned = false
if (!ls.info.IsSignIn) { if (!ls.info.IsSignIn) {
@@ -688,23 +690,23 @@ class QMClient {
const req = d.req_0 || {}, data = req.data || {} const req = d.req_0 || {}, data = req.data || {}
const ret = Number(data.retCode ?? 0) const ret = Number(data.retCode ?? 0)
if (d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)) { if (d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)) {
this.step('金币抽奖签到', 'done', lotterySigned ? '已签 + 已领' : '已领') this.step('金币·抽奖签到', 'done', lotterySigned ? '已签 + 已领' : '已领')
} else { } else {
this.step('金币抽奖签到', 'warn', `领奖失败 ret ${ret}`) this.step('金币·抽奖签到', 'warn', `领奖失败 ret ${ret}`)
} }
} else if (lotterySigned) { } else if (lotterySigned) {
// 签了但当场没查到可领项(接口有延迟)→ 也记一笔,别让「做过的事」在通知里消失 // 签了但当场没查到可领项(接口有延迟)→ 也记一笔,别让「做过的事」在通知里消失
this.step('金币抽奖签到', 'done', '已签(奖励待下次领)') this.step('金币·抽奖签到', 'done', '已签(奖励待下次领)')
} else if (ls.ok && ls.info.IsSignIn) { } else if (ls.ok && ls.info.IsSignIn) {
this.step('金币抽奖签到', 'skip', '今日已签') this.step('金币·抽奖签到', 'skip', '今日已签')
} else { } else {
this.step('金币抽奖签到', 'skip', '无需处理') this.step('金币·抽奖签到', 'skip', '无需处理')
} }
} }
} catch (e) { } catch (e) {
if (e instanceof QmDead) throw e if (e instanceof QmDead) throw e
log(`[金币抽奖] 异常: ${e.message}`) log(`[金币·抽奖签到] 异常: ${e.message}`)
this.step('金币抽奖签到', 'warn', e.message) this.step('金币·抽奖签到', 'warn', e.message)
} }
return 0 // 金币不计入成长值合计 return 0 // 金币不计入成长值合计
@@ -801,7 +803,7 @@ class QMClient {
return 0 // 不产成长值,不计入 total return 0 // 不产成长值,不计入 total
} }
/** 单账号全流程:续期 → 签到 → 任务动作 → 全量领取 → 福利站 → 分享卡 */ /** 单账号全流程:续期 → 任务中心 → 福利站 → 金币中心 → 分享卡 */
async run() { async run() {
const out = { total: 0, dead: false, cardUrl: '' } const out = { total: 0, dead: false, cardUrl: '' }
try { try {
@@ -810,53 +812,60 @@ class QMClient {
const signed = await this.signQry() const signed = await this.signQry()
log(`[状态] 签到:${signed ? '已签' : '未签'} 挂件:${st['8']} 装扮:${st['9']} 听书:${st['30']} 节目:${st['20']}`) log(`[状态] 签到:${signed ? '已签' : '未签'} 挂件:${st['8']} 装扮:${st['9']} 听书:${st['30']} 节目:${st['20']}`)
if (signed) { log('[签到] 今日已签到'); this.step('任务中心签到', 'skip', '今日已签') } // 三套签到体系各自独立,用段落标题分开 —— 日志里一眼能看出哪套做了什么
log('\n───── ① 任务中心(成长值)─────')
if (signed) { log('[任务中心·签到] 今日已签到'); this.step('任务中心·签到', 'skip', '今日已签') }
else out.total += await this.signDaily() else out.total += await this.signDaily()
// 动作语义:N=执行动作 / W=只领(能否领取决于行为已计次)/ Y=零请求(防风控) // 动作语义:N=执行动作 / W=只领(能否领取决于行为已计次)/ Y=零请求(防风控)
if (st['8'] === 'N') await this.pendant() if (st['8'] === 'N') await this.pendant()
else this.step('挂件', 'skip', '今日无需动作') else this.step('任务中心·挂件', 'skip', '今日无需动作')
if (st['9'] === 'N') await this.cosmetic() if (st['9'] === 'N') await this.cosmetic()
else this.step('装扮', 'skip', '今日无需动作') else this.step('任务中心·装扮', 'skip', '今日无需动作')
if (st['30'] === 'N') { if (st['30'] === 'N') {
await this.api('music.lvz.LevelConfigSvr', 'RecordBehavior', { ID: '30' }) await this.api('music.lvz.LevelConfigSvr', 'RecordBehavior', { ID: '30' })
log('[听书] 行为上报完成') log('[任务中心·听书] 行为上报完成')
this.step('听书行为', 'done', '已上报') this.step('任务中心·听书', 'done', '已上报')
} else { } else {
this.step('听书行为', 'skip', '今日无需动作') this.step('任务中心·听书', 'skip', '今日无需动作')
} }
if (st['20'] === 'N' || !signed) { if (st['20'] === 'N' || !signed) {
// 任务20跨天不重置:当日未签时 20:Y 是昨日残留,仍挂开始标记 // 任务20跨天不重置:当日未签时 20:Y 是昨日残留,仍挂开始标记
const r = (await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' })).data || {} const r = (await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' })).data || {}
if (Number(r.code) === 1) { if (Number(r.code) === 1) {
log(PLAY_REPORT log(PLAY_REPORT
? '[节目] 开始标记已挂(播放上报未实现于本版,App 内正常收听计入)' ? '[任务中心·节目] 开始标记已挂(播放上报未实现于本版,App 内正常收听计入)'
: '[节目] 开始标记已挂(播放上报未启用,App 内正常收听计入)') : '[任务中心·节目] 开始标记已挂(播放上报未启用,App 内正常收听计入)')
if (r.count != null) log(`[节目] 当前播放计数: ${r.count}`) if (r.count != null) log(`[任务中心·节目] 当前播放计数: ${r.count}`)
this.step('节目开始标记', 'done', r.count != null ? `计数 ${r.count}` : '已挂') this.step('任务中心·节目', 'done', r.count != null ? `计数 ${r.count}` : '已挂')
} else { } else {
log(`[节目] StartTask 返回 code:${r.code},无法开始`) log(`[任务中心·节目] StartTask 返回 code:${r.code},无法开始`)
this.step('节目开始标记', 'warn', `code ${r.code}`) this.step('任务中心·节目', 'warn', `code ${r.code}`)
} }
} else { } else {
this.step('节目开始标记', 'skip', '今日无需动作') this.step('任务中心·节目', 'skip', '今日无需动作')
} }
const stNow = await this.state() const stNow = await this.state()
out.total += await this.claimAll(stNow) out.total += await this.claimAll(stNow)
log('\n───── ② 会员福利站(成长值 + 权益)─────')
try { out.total += await this.vipStation() } catch (e) { try { out.total += await this.vipStation() } catch (e) {
if (e instanceof QmDead) throw e // 失效语义不被福利站吞掉 if (e instanceof QmDead) throw e // 失效语义不被福利站吞掉
log(`[福利站] 运行异常: ${e.message}`) log(`[福利站] 运行异常: ${e.message}`)
this.step('福利站', 'fail', e.message) this.step('福利站', 'fail', e.message)
} }
// 金币中心:独立协议(musicu.fcg + 小程序 comm),给金币不给成长值。 // 金币中心:独立协议(musicu.fcg + 小程序 comm),给金币不给成长值。
// 放在福利站之后、分享卡之前 —— 金币体系挂了不影响前两套,也不拖累取卡。 // 放在福利站之后、分享卡之前 —— 金币体系挂了不影响前两套,也不拖累取卡。
log('\n───── ③ 金币中心(金币,不计入成长值)─────')
try { await this.coinCenter() } catch (e) { try { await this.coinCenter() } catch (e) {
if (e instanceof QmDead) throw e if (e instanceof QmDead) throw e
log(`[金币] 运行异常: ${e.message}`) log(`[金币·签到] 运行异常: ${e.message}`)
this.step('金币签到', 'fail', e.message) this.step('金币·签到', 'fail', e.message)
} }
log('\n───── ④ VIP 体验券(分享卡)─────')
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}`) // 接口改版/风控不拖垮主流程
@@ -865,7 +874,8 @@ class QMClient {
out.cardUrl = this.shareCardUrl || '' out.cardUrl = this.shareCardUrl || ''
out.steps = this.steps out.steps = this.steps
out.verdict = this.verdict() out.verdict = this.verdict()
log(`[QQ音乐] 本次新增成长值合计: ${out.total}`) log(`\n[QQ音乐] 本次新增成长值合计: ${out.total}`)
log(`[QQ音乐] 金币中心为独立体系,不产成长值(明细见上方 ③ 段)`)
} catch (e) { } catch (e) {
if (e instanceof QmDead) { if (e instanceof QmDead) {
out.dead = true out.dead = true
@@ -984,30 +994,47 @@ function pushCardResult(results) {
* 全部 skip 的项折叠成一行,避免通知被十几行「今日已完成」淹没。 */ * 全部 skip 的项折叠成一行,避免通知被十几行「今日已完成」淹没。 */
function renderAccount(uin, r) { function renderAccount(uin, r) {
const icon = { done: '✓', warn: '△', fail: '✗', skip: '·' } const icon = { done: '✓', warn: '△', fail: '✗', skip: '·' }
// 金币体系的 step 名字都以「金币」开头 —— 单独成组,别混进成长值里让人误读 // step 名统一成「体系·子项」两段(2026-11-07 改),按体系分组渲染 ——
const isCoin = s => /^金币/.test(s.name) // 通知里必须一眼看出「哪个签到完成了」,不能把所有 done 平铺成一堆。
const coin = r.steps.filter(isCoin) const group = s => {
const rest = r.steps.filter(s => !isCoin(s)) const m = /^(任务中心|福利站|金币|分享卡)/.exec(s.name)
const did = rest.filter(s => s.status === 'done') return m ? m[1] : '其他'
const warn = rest.filter(s => s.status === 'warn' || s.status === 'fail') }
const skip = rest.filter(s => s.status === 'skip') const line = s => ` ${icon[s.status] || '·'} ${s.name.replace(/^[^·]+·/, '')}${s.detail ? ` ${s.detail}` : ''}`
const lines = [`${r.verdict === 'done' ? '🟢' : '🟡'} ${uin}:成长值 +${r.total}`] 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}` : ''}`) const ORDER = [
['任务中心', '📈 任务中心(成长值)'],
['福利站', '🎁 会员福利站(成长值 + 权益)'],
['金币', '💰 金币中心(金币,不计成长值)'],
['分享卡', '🎫 VIP 体验券'],
['其他', '📌 其他'],
]
for (const [key, title] of ORDER) {
const items = r.steps.filter(s => group(s) === key)
if (!items.length) continue
const done = items.filter(s => s.status === 'done')
const bad = items.filter(s => s.status === 'warn' || s.status === 'fail')
const skip = items.filter(s => s.status === 'skip')
// 整个体系都没做事(全 skip)→ 收成一行,别占屏
if (!done.length && !bad.length && skip.length) {
lines.push(` ${title}:${skip.map(s => s.name.replace(/^[^·]+·/, '')).join('、')} 今日无需处理`)
continue
}
lines.push(` ${title}`)
for (const s of done) lines.push(line(s))
for (const s of bad) lines.push(line(s))
if (skip.length) { if (skip.length) {
lines.push(` ${icon.skip} 无需处理 ${skip.length} 项:${skip.map(s => s.name).join('、')}`) lines.push(` · 无需处理 ${skip.length} 项:${skip.map(s => s.name.replace(/^[^·]+·/, '')).join('、')}`)
} }
// 金币单独一行:它不加成长值,混在上面会被当成成长值的一部分
if (coin.length) {
const doneCoin = coin.filter(s => s.status === 'done')
const badCoin = coin.filter(s => s.status === 'warn' || s.status === 'fail')
if (doneCoin.length) {
lines.push(` 💰 金币:${doneCoin.map(s => `${s.name.replace(/^金币/, '') || '签到'}${s.detail ? ` ${s.detail}` : ''}`).join(' / ')}`)
} }
for (const s of badCoin) lines.push(` ${icon[s.status]} ${s.name}${s.detail ? ` ${s.detail}` : ''}`)
if (!doneCoin.length && !badCoin.length) lines.push(` · 金币:今日已签`) if (r.cardUrl) lines.push(` 🔗 领卡链接 ${r.cardUrl}`)
}
if (r.cardUrl) lines.push(` 🎁 分享卡 ${r.cardUrl}`)
return lines.join('\n') return lines.join('\n')
} }

View File

@@ -131,10 +131,10 @@ async function runCase(name, script, want) {
ok(`返回 0(金币不计入成长值)`, t === 0, String(t)) ok(`返回 0(金币不计入成长值)`, t === 0, String(t))
for (const [k, v] of Object.entries(want)) { for (const [k, v] of Object.entries(want)) {
if (k === 'status') { if (k === 'status') {
const s = c.steps.find(x => x.name === '金币签到') 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}`))) ok(`step 存在且 status=${v}`, s && s.status === v, JSON.stringify(c.steps.map(x => `${x.name}:${x.status}`)))
} else if (k === 'has') { } else if (k === 'has') {
const s = c.steps.find(x => x.name === '金币签到') const s = c.steps.find(x => x.name === '金币·签到')
ok(`detail 含「${v}」`, s && s.detail.includes(v), s && s.detail) ok(`detail 含「${v}」`, s && s.detail.includes(v), s && s.detail)
} else if (k === 'calls') { } else if (k === 'calls') {
ok(`请求次数 = ${v}`, calls.length === v, String(calls.length)) ok(`请求次数 = ${v}`, calls.length === v, String(calls.length))
@@ -223,7 +223,7 @@ async function runCase(name, script, want) {
} }
const c = mkClient() const c = mkClient()
await c.coinCenter() await c.coinCenter()
const s = c.steps.find(x => x.name === '金币抽奖签到') const s = c.steps.find(x => x.name === '金币·抽奖签到')
ok('金币抽奖签到有独立 step', !!s, JSON.stringify(c.steps.map(x => x.name))) ok('金币抽奖签到有独立 step', !!s, JSON.stringify(c.steps.map(x => x.name)))
} }

113
qqmusic/test_render.js Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* test_render.js —— 通知正文渲染的分组与「哪个签到完成了」可见性
*
* 2026-11-07 需求:执行日志/通知里必须一眼看出「是什么签到完成了」。
* 这个测试冻结 renderAccount 的分组契约:
* 1. 三套体系各自成组,组标题带 emoji
* 2. 组内每条都带「子项名 + detail」,不能只剩 detail
* 3. 整组全 skip → 收成一行
* 4. 金币组明确标注「不计成长值」,不与成长值混算
* 5. 分享卡链接单独一行
*
* 用法: node qqmusic/test_render.js
*/
'use strict'
const { renderAccount } = require('./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 : ''}`) }
}
const has = (out, s, label) => ok(label || `含「${s}」`, out.includes(s), out)
const not = (out, s, label) => ok(label || `不含「${s}」`, !out.includes(s), out)
/* ── 1. 三套体系都有收获 ── */
console.log('\n── 1. 三套体系都有收获 ──')
const r1 = renderAccount('1006764347', {
total: 45, verdict: 'done', cardUrl: '',
steps: [
{ name: '任务中心·签到', status: 'done', detail: '+15' },
{ name: '任务中心·挂件', status: 'done', detail: '已佩戴' },
{ name: '任务中心·装扮', status: 'skip', detail: '' },
{ name: '福利站·签到', status: 'done', detail: '15成长值(本月 8 天)' },
{ name: '福利站·礼券', status: 'done', detail: '+10 心愿值' },
{ name: '福利站·月礼', status: 'skip', detail: '' },
{ name: '金币·签到', status: 'done', detail: '已签 + 30 金币' },
{ name: '金币·抽奖签到', status: 'done', detail: '已签 + 已领' },
],
})
console.log(r1.split('\n').map(l => ' │ ' + l).join('\n'))
has(r1, '📈 任务中心', '有任务中心组标题')
has(r1, '🎁 会员福利站', '有福利站组标题')
has(r1, '💰 金币中心', '有金币中心组标题')
has(r1, '不计成长值', '金币组标注「不计成长值」')
has(r1, '✓ 签到 +15', '任务中心签到带子项名 + detail')
has(r1, '✓ 礼券 +10 心愿值', '福利站礼券带子项名 + detail')
has(r1, '✓ 签到 已签 + 30 金币', '金币签到带子项名 + detail')
has(r1, '✓ 抽奖签到', '金币抽奖签到也在')
has(r1, '无需处理 1 项:装扮', '任务中心 skip 收成一行')
has(r1, '无需处理 1 项:月礼', '福利站 skip 收成一行')
// 关键:不能出现「只有 detail 没有名字」的行
const detailOnly = r1.split('\n').filter(l => /^\s+[✓△✗·] (已签|\+|已领|已佩戴)/.test(l))
ok('没有「只有 detail 没有子项名」的行', detailOnly.length === 0, JSON.stringify(detailOnly))
/* ── 2. 整组全 skip → 收成一行 ── */
console.log('\n── 2. 金币全已签(整组 skip)──')
const r2 = renderAccount('1006764347', {
total: 30, verdict: 'done', cardUrl: '',
steps: [
{ name: '任务中心·签到', status: 'done', detail: '+15' },
{ name: '金币·签到', status: 'skip', detail: '今日已签(连续第 7 天)' },
{ name: '金币·抽奖签到', status: 'skip', detail: '今日已签' },
],
})
console.log(r2.split('\n').map(l => ' │ ' + l).join('\n'))
has(r2, '💰 金币中心', '金币组还在')
has(r2, '今日无需处理', '整组 skip 收成一行')
has(r2, '签到、抽奖签到', '两个子项都列出来了')
not(r2, '✓ 签到 今日已签', '没有多余的成功行')
/* ── 3. 金币 ActID 过期 → warn 单独列 ── */
console.log('\n── 3. 金币 ActID 过期 ──')
const r3 = renderAccount('1006764347', {
total: 30, verdict: 'warn', cardUrl: '',
steps: [
{ name: '任务中心·签到', status: 'done', detail: '+15' },
{ name: '金币·签到', status: 'warn', detail: '查询失败 code -1/-1(可能 ActID 过期)' },
],
})
console.log(r3.split('\n').map(l => ' │ ' + l).join('\n'))
has(r3, '🟡', 'verdict=warn 用黄脸')
has(r3, '△ 签到 查询失败', 'warn 带子项名 + detail')
has(r3, 'ActID', 'detail 里能看出是 ActID 问题')
/* ── 4. 分享卡链接 ── */
console.log('\n── 4. 分享卡链接 ──')
const r4 = renderAccount('1006764347', {
total: 30, verdict: 'done', cardUrl: 'https://y.qq.com/m/vip/experience_card/dist/index.html?cardid=ABC',
steps: [{ name: '分享卡', status: 'done', detail: '新建 1 张,还能被领 3 次' }],
})
console.log(r4.split('\n').map(l => ' │ ' + l).join('\n'))
has(r4, '🔗 领卡链接 https://y.qq.com', '链接单独一行且有前缀')
has(r4, '🎫 VIP 体验券', '有分享卡组标题')
/* ── 5. 空 steps 不崩 ── */
console.log('\n── 5. 空 steps ──')
const r5 = renderAccount('1006764347', { total: 0, verdict: 'done', cardUrl: '', steps: [] })
ok('不抛错', typeof r5 === 'string')
has(r5, '成长值 +0', '首行仍有成长值')
/* ── 6. 未知前缀落到「其他」组 ── */
console.log('\n── 6. 未知前缀 → 其他组 ──')
const r6 = renderAccount('1006764347', {
total: 0, verdict: 'done', cardUrl: '',
steps: [{ name: '某个新功能', status: 'done', detail: 'ok' }],
})
has(r6, '📌 其他', '落到「其他」组')
has(r6, '某个新功能', '名字保留完整(没有 · 可切)')
console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`)
process.exit(fail === 0 ? 0 : 1)