diff --git a/qqmusic/README.md b/qqmusic/README.md index 57a06dd..95cd091 100644 --- a/qqmusic/README.md +++ b/qqmusic/README.md @@ -140,6 +140,45 @@ cron: 1 0,6 * * * - `https.request` 的选项用 `host:`(Node 的 `hostname` 别名)而不是 `hostname:` - cron 注解独占一行 +## 通知内容 + +每轮跑完推送一条,格式固定: + +``` +✅ QQ音乐签到 · 全部完成 + +🟢 123456789:成长值 +35 + ✓ 任务中心签到 +15 + ✓ 挂件 retCode 0 + ✓ 领奖·头像挂件 +10 + ✓ 福利站签到 5成长值(本月 12 天) + △ 领奖·收听节目 未计次,待 App 内收听 + · 无需处理 3 项:福利站礼券、月里程碑、分享卡 + 🎁 分享卡 https://y.qq.com/m/vip/experience_card/dist/index.html?cardid=xxx + +——— +轮次: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底) +节目任务(20)依赖 App 内正常收听计入(播放上报未启用) +时间: 2026/11/4 23:10:05 +``` + +**标题就是结论**:`✅ 全部完成` / `⚠️ 有未完成项`,不用点开就知道要不要管。 + +每个账号下分四类: + +| 符号 | 含义 | +|---|---| +| `✓` | 做到了(带结果,如 `+15`、`retCode 0`) | +| `△` | 部分完成 / 留待下次(如任务20未计次、写锁) | +| `✗` | 失败(接口报错、code 非预期) | +| `·` | 无需处理(今日已完成 / 未开放),**折叠成一行**,不占版面 | + +判定规则:有任何 `✗` → 标题变 `⚠️`;只有 `△` → 仍算完成(有遗留但不影响); +全 `✓`/`·` → `✅`。登录态失效单独用 🔴 并直接给处理指引,不进明细列表。 + +明细来源是 `QMClient.steps`:每个动作调一次 `step(name, status, detail)`, +`verdict()` 汇总。新增任务时记得补一句 `step()`,否则它在通知里不可见。 + ## 任务覆盖 | 层 | 内容 | 风险 | diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index 2c9bd42..1c85b9f 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -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