From 656442e4d62c54db6e5e1f2a064424b91e6bdb4b Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Wed, 4 Nov 2026 22:57:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqmusic):=20=E6=89=8B=E5=8A=A8=E5=8F=96?= =?UTF-8?q?=E5=8D=A1=E6=A8=A1=E5=BC=8F=20+=20=E5=A4=8D=E7=94=A8=E6=9C=AC?= =?UTF-8?q?=E6=9C=88=E6=9C=AA=E9=A2=86=E5=AE=8C=E7=9A=84=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事: 1) 手动取卡入口(给好友送卡用,点一次就出链接): task qqmusic/scrip_qqmusic_checkin.js -- card 或 QM_CARD=1。青龙 task 的 -- 之后是脚本参数,经 shell/task.sh 的 format_params 收进 script_params、otask.sh 的 run_task_command 原样透传给 node(已读青龙 shell 源码确认,不是猜的)。手动模式只跑取卡,不跑签到。 2) 复用优先(照抄落地页行为): 体验券落地页的按钮逻辑是 createCard(d ? cardId : ) —— 已有卡就复用那张, 没有才建新的。原实现只会 CreatCard,会不断堆废卡。现在先扫本月卡列表, 对每张调 CheckCard 拿 cardInfo.left,>0 就直接吐那张的链接,绝不重复建卡; 全部领完且配额有剩才 CreatCard。 同时纠正三处口径: - 输出文案「已送出」→「已被领」:reciveUinList 是好友领走记录,不是主动送出 - 区分三个数:sum=本月配额 / reciveUinList.length=已被领 / cardInfo.left=单张卡 剩余可领次数(一张卡可被多人领)。「还能送几张」= sum - 已被领 - CheckCard 顺带校验 ownerUin 是自己(自己领不了自己的卡,能确认卡还有效) 验证:node --check / 签名对拍 6 组 PASS / 空 env 与 -- card 与 QM_CARD=1 三种 入口均优雅跳过 / 7 个 mock 用例(复用/多卡择一/新建/配额用完/无卡直建/只读/ 单卡过期跳过)全 PASS / _probe_meta 三元数据 PASS --- qqmusic/README.md | 84 ++++++++++++++++++--------- qqmusic/scrip_qqmusic_checkin.js | 98 ++++++++++++++++++++++++++------ 2 files changed, 136 insertions(+), 46 deletions(-) diff --git a/qqmusic/README.md b/qqmusic/README.md index 06f59fc..57a06dd 100644 --- a/qqmusic/README.md +++ b/qqmusic/README.md @@ -15,7 +15,8 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node | `QM_KEY` / `QM_UIN` | 备选 | 单账号兼容形态(`SH_QQMUSIC_KEY` 优先) | | `QM_CACHE_DIR` | 可选 | key 缓存目录,默认 `/ql/data/qqmusic_cache`(青龙)/ `qqmusic/.cache`(本地) | | `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) | -| `QM_SHARE_CARD` | 可选 | `on` 才创建 VIP 体验券(分享卡)。默认 off = 只读探测「本月还能送几张」,不消耗配额 | +| `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 | +| `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) | ## VIP 体验券(分享卡) @@ -32,46 +33,73 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node |---|---|---|---|---|---|---|---|---| | 张数 | 1 | 1 | 2 | 3 | 3 | 5 | 5 | 7 | +(实测配额表还有 LV9=10、LV10=15) + **卡只能送人,自己领不了自己的**(`CheckCard` 的 `ownerUin` 判定)。 好友领取限制:非会员自然月内限领 1 张,体验券仅周期内有效、不可累计。 +### 三个容易混淆的数 + +落地页上这三个数不是一回事,脚本输出里也分开报: + +| 字段 | 含义 | +|---|---| +| `sum` | **本月配额**(你这个等级这个月一共能送几张) | +| `reciveUinList.length` | **本月已被好友领走的张数** | +| `cardInfo.left` | **单张卡还能被领几次**(一张卡可以被多个人领,每次 -1) | + +「还能送几张」= `sum - reciveUinList.length`。 +`left` 是另一维度:它只说明这张卡还剩几次可被领,**建卡本身就已经占掉一张配额**。 + +### 两种模式 + +**① 手动取卡(你要的场景)**——青龙里点一次「运行」就吐链接: + +``` +task qqmusic/scrip_qqmusic_checkin.js -- card +``` + +或者环境变量 `QM_CARD=1`。 + +行为:**先扫本月已有的卡,有 `cardLeft>0` 的直接复用**(照抄落地页的 +`createCard(d ? cardId : "")` 逻辑),只有卡都领完了才 `CreatCard` 建新的。 +不会为了取一张卡把整个签到流程再跑一遍。 + +输出: + +``` +===== 取卡 账号 123456789 ===== +[分享卡] 本月配额 5 张,已被领 1 张,还能送 4 张 +[分享卡] 复用本月未领完的卡:还能被领 3 次(不新建、不占新配额) +[分享卡] 分享链接:https://y.qq.com/m/vip/experience_card/dist/index.html?cardid= + +👉 复制这条链接发给对方: +https://y.qq.com/m/vip/experience_card/dist/index.html?cardid= +``` + +**② 定时任务路径(默认只读)**——每天签到顺带报个数,不建卡: + +``` +[分享卡] 本月配额 5 张,已被领 2 张,还能送 3 张 +[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡 +``` + +要让定时任务也自动建卡(不建议,会堆废卡)才设 `QM_SHARE_CARD=on`。 + ### 接口清单 | method | 作用 | 需要登录 | |---|---|---| | `GetCanNumConf` | 全等级配额表 `{等级: 张数}` | ❌ | -| `CheckCurrentCardList` | 本月卡列表 + 已送出 uin 列表 | ✅ | -| `CreatCard` | 建卡,`param: {source: 0}` → 返回 `cardID` + `cardInfo.left` | ✅ | -| `CheckCard` | 查某张卡详情(`param: {cardID}`) | ✅ | -| `GetCard` | **好友领取**用(`param: {cardID, source}`) | ✅ | +| `CheckCurrentCardList` | 本月卡列表 + 已被领 uin 列表 | ✅ | +| `CheckCard` | 查某张卡详情 `{cardID}` → `cardInfo.left` / `ownerUin` | ✅ | +| `CreatCard` | 建卡 `{source: 0}` → `cardID` + `cardInfo.left` | ✅ | +| `GetCard` | **好友领取**用 `{cardID, source}` | ✅ | `source` 是落地页 ADTAG 映射:`xqy`=1 / `cdy`=2 / `privilege`=3 / 其他=0。 错误码:`100205`=当月已领取过 · `100210`=体验券已被领取 · `100211`=领取失败。 -### 用法 - -```bash -# 只读:看本月还能送几张(不建卡、不占配额) -node scrip_qqmusic_checkin.js - -# 建卡:有剩余配额时创建一张,输出分享链接 -QM_SHARE_CARD=on node scrip_qqmusic_checkin.js -``` - -建卡成功后脚本会打印: - -``` -[分享卡] 本月配额 5 张,已送出 1 张,剩余 4 张 -[分享卡] 已创建:剩余可领 4 次 -[分享卡] 分享链接:https://y.qq.com/m/vip/experience_card/dist/index.html?cardid= -``` - -链接发给好友,对方打开后自己点「立即领取」即可(脚本不代领,代领会撞 `100205`/`100210`)。 - -**默认 off 的理由**:`CreatCard` 会真的占掉一张配额,而卡是给人送的、不是自己用的 —— -自动建卡只会堆出没人领的废卡。所以默认只报数,要送人时手动置 `QM_SHARE_CARD=on` 跑一次。 - ## 凭证获取(web 抓一次,之后无限续) 1. 浏览器打开 [y.qq.com](https://y.qq.com) 登录(QQ/微信均可) diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index 45d5f76..155d265 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -448,43 +448,74 @@ class QMClient { * 也返回真实配额表,说明该 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 张。 + * 有效期内的豪华绿钻每 31 天可按等级赠送「不同非会员好友」3 天体验券, + * LV1-2 各 1 张、LV3 2 张、LV4-5 各 3 张、LV6-7 各 5 张、LV8 7 张(LV9 10、LV10 15)。 * 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。 * - * 默认只读:只报「本月还能送几张」,不 CreatCard(CreatCard 会真的占配额)。 - * QM_SHARE_CARD=on 时才在「配额有剩余 + 本月卡已发完/没卡」时创建一张新卡。 */ - async shareCard() { - // ① 等级配额表(不校验登录,任何账号都能读到全等级档位;仅用于展示上限) + * ★ 复用优先(照抄落地页行为):页面上的按钮逻辑是 + * 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) + // ② 本月卡列表 + 已送出记录(需要登录态;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。 + // 配额来源:本月卡 sum 优先;本月没卡时取 confInfo 最大档位作上限参考。 + // 不能拿 confInfo[0] 兜底 —— 表里没有 0 号档,取到 undefined 会让 remain 恒为 0。 const maxQuota = Math.max(0, ...Object.values(conf).map(Number).filter(Number.isFinite)) const hasCard = cards.length > 0 && Number.isFinite(Number(last.sum)) const quota = hasCard ? Number(last.sum) : maxQuota - const remain = hasCard ? Math.max(0, quota - sentList.length) : quota + const canSend = hasCard ? Math.max(0, quota - sentList.length) : quota - if (hasCard) { - log(`[分享卡] 本月配额 ${quota} 张,已送出 ${sentList.length} 张,剩余 ${remain} 张`) - } else { - log(`[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`) + 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 (!SHARE_CARD) { + if (!force && !SHARE_CARD) { log('[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡') return 0 } - if (remain <= 0) { + if (canSend <= 0) { log('[分享卡] 本月配额已用完,下月再送') return 0 } - // ③ 有配额才建卡:CreatCard 返回 cardID + cardInfo.left(该卡可被领次数) + // ④ 本月没有可复用的卡,才建新的:CreatCard 返回 cardID + cardInfo.left const created = (await this.api('music.vip.VipFreeCardSvr', 'CreatCard', { source: 0 })).data || {} const cardId = created.cardID if (!cardId) { @@ -493,7 +524,7 @@ class QMClient { } const left = (created.cardInfo || {}).left const url = `${CARD_LANDING}?cardid=${cardId}` - log(`[分享卡] 已创建:剩余可领 ${left != null ? left : '?'} 次`) + log(`[分享卡] 已新建:还能被领 ${left != null ? left : '?'} 次`) log(`[分享卡] 分享链接:${url}`) this.shareCardUrl = url // 供汇总输出 return 0 // 不产成长值,不计入 total @@ -610,12 +641,43 @@ function parseAccounts() { } 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} =====`)