From 89afcd1e60ba384988695c2f7458d42a4c37edb4 Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Wed, 4 Nov 2026 00:38:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqmusic):=20QQ=E9=9F=B3=E4=B9=90=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E6=88=90=E9=95=BF=E5=80=BC+=E7=A6=8F=E5=88=A9?= =?UTF-8?q?=E7=AB=99=E8=87=AA=E5=8A=A8=E7=AD=BE=E5=88=B0=EF=BC=88zzc?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E5=AF=B9=E6=8B=8D=E9=AA=8C=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 * * * 双轮 --- .gitignore | 3 + qqmusic/README.md | 59 ++++ qqmusic/qqmusic_checkin.js | 569 ++++++++++++++++++++++++++++++++++++ qqmusic/test_sign_run.js | 27 ++ qqmusic/test_sign_vector.py | 64 ++++ 5 files changed, 722 insertions(+) create mode 100644 qqmusic/README.md create mode 100644 qqmusic/qqmusic_checkin.js create mode 100644 qqmusic/test_sign_run.js create mode 100644 qqmusic/test_sign_vector.py diff --git a/.gitignore b/.gitignore index 491ff79..7491429 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ ehthumbs.db Thumbs.db .claude/ + +# qqmusic key cache (local dev only; production cache lives in /ql/data/qqmusic_cache) +qqmusic/.cache/ diff --git a/qqmusic/README.md b/qqmusic/README.md new file mode 100644 index 0000000..83ca4d8 --- /dev/null +++ b/qqmusic/README.md @@ -0,0 +1,59 @@ +# QQ音乐自动签到(qqmusic_checkin.js) + +QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node 零依赖。 +协议底座与 [LDP924/auto-checkin](https://github.com/LDP924/auto-checkin)(Python)逐字节同源: +`u6.y.qq.com/cgi-bin/musics.fcg` 网关 + `zzc` 签名(sha1 重组 + 置乱表 + b64 去尾)+ `qm_keyst` cookie。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|---|---|---| +| `SH_QQMUSIC_KEY` | ✅ | 多账号:青龙同名 env 加多条(注入自动 `&` 连接)。**单条格式 `#`**,如 `123456789#Q_H_L_xxxx`。也接受整串 cookie(含 `qm_keyst=` 与 `uin=o...`) | +| `QM_KEY` / `QM_UIN` | 备选 | 单账号兼容形态(`SH_QQMUSIC_KEY` 优先) | +| `QM_CACHE_DIR` | 可选 | key 缓存目录,默认 `/ql/data/qqmusic_cache`(青龙)/ `qqmusic/.cache`(本地) | +| `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) | + +## 凭证获取(web 抓一次,之后无限续) + +1. 浏览器打开 [y.qq.com](https://y.qq.com) 登录(QQ/微信均可) +2. F12 → Application → Cookies → `https://y.qq.com` → 复制 `qm_keyst` 与 `uin`(`o123456789` 去掉 `o`) +3. 青龙新建环境变量 `SH_QQMUSIC_KEY`,值 `uin#qm_keyst` + +**为什么抓一次就够**:签名/`g_tk` 全是本地算法(无盐、无设备绑定、无动态 token); +`qm_keyst` 名义 72h,但续期接口 `QQConnectLogin.LoginServer.QQLogin` 旧 key 不吊销—— +脚本每次运行先续期,新 key 写缓存滚动,环境变量永远不用再动。 +只有脚本断跑超 72h 且缓存丢失(推送出现 🔴 2000)才需要重新抓。 + +## 缓存与订阅安全 + +续期产物存 `/ql/data/qqmusic_cache/qm_.json`(**订阅仓库之外**)。 +git 的 reset/clean 只动 tracked 文件,untracked 目录在 `git pull` 下天然安全; +且脚本读缓存优先、env 兜底,缓存丢失仅退化为"72h 内重新抓一次"。 + +## 定时 + +``` +cron: 1 0,6 * * * +``` + +每天 00:01 + 06:02 双轮:任务20(收听节目)跨天滚动翻账本 + 福利站 0 点翻新兜底。 + +## 任务覆盖 + +| 层 | 内容 | 风险 | +|---|---|---| +| 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) | +| 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) | +| 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) | +| 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 | + +状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。 +错误码处置:`20020` 冷却3s重领 · `30005` 只读探测后补领 · `10006` 写锁即停次日恢复 · `2000` 登录态失效推送提示。 + +## 本地测试 + +```bash +node --check qqmusic_checkin.js # 语法 +node qqmusic_checkin.js # 无 env 时优雅跳过 +SH_QQMUSIC_KEY='123#Q_H_L_x' node -e "console.log(require('./qqmusic_checkin').parseAccounts())" +``` diff --git a/qqmusic/qqmusic_checkin.js b/qqmusic/qqmusic_checkin.js new file mode 100644 index 0000000..d6f7734 --- /dev/null +++ b/qqmusic/qqmusic_checkin.js @@ -0,0 +1,569 @@ +#!/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 多条,注入自动 & 连接;单条格式 # + * 也接受整串 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. # —— 推荐单条格式 + * 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 可带 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(`[配置] 无法识别的条目(期望 #): ${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 } diff --git a/qqmusic/test_sign_run.js b/qqmusic/test_sign_run.js new file mode 100644 index 0000000..27c5a45 --- /dev/null +++ b/qqmusic/test_sign_run.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// 被测模块 vs Python 参考实现的逐字节比对(输入: test_sign_expect.json) +'use strict' +const fs = require('fs') +const path = require('path') +const { zzc, time33 } = require(path.join(__dirname, 'qqmusic_checkin.js')) + +const expect = JSON.parse(fs.readFileSync(path.join(__dirname, 'test_sign_expect.json'), 'utf8')) +let fails = 0 + +const t33js = time33('Q_H_L_testkey1234567890') +const t33ok = t33js === expect.time33 +console.log(`time33 py=${expect.time33} js=${t33js} -> ${t33ok ? 'OK' : 'MISMATCH'}`) +if (!t33ok) fails++ + +for (const c of expect.cases) { + const got = zzc(c.payload) + const ok = got === c.zzc + if (!ok) { + fails++ + console.log(`zzc MISMATCH payload[:40]=${JSON.stringify(c.payload.slice(0, 40))}\n py=${c.zzc}\n js=${got}`) + } else { + console.log(`zzc OK len=${c.len} payload[:28]=${JSON.stringify(c.payload.slice(0, 28))}`) + } +} +console.log('RESULT:', fails === 0 ? 'PASS' : `FAIL(${fails})`) +process.exit(fails === 0 ? 0 : 1) diff --git a/qqmusic/test_sign_vector.py b/qqmusic/test_sign_vector.py new file mode 100644 index 0000000..4b2c15e --- /dev/null +++ b/qqmusic/test_sign_vector.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""签名对拍(文件交换版,规避 Windows 子进程控制台编码坑): +本文件 = LDP924/auto-checkin qmcommon.py 的 zzc/time33 参考实现,生成期望值 JSON; +qqmusic/test_sign_run.js = 被测 Node 模块,读取 JSON 逐条比对并给出 PASS/FAIL。 +两个实现任何一处不一致都会 MISMATCH —— 这是协议调用可用性的硬门槛。""" +import json +import subprocess +import sys +import os +from base64 import b64encode +from hashlib import sha1 + +SCRAMBLE = (89, 39, 179, 150, 218, 82, 58, 252, 177, 52, + 186, 123, 120, 64, 242, 133, 143, 161, 121, 179) + + +def time33(s): + h = 5381 + for c in s: + h += (h << 5) + ord(c) + h &= 0xFFFFFFFF + return h & 0x7FFFFFFF + + +def zzc(payload): + h = sha1(payload.encode()).hexdigest().upper() + p1 = ''.join(h[i] for i in (23, 14, 6, 36, 16, 7, 19)) + p2 = ''.join(h[i] for i in (16, 1, 32, 12, 19, 27, 8, 5)) + p3 = bytes(v ^ int(h[i * 2:i * 2 + 2], 16) for i, v in enumerate(SCRAMBLE)) + mid = b64encode(p3).decode().replace('\\', '').replace('/', '').replace('+', '').replace('=', '') + return ('zzc' + p1 + mid + p2).lower() + + +PAYLOADS = [ + '{"comm":{"g_tk":962138355,"uin":123456789,"format":"json","inCharset":"utf-8","outCharset":"utf-8","notice":0,"platform":"h5","needNewCode":1,"ct":23,"cv":0},"req_0":{"module":"music.lvz.LevelConfigSvr","method":"GetState","param":{}}}', + '{"comm":{},"req_0":{"module":"m","method":"x","param":{}}}', + 'x', + 'a' * 10000, + '', + 'unicode测试中文签名输入⏰', +] + + +def main(): + expect = { + 'time33': time33('Q_H_L_testkey1234567890'), + 'cases': [{'payload': p, 'zzc': zzc(p), 'len': len(zzc(p))} for p in PAYLOADS], + } + here = os.path.dirname(os.path.abspath(__file__)) + expect_path = os.path.join(here, 'test_sign_expect.json') + with open(expect_path, 'w', encoding='utf-8') as f: + json.dump(expect, f, ensure_ascii=False) + out = subprocess.run(['node', os.path.join(here, 'test_sign_run.js')], + capture_output=True, text=True, shell=False, cwd=here, + encoding='utf-8', errors='replace') + sys.stdout.write(out.stdout) + if out.stderr: + sys.stderr.write(out.stderr) + return out.returncode + + +if __name__ == '__main__': + sys.exit(main())