refactor(qqmusic): 脚本改名 scrip_qqmusic_checkin.js 以匹配订阅白名单
订阅规则 ql repo 的 whitelist 参数是 "scrip_",只有文件名带该前缀的才会被拉取。
原文件名 qqmusic_checkin.js 不在白名单内,青龙订阅拉不到。
- qqmusic/qqmusic_checkin.js -> qqmusic/scrip_qqmusic_checkin.js(git mv 保留历史)
- test_sign_run.js 的 require 路径同步
- qqmusic/README.md 与根 README.md 更新文件名与白名单约定说明
- 根 README 补 QQ音乐条目(环境变量 SH_QQMUSIC_KEY / cron 1 0,6 * * *)
依赖拉取不受影响:订阅命令的 dependence 参数 sendNotify|common 会额外拉这两个文件,
脚本的 require('../sendNotify') 仍指向青龙内置/仓库根的 sendNotify.js。
验证:node --check 通过 / 签名对拍 6 组向量全 PASS / 空 env 优雅跳过 /
单账号与 & 多账号解析正确 / 整串 cookie 形态解析正确
This commit is contained in:
572
qqmusic/scrip_qqmusic_checkin.js
Normal file
572
qqmusic/scrip_qqmusic_checkin.js
Normal file
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scrip_qqmusic_checkin.js —— QQ音乐会员成长值 + 会员福利站 自动签到(青龙 Node 零依赖)
|
||||
*
|
||||
* 文件名前缀 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 标记)
|
||||
*
|
||||
* 定时: 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 }
|
||||
Reference in New Issue
Block a user