Files
ql-scripts/qqmusic/scrip_qqmusic_checkin.js
zhuhjay 2bfddc7e04 feat(qqmusic): VIP 体验券(分享卡)配额查询与建卡
从体验券落地页 y.qq.com/m/vip/experience_card/dist/index.html 的
index.036b2e716.js 逆出 music.vip.VipFreeCardSvr 全部 7 个 method,
并在 u6 网关实测路由(GetCanNumConf 用假 key 也返回真实配额表
{1:1,2:1,3:2,4:3,5:3,6:5,7:5,8:7,9:10,10:15},说明该 method 不校验登录;
CreatCard / CheckCurrentCardList 返回 1000=未登录,确认需要登录态)。

语义(官方特权页):有效期内的豪华绿钻每 31 天可按等级赠送不同非会员
好友 3 天体验券,LV1-2 各 1 张、LV3 2 张、LV4-5 各 3 张、LV6-7 各 5 张、
LV8 7 张。卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定);
好友非会员自然月限领 1 张。

实现:
- 新增 QMClient.shareCard(),接在 vipStation 之后,失败不拖垮主流程
  (QmDead 照常上抛,其他异常只记日志)
- 默认只读:报「本月配额 N 张 / 已送 M 张 / 剩余 K 张」,不 CreatCard
- QM_SHARE_CARD=on 才在 remain>0 时 CreatCard,输出 cardid 分享链接
- 配额来源:本月卡 sum 优先;本月无卡时取 confInfo 最大档位作上限参考
  (不拿 confInfo[0] 兜底 —— 表里没有 0 号档,会取到 undefined 让 remain 恒 0)
- run() 返回值新增 cardUrl,汇总与通知里带分享链接

验证:node --check 通过 / 签名对拍 6 组 PASS / 空 env 优雅跳过 /
5 个 mock 用例(只读/用完/建卡/无卡兜底)输出符合预期 /
_probe_meta 三元数据 PASS
2026-11-04 22:43:06 +08:00

651 lines
30 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'
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 同语义)
}
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 明细)'}`)
return got || 0
}
log(`[签到] Ret:${data.Ret} ${data.Msg || ''}`)
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跳过'); 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}`)
}
/** 任务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跳过'); 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})`)
}
/** 领取循环: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}: 今日已完成`); 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'},留待下次运行`)
continue
}
}
if (code === 0) {
const got = (d.data && d.data.DrawScores) || 0
log(`[任务] ${name}: 领取成功 +${got}`)
total += got
await sleep(3000) // 成功发放后 ~2s 冷却,拉开下一个
} else if (code === 30009) {
log(`[任务] ${name}: 今日已完成`)
} else if (code === 10006) {
log(`[任务] ${name}: 10006 写操作锁,停止本账号领取(次日自动恢复)`)
break
} else {
log(`[任务] ${name}: 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} 天)`)
} 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])
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} 天)`)
} else {
log(`[福利站] 签到异常 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}心愿值`)
else if (r.code === 14816772) log('[礼券] 今日已领取')
else log(`[礼券] code:${r.code}`)
} else {
log(`[礼券] 券池今日无可领券(${infos.length} 张全非可领态)`)
}
} else if (t502) {
log('[礼券] 今日已完成')
}
// ---- 旅程激励:心愿值达标档位实时领取(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('、')}`)
else log(`[激励] code:${r.code}`)
} else {
const nxt = (idata.incentives || []).find(x => x.status === 5)
if (nxt) log(`[激励] 心愿值${idata.currentScore || 0}/${nxt.score}(下档 ${nxt.name})`)
}
// ---- 月度里程碑:签满目标天数即领(探针先行,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}`)
else log(`[月里程碑] code:${r.code}(${signedCnt} 天达标待领)`)
} else {
log(`[月里程碑] ${signedCnt}/${need} 天(${target.incentiveName})`)
}
}
// ---- 月度好礼:poolStatus 3=可领 且库存>0 才领;补货时刻实读打印 ----
const gpData = (await this.api('music.vipcgi.VipRenewalMonthGiftCgi', 'QueryVipRenewalMonthlyGiftPool', {})).data || {}
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}`)
} 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} 补货)`)
}
}
// ---- 臻享抽奖:有抽奖券才抽,抽到清零或失败即停 ----
const dd = (await this.api('music.vipcgi.VipRenewalLotteryCgi', 'QueryVipRenewDrawInfo', {})).data || {}
let draws = dd.lotteryCount || 0
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 || '成功'}`)
draws -= 1
} else {
log(`[抽奖] code:${r.code},停止`)
break
}
}
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 张。
* 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。
*
* 默认只读:只报「本月还能送几张」,不 CreatCard(CreatCard 会真的占配额)。
* QM_SHARE_CARD=on 时才在「配额有剩余 + 本月卡已发完/没卡」时创建一张新卡。 */
async shareCard() {
// ① 等级配额表(不校验登录,任何账号都能读到全等级档位;仅用于展示上限)
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 remain = hasCard ? Math.max(0, quota - sentList.length) : quota
if (hasCard) {
log(`[分享卡] 本月配额 ${quota} 张,已送出 ${sentList.length} 张,剩余 ${remain} 张`)
} else {
log(`[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`)
}
if (!SHARE_CARD) {
log('[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡')
return 0
}
if (remain <= 0) {
log('[分享卡] 本月配额已用完,下月再送')
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,跳过')
return 0
}
const left = (created.cardInfo || {}).left
const url = `${CARD_LANDING}?cardid=${cardId}`
log(`[分享卡] 已创建:剩余可领 ${left != null ? left : '?'} 次`)
log(`[分享卡] 分享链接:${url}`)
this.shareCardUrl = url // 供汇总输出
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('[签到] 今日已签到')
else out.total += await this.signDaily()
// 动作语义:N=执行动作 / W=只领(能否领取决于行为已计次)/ Y=零请求(防风控)
if (st['8'] === 'N') await this.pendant()
if (st['9'] === 'N') await this.cosmetic()
if (st['30'] === 'N') {
await this.api('music.lvz.LevelConfigSvr', 'RecordBehavior', { ID: '30' })
log('[听书] 行为上报完成')
}
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}`)
} else {
log(`[节目] StartTask 返回 code:${r.code},无法开始`)
}
}
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}`)
}
try { await this.shareCard() } catch (e) {
if (e instanceof QmDead) throw e // 登录态失效照常上抛
log(`[分享卡] 运行异常: ${e.message}`) // 接口改版/风控不拖垮主流程
}
out.cardUrl = this.shareCardUrl || ''
log(`[QQ音乐] 本次新增成长值合计: ${out.total}`)
} catch (e) {
if (e instanceof QmDead) {
out.dead = true
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
}
async function main() {
const accounts = parseAccounts()
if (!accounts.length) {
log('[QQ音乐] 未配置 SH_QQMUSIC_KEY(或 QM_KEY/QM_UIN),跳过')
return
}
const dir = cacheDir()
const blocks = []
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}`)
} catch (e) {
log(`[QQ音乐] 账号 ${a.uin} 运行异常: ${e.message}`)
blocks.push(`🔴 ${a.uin}:运行异常 ${e.message}`)
}
}
const desp = 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
try { sendNotifyFn = require('../sendNotify').sendNotify } catch { /* 无 sendNotify 时仅控制台 */ }
if (sendNotifyFn) {
try { await sendNotifyFn('QQ音乐签到', 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 }