Files
ql-scripts/qqmusic/qqmusic_checkin.js
zhuhjay 89afcd1e60 feat(qqmusic): QQ音乐会员成长值+福利站自动签到(zzc签名对拍验证)
- u6.y.qq.com 协议底座: zzc/time33 与 LDP924/auto-checkin qmcommon.py 逐字节对拍 PASS(6向量)
- 续期闭环: QQLogin 换新 key + openid/access_token 二级救活, 缓存存 /ql/data/qqmusic_cache(订阅git免疫)
- 任务中心三态机: 签到/挂件/装扮/听书/节目, Y零请求/W直领/N动作, 10006写锁即停
- 福利站全量: 每日签到/礼券/旅程激励/月里程碑/月度好礼/臻享抽奖, 先探后做
- env: SH_QQMUSIC_KEY 多账号(uin#key / cookie串 / 混排), code 1000/2000 归一失效语义
- 播放上报(任务20完整+10)默认关闭, 定时 1 0,6 * * * 双轮
2026-11-04 00:38:19 +08:00

570 lines
25 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
/**
* qqmusic_checkin.js —— QQ音乐会员成长值 + 会员福利站 自动签到(青龙 Node 零依赖)
*
* 协议底座: 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 标记)
*
* 定时: cron 1 0,6 * * * (00:01 + 06:02 双轮:任务20跨天滚动 + 福利站翻新兜底)
*/
'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'
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 是否被装好) */
function request(method, urlStr, headers = {}, body = null, timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr)
const req = https.request({
hostname: 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
}
/** 单账号全流程:续期 → 签到 → 任务动作 → 全量领取 → 福利站 */
async run() {
const out = { total: 0, dead: false }
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}`)
}
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}`)
} 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 }