Files
ql-scripts/qqmusic/scrip_qqmusic_checkin.js
zhuhjay f5c9ca6fa1 feat(qqmusic): 标题带结论 + 首行放摘要,折叠状态也能看关键信息
用户截图确认:pushplus 的微信卡片只显示「标题 QQ音乐签到 + 正文第一行」,
后面的明细全折叠。查 pushplus 官方文档,这是平台默认行为 ——
服务号模板消息默认不展示具体内容,必须点「查看详情」。

三条官方解法都要额外成本(发「激活消息」24h 内限 5 条 / 开会员 /
绑定自己认证的服务号每年 300 元),所以不改通道,改信息密度:

- 标题从固定的「QQ音乐签到」改成三态:QQ音乐签到 ✅ / 🟡 / ⚠️
- 正文第一行从「✅ QQ音乐签到 · 全部完成」改成带数字的摘要:
  「✅ 全部完成 · 成长值 +35」「✅ 已完成 · 成长值 +15 · 1 项待处理」
- 引入三态结论 state:done / warn(有 △ 但无 ✗,仍算完成)/ fail
- 调用 sendNotify 时第四参传空串,去掉尾部「本通知 By:github.com/...」

标题不放数字的理由:平台抑制指纹是 sha256(kind|credId|title去数字),
数字本来就被 titleKeyOf 剥掉,带 +35 / +15 算同一指纹,抑制不会失效。

顺带修 main 里的累加:blocks 从数组改成 {body,total,warn},
成长值合计与待处理项数在拼装前算好,摘要行才能直接用。

验证:node --check / 签名对拍 6 组 PASS / 5 个场景
(全好、有遗留、有失败、登录失效、双账号)的 state+标题+首行逐字比对 PASS /
_probe_meta 四文件 PASS
2026-11-05 20:43:22 +08:00

895 lines
44 KiB
JavaScript
Raw 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'
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()
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
}
/** 续期(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
}
/** 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']}`)
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)
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
}
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: '·' }
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
// 青龙 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 = { body: [], total: 0, warn: 0 }
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.warn += 1
blocks.body.push(`🔴 ${a.uin}:登录态失效\n └ 请重新在网页抓 qm_keyst 更新 env`)
continue
}
blocks.total += r.total || 0
blocks.warn += (r.steps || []).filter(s => s.status === 'warn' || s.status === 'fail').length
if (r.verdict === 'fail') allDone = false
blocks.body.push(renderAccount(a.uin, r))
} catch (e) {
allDone = false
blocks.warn += 1
log(`[QQ音乐] 账号 ${a.uin} 运行异常: ${e.message}`)
blocks.body.push(`🔴 ${a.uin}:运行异常\n └ ${e.message}`)
}
}
// 三态结论:全好 / 有遗留(不影响主流程)/ 有失败或失效
const state = !allDone ? 'fail' : (blocks.warn > 0 ? 'warn' : 'done')
const head = { done: '✅ 全部完成', warn: '✅ 已完成', fail: '⚠️ 有未完成项' }[state]
// 摘要行放最前面:pushplus 的微信模板消息默认只展示标题 + 正文开头,
// 不点开「查看详情」看不到后面。所以把关键数字压进第一行,折叠也能看到。
const sum = accounts.length === 1
? `成长值 +${blocks.total}${blocks.warn ? ` · ${blocks.warn} 项待处理` : ''}`
: `${accounts.length} 个账号 · 成长值 +${blocks.total}${blocks.warn ? ` · ${blocks.warn} 项待处理` : ''}`
const desp = [`${head} · ${sum}`, '', ...blocks.body].join('\n')
+ `\n\n———`
+ `\n轮次: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底)`
+ `\n节目任务(20)依赖 App 内正常收听计入(播放上报未启用)`
+ `\n时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`
// 标题只放状态、不放数字:平台的抑制指纹是 sha256(kind|credId|title去数字),
// 标题带数字会被 titleKeyOf 剥掉,指纹仍然稳定,抑制不会失效。
const title = `QQ音乐签到 ${{ done: '✅', warn: '🟡', fail: '⚠️' }[state]}`
let sendNotifyFn = null
try { sendNotifyFn = require('../sendNotify').sendNotify } catch (e) {
// 静默吞掉会让「通知没发出去」完全不可见 —— 必须留一行,否则只能靠猜
log(`[通知] sendNotify 不可用(${e.message.split('\n')[0]}),本轮不发推送`)
}
if (sendNotifyFn) {
// 第四参传空串:默认会往正文尾部追加「本通知 By:github.com/whyour/qinglong」,
// 平台转发时这行也会进 notify_events.content,纯噪音。
try { await sendNotifyFn(title, desp, {}, '') } catch (e) { log(`[通知] 推送失败: ${e.message}`) }
}
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 }