Files
ql-scripts/qqmusic/scrip_qqmusic_checkin.js
zhuhjay d081ac66da 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 名断言同步改成新名(金币·签到 / 金币·抽奖签到)。
2026-11-07 18:58:15 +08:00

1122 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// cron: 1 0,6 * * *
// name: QQ音乐自动签到
/**
* QQ音乐会员成长值 + 会员福利站 自动签到(青龙 Node 零依赖)
*
* 上面两行是青龙订阅自动建任务用的元数据注解,格式约定见仓库根 README。
* 定时说明: 00:01 + 06:02 双轮(任务20跨天滚动 + 福利站翻新兜底)。
*
* 文件名前缀 scrip_ 是订阅白名单要求(ql repo 的 whitelist 参数 = "scrip_"),
* 不带前缀的文件不会被青龙订阅拉取。
*
* 协议底座: u6.y.qq.com/cgi-bin/musics.fcg + zzc 签名 + qm_keyst cookie
* - zzc / time33 实现与 LDP924/auto-checkin qmcommon.py 逐字节同源(sha1 重组 + 置乱表 + b64 去尾)
* - 凭证: 浏览器 y.qq.com 抓 qm_keyst + uin 一次即可;每日经
* QQConnectLogin.LoginServer.QQLogin 续期(旧 key 不吊销,可无限续),新 key 写缓存
* - 缓存目录默认 /ql/data/qqmusic_cache(青龙),在订阅仓库之外 —— 订阅 git pull /
* reset --hard 都不触碰(git 从不删除 untracked 文件),本地开发回落 qqmusic/.cache/
*
* 环境变量:
* SH_QQMUSIC_KEY 多账号: 青龙同名 env 多条,注入自动 & 连接;单条格式 <uin>#<qm_keyst>
* 也接受整串 cookie(含 qm_keyst= 与 uin=o... 的原始形态)
* QM_KEY/QM_UIN 兼容单账号配置(SH_QQMUSIC_KEY 优先)
* QM_CACHE_DIR key 缓存目录覆盖(默认规则见上)
* QM_PLAY_REPORT 任务20播放上报开关(默认 off;伪造统计数据属中风险,
* 且需播放 XML 模板,本版未实现,仅挂 StartTask 标记)
* QM_SHARE_CARD VIP 体验券(分享卡)开关,默认 off 只读探测不消耗配额。
* on = 配额有剩余且本月卡已领完时自动 CreatCard,输出可分享的领取链接。
* 卡是「赠送给非会员好友」的 3 天豪华绿钻,自己领不了自己的卡。
*/
'use strict'
const fs = require('fs')
const path = require('path')
const https = require('https')
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
const POLL_PROBE_MS = 15000 // 任务20 30005 时的一次性只读探测
const PLAY_REPORT = String(process.env.QM_PLAY_REPORT || '').toLowerCase() === 'on'
// VIP 体验券(分享卡):默认只读探测。on 才在「配额有剩余 + 本月卡已用完」时 CreatCard。
const SHARE_CARD = String(process.env.QM_SHARE_CARD || '').toLowerCase() === 'on'
// 分享落地页:cardid 由 CreatCard 返回,好友打开后自己点领取(非会员自然月限领 1 张)
const CARD_LANDING = 'https://y.qq.com/m/vip/experience_card/dist/index.html'
// 取卡结果回传地址(平台侧 /api/hooks/card 或自建 n8n/Server酱 中转)。置空则只打印到日志。
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 复制顺序是三步 ——
* ① cp -f $file_notify_js "${dir_scripts}/${uniq_path}" 面板自带的 sendNotify.js
* ② cp -f <仓库依赖文件> "${dir_scripts}/${uniq_path}/" 仓库里的 sendNotify.js(覆盖①)
* ③ cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" /ql/data/deps/*(再覆盖②)
* $dir_dep = /ql/data/deps,share.sh:228 初始化时往里面拷了一份面板自带的 notify.js。
* ③ 排在最后,所以仓库里的 sendNotify.js 每轮都被覆盖,改它永远不生效。
*
* common.js 不在 deps 里(deps 只有 notify.py 和 sendNotify.js),所以把通知实现放
* 那里是安全的,且后续任何脚本都能 require('../common') 直接用。 */
const { notify } = require('../common')
function time33(s) {
let h = 5381
for (let i = 0; i < s.length; i++) {
h += (h << 5) + s.charCodeAt(i)
h &= 0xFFFFFFFF
}
return h & 0x7FFFFFFF
}
/** zzc 签名:sha1 大写 → 两个下标重组段 + 置乱表异或 b64 去尾,固定 45 字符小写 */
function zzc(payload) {
const h = crypto.createHash('sha1').update(payload, 'utf8').digest('hex').toUpperCase()
const p1 = [23, 14, 6, 36, 16, 7, 19].map(i => h[i]).join('')
const p2 = [16, 1, 32, 12, 19, 27, 8, 5].map(i => h[i]).join('')
const raw = Buffer.from(SCRAMBLE.map((v, i) => v ^ parseInt(h.substr(i * 2, 2), 16)))
const mid = raw.toString('base64').replace(/[\/+=]/g, '')
return ('zzc' + p1 + mid + p2).toLowerCase()
}
/** Node 内置 https,零外部依赖(不依赖 node_modules 是否被装好)
* 两个坑(2026-11-04 真机踩过,任务名被建成 "u.hostname,"、cron 被随机成 "18 21 * * *"):
* 1. 本文件任何位置都不要出现「name + 冒号」的连写 —— 青龙订阅自动建任务时用不锚定的
* 正则抓任务名,对象字段(https 选项里的 host 与 name 两段)会被当成任务名。
* 所以下面选项用 `host:`(https.request 支持,是 hostname 的别名)。
* 2. 定时注解必须写成「cron + 冒号」且独占一行 —— 旧版用不锚定正则抓,
* 写成「cron 1 0,6 * * *」(无冒号)匹配不到,任务会落到随机 fallback。 */
function request(method, urlStr, headers = {}, body = null, timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr)
const req = https.request({
host: u.hostname,
port: 443,
path: u.pathname + u.search,
method,
headers: {
...headers,
...(body != null ? { 'Content-Length': Buffer.byteLength(body) } : {}),
},
timeout: timeoutMs,
}, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }))
})
req.on('timeout', () => req.destroy(new Error('request timeout')))
req.on('error', reject)
if (body != null) req.write(body)
req.end()
})
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
const log = (...a) => console.log(...a)
/** 登录态失效(协议 code 2000)→ 本账号停止,提示重抓 */
class QmDead extends Error {}
/* ---------------- 缓存:key 滚动续期产物,存仓库外,订阅更新免疫 ---------------- */
function cacheDir() {
if (process.env.QM_CACHE_DIR) return process.env.QM_CACHE_DIR
for (const base of ['/ql/data', '/ql']) { // 青龙容器数据根(新版/旧版)
try { if (fs.existsSync(base)) return path.join(base, 'qqmusic_cache') } catch { /* ignore */ }
}
return path.join(__dirname, '.cache') // 本地开发回落(已 gitignore)
}
/* ---------------- 客户端 ---------------- */
class QMClient {
constructor(uin, key, cacheFile) {
this.uin = String(uin)
this.cacheFile = cacheFile
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() {
try {
const d = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8'))
if (d && typeof d.key === 'string' && d.key.length > 10) {
this.key = d.key
if (d.openid && d.access_token) this.rescue = { openid: d.openid, access_token: d.access_token }
}
} catch { /* 无缓存/坏缓存按 env 兜底 */ }
}
saveCache() {
try {
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true })
fs.writeFileSync(this.cacheFile, JSON.stringify({
key: this.key, openid: this.rescue.openid, access_token: this.rescue.access_token,
savedAt: new Date().toISOString(),
}, null, 2))
} catch (e) { log(`[缓存] 写入失败(不影响本轮): ${e.message}`) }
}
cookie() {
const k = this.key
return `qm_keyst=${k}; qqmusic_key=${k}; p_lskey=${k}; uin=o${this.uin}`
}
headers() {
return {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': this.cookie(),
'Referer': 'https://y.qq.com/',
'Origin': 'https://y.qq.com',
'User-Agent': UA,
}
}
/** 通用 u6 调用:紧凑 JSON + zzc 签名,返回 req_0 数据;code 2000 抛 QmDead */
async api(module, method, param, commOverride) {
const comm = commOverride || {
g_tk: time33(this.key), uin: Number(this.uin), format: 'json',
inCharset: 'utf-8', outCharset: 'utf-8', notice: 0,
platform: 'h5', needNewCode: 1, ct: 23, cv: 0,
}
const body = JSON.stringify({ comm, req_0: { module, method, param } })
const r = await request('POST', `${API}?_=${Date.now()}&sign=${zzc(body)}`, this.headers(), body)
let d
try { d = JSON.parse(r.text) } catch { throw new Error(`响应非 JSON(http ${r.status}): ${r.text.slice(0, 120)}`) }
const out = (d && d.req_0) || {}
// 2000=登录态失效(协议表);1000=未登录(写接口实测,假 token 冒烟实证)→ 同归 QmDead
if (out.code === 2000 || out.code === 1000) throw new QmDead('请重新打开 y.qq.com 抓 qm_keyst 更新 env')
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 } })
const url = `${API}?sign=${zzc(ds)}&format=json&inCharset=utf8&outCharset=utf-8&data=${encodeURIComponent(ds)}`
try {
const r = await request('GET', url, this.headers())
const j = JSON.parse(r.text)
return (j && j.req1 && j.req1.data) || {}
} catch (e) {
log(`[续期] 请求异常: ${e.message}`)
return {}
}
}
async refresh() {
const base = { expired_in: 7776000, musicid: Number(this.uin), musickey: this.key }
let result = await this.qqLogin(base)
let newKey = result.musickey || ''
if (!newKey || newKey === this.key) {
if (this.rescue.openid && this.rescue.access_token) {
log('[续期] 一级失败,走 openid+access_token 二级救活')
result = await this.qqLogin({ ...base, openid: this.rescue.openid, access_token: this.rescue.access_token })
newKey = result.musickey || ''
}
}
if (newKey && newKey !== this.key) {
this.key = newKey
this.rescue = {
openid: result.openid || this.rescue.openid,
access_token: result.access_token || this.rescue.access_token,
}
this.saveCache()
log('[续期] musickey 已刷新并缓存(72h 滚动,可无限续)')
} else {
log('[续期] 未取得新 key,沿用当前 key 继续本轮')
}
}
async state() {
const d = await this.api('music.lvz.LevelConfigSvr', 'GetState', {})
if (d.code !== 0) throw new Error(`GetState 异常 code:${d.code}`)
return (d.data && d.data.StateMap) || {}
}
/** 签到预查(Cmd:qry):true = 今日已签(Ret 20019) */
async signQry() {
const d = await this.api('music.lvz.MuFest13TaskSvr', 'EveryDaySignLvzScore', { Cmd: 'qry' })
return ((d.data || {}).Ret) === 20019
}
async signDaily() {
const d = await this.api('music.lvz.MuFest13TaskSvr', 'EveryDaySignLvzScore', { Cmd: 'get' })
const data = d.data || {}
if (data.Ret === 0) {
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
}
/** 任务8 挂件:已佩戴(42502)时 Cancel→Use 成对制造当日动作 */
async pendant() {
const d = await this.api('music.vip.PendantUserSvr', 'QueryUserPendant', {})
const pid = (d.data || {}).pendant && d.data.pendant.id
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 成对制造当日动作
await this.api('music.vip.PendantUserSvr', 'CancelPendant', { id: Number(pid) })
r = await this.api('music.vip.PendantUserSvr', 'UsePendant', { id: Number(pid) })
rc = Number((r.data || {}).retCode)
}
log(`[任务中心·挂件] UsePendant retCode:${rc}`)
this.step('任务中心·挂件', rc === 0 ? 'done' : 'warn', `retCode ${rc}`)
}
/** 任务9 装扮:UseCosmetic 当前使用中的装扮(cosmeticcgi 专用 comm) */
async cosmetic() {
const comm = {
g_tk: time33(this.key), uin: Number(this.uin), format: 'json',
inCharset: 'utf-8', outCharset: 'utf-8', notice: 0, ...APP_COMM,
}
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跳过'); 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 已领 */
async claimAll(stNow) {
let total = 0
const CLAIMS = [[8, '头像挂件'], [9, '主题装扮'], [30, '逛听书频道'], [20, '收听节目']]
for (const [tid, name] of CLAIMS) {
const k = String(tid)
const v = stNow[k]
if (v === 'Y') { log(`[任务中心·领奖] ${name}: 今日已完成`); this.step(`领奖·${name}`, 'skip', '今日已完成'); continue }
if (tid === 20 && v === 'W') {
await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' }) // 领取钥匙
}
let d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k })
let code = d.code
if (code === 20020) {
log(`[任务中心·领奖] ${name}: 20020 冷却,3s 后原地重领`)
await sleep(3000)
d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k })
code = d.code
} else if (code === 30005 && tid === 20) {
log(`[任务中心·领奖] ${name}: 30005 未计次(本版无播放上报,仅 ${POLL_PROBE_MS / 1000}s 只读探测一次)`)
await sleep(POLL_PROBE_MS)
const v2 = (await this.state())[k]
if (v2 === 'W') {
await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' })
d = await this.api('music.lvz.LevelConfigSvr', 'IssueAward', { ID: k })
code = d.code
} else if (v2 === 'Y') {
code = 30009
} else {
log(`[任务中心·领奖] ${name}: state 仍 ${v2 || 'N'},留待下次运行`)
this.step(`领奖·${name}`, 'warn', '未计次,待 App 内收听')
continue
}
}
if (code === 0) {
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
}
/** 会员福利站(独立第二体系,先探后做,无硬编码资源 ID)。失败不影响任务中心主流程 */
async vipStation() {
let gained = 0
const now = new Date()
const month = Number(`${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`)
const day = now.getDate()
const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
// ---- 每日签到(独立于任务中心,两份都拿)----
let signedCnt = 0
let monthDays = 0
const d = await this.api('music.vipcgi.RenewalPlaySignInCgi', 'GetMonthSignInData', { month })
const days = ((d.data || {}).days) || []
for (const x of days) if (x.signInStatus === 2) signedCnt += 1
monthDays = (d.data || {}).signInDays || signedCnt
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) {
const data = r.data || {}
const name = (data.todayIncentive || {}).incentiveName || '奖励'
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) {
log(`[福利站·${mk.startsWith('week') ? '周' : '月'}里程碑] 达成:${inc.incentiveName}`)
}
}
} else if (r.code === 14822150) {
log(`[福利站·签到] 今日已签到(幂等码,本月 ${monthDays} 天)`)
this.step('福利站·签到', 'skip', '今日已签')
} else {
log(`[福利站·签到] 异常 code:${r.code}`)
this.step('福利站·签到', 'fail', `code ${r.code}`)
}
}
// ---- 星愿任务探针(seasonID 从响应取,换季自适应)----
const sd = await this.api('music.vipcgi.StarWishTaskCgi', 'ListStarWishTask', { source: 'renewalplay_station' })
const sdata = sd.data || {}
const season = sdata.seasonID || 7
const tasks = sdata.tasks || []
const t502 = tasks.find(t => t.taskType === 502)
// ---- 礼券任务(502):探券池 status 1=可领 2=今日已领 3=领完 5=锁,按日轮换挑可领 ----
if (t502 && t502.taskStatus !== 2) {
const pool = await this.api('music.vipcgi.VipRenewalCouponCgi', 'QueryVipRenewCouponInfo', {})
const infos = ((pool.data || {}).couponInfos) || []
const ok = infos.filter(c => c.status === 1)
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}心愿值`)
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 幂等)----
const idata = (await this.api('music.vipcgi.StarWishLotteryCgi', 'GetIncentives', { seasonID: season })).data || {}
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('、')}`)
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})`)
this.step('福利站·旅程激励', 'skip', `心愿值 ${idata.currentScore || 0}/${nxt.score}`)
} else {
this.step('福利站·旅程激励', 'skip', '无待领档位')
}
}
// ---- 月度里程碑:签满目标天数即领(探针先行,14822144=未达标幂等)----
const msData = (await this.api('music.vipcgi.RenewalPlaySignInCgi', 'GetMonthSignInMilestoneIncentive', { month })).data || {}
const ms = msData.incentives || []
if (ms.length) {
const target = ms[0]
const need = target.signInDay || 28
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}`)
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}`); 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
}
/** 金币中心签到 + 领奖 —— 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 }
log(`[金币·签到] 开始:ActID=${COIN_ACT_ID} ScenesID=${COIN_SCENE_ID}`)
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 {
log(`[金币·抽奖签到] 开始:ActID=${LOTTERY_ACT_ID}`)
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)。
*
* 语义(官方特权页 https://y.qq.com/m/myvip/v6/privilege_detail.html?id=experience_card):
* 有效期内的豪华绿钻每 31 天可按等级赠送「不同非会员好友」3 天体验券,
* LV1-2 各 1 张、LV3 2 张、LV4-5 各 3 张、LV6-7 各 5 张、LV8 7 张(LV9 10、LV10 15)。
* 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。
*
* ★ 复用优先(照抄落地页行为):页面上的按钮逻辑是
* createCard(d ? cardId : "")
* 即「已经有卡就复用那张,没有才建新的」。所以本方法先扫本月卡,
* 找到 cardLeft>0 的就直接吐它的链接,**绝不重复建卡**。
*
* 配额口径(从落地页 UI 文案逆推,`本次权益已被人领取:已领/配额`):
* sum = 本月配额(等级档位张数)
* reciveUinList = 本月已被好友领走的记录
* 所以「还能送几张」= sum - reciveUinList.length。
* 注意 cardLeft 是**单张卡还能被领几次**,跟本月配额不是一个东西。
*
* @param force true = 无视 QM_SHARE_CARD 开关(手动取卡模式用) */
async shareCard(force) {
// ① 等级配额表(不校验登录,任何账号都能读到全等级档位)
const conf = ((await this.api('music.vip.VipFreeCardSvr', 'GetCanNumConf', {})).data || {}).confInfo || {}
// ② 本月卡列表 + 已送出记录(需要登录态;1000 会在 api() 里抛 QmDead)
const listData = (await this.api('music.vip.VipFreeCardSvr', 'CheckCurrentCardList', {})).data || {}
const cards = listData.cardInfoList || []
const last = cards[cards.length - 1] || {}
const sentList = last.reciveUinList || [] // 服务端字段名就是 reciveUinList(拼写如此)
// 配额来源:本月卡 sum 优先;本月没卡时取 confInfo 最大档位作上限参考。
// 不能拿 confInfo[0] 兜底 —— 表里没有 0 号档,取到 undefined 会让 remain 恒为 0。
const maxQuota = Math.max(0, ...Object.values(conf).map(Number).filter(Number.isFinite))
const hasCard = cards.length > 0 && Number.isFinite(Number(last.sum))
const quota = hasCard ? Number(last.sum) : maxQuota
const canSend = hasCard ? Math.max(0, quota - sentList.length) : quota
log(hasCard
? `[分享卡] 本月配额 ${quota} 张,已被领 ${sentList.length} 张,还能送 ${canSend} 张`
: `[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`)
// ③ 复用优先:扫本月每张卡,cardLeft>0 的直接返回链接(不建新卡)
for (const c of cards) {
const cid = c.cardID || c.cardId
if (!cid) continue
let left
try {
const info = ((await this.api('music.vip.VipFreeCardSvr', 'CheckCard', { cardID: cid })).data || {}).cardInfo || {}
left = Number(info.left)
// 自己领不了自己的卡,但要能确认这张卡还在有效期内
if (info.ownerUin != null && String(info.ownerUin) !== String(this.uin)) continue
} catch (e) {
if (e instanceof QmDead) throw e
continue // 单张卡查不到(过期/已删)就跳过
}
if (Number.isFinite(left) && left > 0) {
const url = `${CARD_LANDING}?cardid=${cid}`
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
}
// ④ 本月没有可复用的卡,才建新的:CreatCard 返回 cardID + cardInfo.left
const created = (await this.api('music.vip.VipFreeCardSvr', 'CreatCard', { source: 0 })).data || {}
const cardId = created.cardID
if (!cardId) {
log('[分享卡] CreatCard 未返回 cardID,跳过')
this.step('分享卡', 'warn', 'CreatCard 未返回 cardID')
return 0
}
const left = (created.cardInfo || {}).left
const url = `${CARD_LANDING}?cardid=${cardId}`
log(`[分享卡] 已新建:还能被领 ${left != null ? left : '?'} 次`)
log(`[分享卡] 分享链接:${url}`)
this.shareCardUrl = url // 供汇总输出
this.step('分享卡', 'done', `新建 1 张,还能被领 ${left != null ? left : '?'} 次`)
return 0 // 不产成长值,不计入 total
}
/** 单账号全流程:续期 → 任务中心 → 福利站 → 金币中心 → 分享卡 */
async run() {
const out = { total: 0, dead: false, cardUrl: '' }
try {
await this.refresh()
const st = await this.state()
const signed = await this.signQry()
log(`[状态] 签到:${signed ? '已签' : '未签'} 挂件:${st['8']} 装扮:${st['9']} 听书:${st['30']} 节目:${st['20']}`)
// 三套签到体系各自独立,用段落标题分开 —— 日志里一眼能看出哪套做了什么
log('\n───── ① 任务中心(成长值)─────')
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 是昨日残留,仍挂开始标记
const r = (await this.api('music.lvz.LevelConfigSvr', 'StartTask', { ID: '20' })).data || {}
if (Number(r.code) === 1) {
log(PLAY_REPORT
? '[任务中心·节目] 开始标记已挂(播放上报未实现于本版,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()
out.total += await this.claimAll(stNow)
log('\n───── ② 会员福利站(成长值 + 权益)─────')
try { out.total += await this.vipStation() } catch (e) {
if (e instanceof QmDead) throw e // 失效语义不被福利站吞掉
log(`[福利站] 运行异常: ${e.message}`)
this.step('福利站', 'fail', e.message)
}
// 金币中心:独立协议(musicu.fcg + 小程序 comm),给金币不给成长值。
// 放在福利站之后、分享卡之前 —— 金币体系挂了不影响前两套,也不拖累取卡。
log('\n───── ③ 金币中心(金币,不计入成长值)─────')
try { await this.coinCenter() } catch (e) {
if (e instanceof QmDead) throw e
log(`[金币·签到] 运行异常: ${e.message}`)
this.step('金币·签到', 'fail', e.message)
}
log('\n───── ④ VIP 体验券(分享卡)─────')
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(`\n[QQ音乐] 本次新增成长值合计: ${out.total}`)
log(`[QQ音乐] 金币中心为独立体系,不产成长值(明细见上方 ③ 段)`)
} catch (e) {
if (e instanceof QmDead) {
out.dead = true
out.steps = this.steps
out.verdict = 'dead'
log(`[QQ音乐] 登录态失效(2000): ${e.message}`)
return out
}
throw e
}
return out
}
}
/* ---------------- env 解析与入口 ---------------- */
/**
* 支持三种形态(可任意混排,青龙同名 env 注入按 & 连接):
* 1. <uin>#<qm_keyst> —— 推荐单条格式
* 2. 整串 cookie(含 qm_keyst= / uin=o..)—— 全局提取 qm_keyst+uin 按序配对
* 3. QM_KEY + QM_UIN 单账号兜底
* qm_keyst 值是 URL-safe token(不含 ; & # 与空白),据此做无歧义切分。
*/
function parseAccounts() {
const raw = process.env.SH_QQMUSIC_KEY || ''
const list = []
const seen = new Set()
const push = (uin, key, src) => {
uin = String(uin).replace(/^o/i, '').trim()
key = String(key || '').trim()
if (!/^\d+$/.test(uin) || key.length <= 10) { log(`[配置] 无效条目忽略(${src}): uin=${uin}`); return }
if (seen.has(uin)) { log(`[配置] 重复账号忽略: ${uin}`); return }
seen.add(uin)
list.push({ uin, key })
}
// Pass A: 提取全部 <uin>#<key>(# 形态,uin 可带 o 前缀、分隔符可多个);从原文移除已消费部分
let rest = raw.replace(/(?:^|[;&\s]+)(o?\d{5,12})#([^;&\s#]+)/gi, (_, u, k) => {
push(u, k, 'uin#key')
return ' '
})
// 残留的「疑似 # 形态但 uin 非法」给出明确报错(而非落到 Pass B 的泛化提示)
for (const m of rest.matchAll(/(?:^|[;&\s]+)([^\s;&]{1,20})#([^;&\s#]+)/g)) {
if (!/^(o?\d{5,12})$/i.test(m[1])) log(`[配置] # 形态 uin 非法(应为5-12位数字): ${m[1]}#...`)
}
// Pass B: 剩余文本按段(& / 换行)配对 cookie 形态的 qm_keyst 与 uin
for (const chunk of rest.split(/[&\n]/)) {
const s = chunk.trim()
if (!s) continue
const keys = [...s.matchAll(/(?:^|[;\s])qm_keyst=([^;\s&]+)/gi)].map(m => m[1])
const uins = [...s.matchAll(/(?:^|[;\s])uin=o?(\d{5,12})\b/gi)].map(m => m[1])
if (!keys.length) {
if (s.length > 4) log(`[配置] 无法识别的条目(期望 <uin>#<qm_keyst>): ${s.slice(0, 18)}...`)
continue
}
if (keys.length === 1 && uins.length >= 1) {
push(uins[0], keys[0], 'cookie串')
} else if (keys.length === uins.length) {
for (let i = 0; i < keys.length; i++) push(uins[i], keys[i], 'cookie串')
} else {
log(`[配置] cookie 串 qm_keyst(${keys.length})/uin(${uins.length}) 数量不配对,忽略该段`)
}
}
if (!list.length && process.env.QM_KEY && process.env.QM_UIN) {
push(process.env.QM_UIN, process.env.QM_KEY, 'QM_KEY/QM_UIN')
}
return list
}
/** 取卡结果回传平台(可选)。平台侧 /api/hooks/card 收到后按该用户的通知目标转发到微信。
* 设计要点:
* - 只回传「取卡结果」,不含 cookie/key —— 走公网也不泄露凭证。
* - 用 Node 内置 https,零依赖(与脚本其余部分一致)。
* - 失败只打日志,绝不因回传失败让取卡本身算失败(卡已经建出来了,链接在日志里)。
* - QM_CARD_CALLBACK 空 → 完全跳过(本地开发/不想接推送的场景)。 */
function pushCardResult(results) {
if (!CARD_CALLBACK) {
log('[回传] 未配置 QM_CARD_CALLBACK,结果仅打印在本日志里')
return Promise.resolve()
}
const payload = JSON.stringify({
cards: results,
at: new Date().toISOString(),
})
return new Promise((resolve) => {
let u
try { u = new URL(CARD_CALLBACK) } catch { log(`[回传] QM_CARD_CALLBACK 不是合法 URL,跳过`); return resolve() }
const lib = u.protocol === 'http:' ? require('http') : https
const req = lib.request({
host: u.hostname,
port: u.port || (u.protocol === 'http:' ? 80 : 443),
path: u.pathname + u.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(CARD_TOKEN ? { 'x-sh-hook-token': CARD_TOKEN } : {}),
'Content-Length': Buffer.byteLength(payload),
},
timeout: 15000,
}, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
if (res.statusCode >= 200 && res.statusCode < 300) log(`[回传] 已推送(HTTP ${res.statusCode})`)
else log(`[回传] 失败 HTTP ${res.statusCode}: ${text.slice(0, 160)}`)
resolve()
})
})
req.on('timeout', () => { log('[回传] 超时,放弃(链接已在日志里)'); req.destroy(); resolve() })
req.on('error', (e) => { log(`[回传] 失败: ${e.message}(链接已在日志里)`); resolve() })
req.write(payload)
req.end()
})
}
/** 把一次执行的 steps 渲染成通知正文。只列「做到了」和「没做到」,
* 全部 skip 的项折叠成一行,避免通知被十几行「今日已完成」淹没。 */
function renderAccount(uin, r) {
const icon = { done: '✓', warn: '△', fail: '✗', skip: '·' }
// step 名统一成「体系·子项」两段(2026-11-07 改),按体系分组渲染 ——
// 通知里必须一眼看出「哪个签到完成了」,不能把所有 done 平铺成一堆。
const group = s => {
const m = /^(任务中心|福利站|金币|分享卡)/.exec(s.name)
return m ? m[1] : '其他'
}
const line = s => ` ${icon[s.status] || '·'} ${s.name.replace(/^[^·]+·/, '')}${s.detail ? ` ${s.detail}` : ''}`
const lines = [`${r.verdict === 'done' ? '🟢' : '🟡'} ${uin}:成长值 +${r.total}`]
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) {
lines.push(` · 无需处理 ${skip.length} 项:${skip.map(s => s.name.replace(/^[^·]+·/, '')).join('、')}`)
}
}
if (r.cardUrl) lines.push(` 🔗 领卡链接 ${r.cardUrl}`)
return lines.join('\n')
}
async function main() {
// ---- 手动取卡模式(青龙「运行」按钮直接点,给人送卡用)----
// 触发:task qqmusic/scrip_qqmusic_checkin.js -- card
// 青龙 task 的 `--` 之后是脚本参数,会原样透传给 node(shell/task.sh 的 format_params
// 把 -- 后面的都收进 script_params,otask.sh 再 run_task_command node <脚本> <args>)。
// 也支持环境变量 QM_CARD=1(定时任务里不方便传参时用)。
// 只跑取卡,不跑签到:手动送卡时不想额外刷一遍签到接口。
// 置了 QM_CARD_CALLBACK 就把结果 POST 回平台,由平台转发到微信(见 pushCardResult)。
const cardOnly = process.argv.includes('card')
|| String(process.env.QM_CARD || '').toLowerCase() === 'on'
|| String(process.env.QM_CARD || '') === '1'
const accounts = parseAccounts()
if (!accounts.length) {
log('[QQ音乐] 未配置 SH_QQMUSIC_KEY(或 QM_KEY/QM_UIN),跳过')
return
}
const dir = cacheDir()
if (cardOnly) {
const results = []
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`))
await cli.refresh() // 先续期,避免 key 过期导致误判失效
await cli.shareCard(true) // force=true:无视 QM_SHARE_CARD 开关
if (cli.shareCardUrl) {
log(`\n👉 复制这条链接发给对方:\n${cli.shareCardUrl}\n`)
results.push({ uin: a.uin, ok: true, url: cli.shareCardUrl })
} else {
log('[分享卡] 本次没有可用的卡(配额用完 / 本月卡都已被领完)')
results.push({ uin: a.uin, ok: false, reason: '配额用完或本月卡都已被领完' })
}
} catch (e) {
const reason = e instanceof QmDead ? '登录态失效,请重新抓 qm_keyst 更新 env' : `取卡异常 ${e.message}`
log(`🔴 ${a.uin}:${reason}`)
results.push({ uin: a.uin, ok: false, reason })
}
}
await pushCardResult(results)
return
}
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()
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}:运行异常\n └ ${e.message}`)
}
}
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' })}`
// 用本文件自带的 notify(),不 require 青龙的 sendNotify.js(见文件头部注释:
// 那份文件每轮都会被 /ql/data/deps/* 覆盖,改它永远不生效)
await notify('QQ音乐签到', desp)
log(`\n===== 汇总 =====\n${desp}`)
}
// 模块导出:供签名对拍与单元复用;仅直接运行时才执行 main
if (require.main === module) {
main().catch(e => { console.error('[QQ音乐] 未捕获异常:', e); process.exitCode = 1 })
}
module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult, renderAccount }