#!/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 多条,注入自动 & 连接;单条格式 # * 也接受整串 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 张(LV9 10、LV10 15)。 * 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。 * * ★ 复用优先(照抄落地页行为):页面上的按钮逻辑是 * createCard(d ? cardId : "") * 即「已经有卡就复用那张,没有才建新的」。所以本方法先扫本月卡, * 找到 cardLeft>0 的就直接吐它的链接,**绝不重复建卡**。 * * 配额口径(从落地页 UI 文案逆推,`本次权益已被人领取:已领/配额`): * sum = 本月配额(等级档位张数) * reciveUinList = 本月已被好友领走的记录 * 所以「还能送几张」= sum - reciveUinList.length。 * 注意 cardLeft 是**单张卡还能被领几次**,跟本月配额不是一个东西。 * * @param force true = 无视 QM_SHARE_CARD 开关(手动取卡模式用) */ async shareCard(force) { // ① 等级配额表(不校验登录,任何账号都能读到全等级档位) const conf = ((await this.api('music.vip.VipFreeCardSvr', 'GetCanNumConf', {})).data || {}).confInfo || {} // ② 本月卡列表 + 已送出记录(需要登录态;1000 会在 api() 里抛 QmDead) const listData = (await this.api('music.vip.VipFreeCardSvr', 'CheckCurrentCardList', {})).data || {} const cards = listData.cardInfoList || [] const last = cards[cards.length - 1] || {} const sentList = last.reciveUinList || [] // 服务端字段名就是 reciveUinList(拼写如此) // 配额来源:本月卡 sum 优先;本月没卡时取 confInfo 最大档位作上限参考。 // 不能拿 confInfo[0] 兜底 —— 表里没有 0 号档,取到 undefined 会让 remain 恒为 0。 const maxQuota = Math.max(0, ...Object.values(conf).map(Number).filter(Number.isFinite)) const hasCard = cards.length > 0 && Number.isFinite(Number(last.sum)) const quota = hasCard ? Number(last.sum) : maxQuota const canSend = hasCard ? Math.max(0, quota - sentList.length) : quota log(hasCard ? `[分享卡] 本月配额 ${quota} 张,已被领 ${sentList.length} 张,还能送 ${canSend} 张` : `[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`) // ③ 复用优先:扫本月每张卡,cardLeft>0 的直接返回链接(不建新卡) for (const c of cards) { const cid = c.cardID || c.cardId if (!cid) continue let left try { const info = ((await this.api('music.vip.VipFreeCardSvr', 'CheckCard', { cardID: cid })).data || {}).cardInfo || {} left = Number(info.left) // 自己领不了自己的卡,但要能确认这张卡还在有效期内 if (info.ownerUin != null && String(info.ownerUin) !== String(this.uin)) continue } catch (e) { if (e instanceof QmDead) throw e continue // 单张卡查不到(过期/已删)就跳过 } if (Number.isFinite(left) && left > 0) { const url = `${CARD_LANDING}?cardid=${cid}` log(`[分享卡] 复用本月未领完的卡:还能被领 ${left} 次(不新建、不占新配额)`) log(`[分享卡] 分享链接:${url}`) this.shareCardUrl = url return 0 } } if (!force && !SHARE_CARD) { log('[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡') return 0 } if (canSend <= 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. # —— 推荐单条格式 * 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() { // ---- 手动取卡模式(青龙「运行」按钮直接点,给人送卡用)---- // 触发:task qqmusic/scrip_qqmusic_checkin.js -- card // 青龙 task 的 `--` 之后是脚本参数,会原样透传给 node(shell/task.sh 的 format_params // 把 -- 后面的都收进 script_params,otask.sh 再 run_task_command node <脚本> )。 // 也支持环境变量 QM_CARD=1(定时任务里不方便传参时用)。 // 只跑取卡,不跑签到:手动送卡时不想额外刷一遍签到接口。 const cardOnly = process.argv.includes('card') || String(process.env.QM_CARD || '').toLowerCase() === 'on' || String(process.env.QM_CARD || '') === '1' const accounts = parseAccounts() if (!accounts.length) { log('[QQ音乐] 未配置 SH_QQMUSIC_KEY(或 QM_KEY/QM_UIN),跳过') return } const dir = cacheDir() if (cardOnly) { for (const a of accounts) { log(`\n===== 取卡 账号 ${a.uin} =====`) try { const cli = new QMClient(a.uin, a.key, path.join(dir, `qm_${a.uin}.json`)) await cli.refresh() // 先续期,避免 key 过期导致误判失效 await cli.shareCard(true) // force=true:无视 QM_SHARE_CARD 开关 if (cli.shareCardUrl) { log(`\n👉 复制这条链接发给对方:\n${cli.shareCardUrl}\n`) } else { log('[分享卡] 本次没有可用的卡(配额用完 / 本月卡都已被领完)') } } catch (e) { if (e instanceof QmDead) log(`🔴 ${a.uin}:登录态失效,请重新抓 qm_keyst 更新 env`) else log(`🔴 ${a.uin}:取卡异常 ${e.message}`) } } return } 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 }