feat(qqmusic): 手动取卡模式 + 复用本月未领完的卡

两件事:

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
This commit is contained in:
2026-11-04 22:57:49 +08:00
parent 2bfddc7e04
commit 656442e4d6
2 changed files with 136 additions and 46 deletions

View File

@@ -15,7 +15,8 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node
| `QM_KEY` / `QM_UIN` | 备选 | 单账号兼容形态(`SH_QQMUSIC_KEY` 优先) | | `QM_KEY` / `QM_UIN` | 备选 | 单账号兼容形态(`SH_QQMUSIC_KEY` 优先) |
| `QM_CACHE_DIR` | 可选 | key 缓存目录,默认 `/ql/data/qqmusic_cache`(青龙)/ `qqmusic/.cache`(本地) | | `QM_CACHE_DIR` | 可选 | key 缓存目录,默认 `/ql/data/qqmusic_cache`(青龙)/ `qqmusic/.cache`(本地) |
| `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) | | `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) |
| `QM_SHARE_CARD` | 可选 | `on` 才创建 VIP 体验券(分享卡)。默认 off = 只读探测「本月还能送几张」,不消耗配额 | | `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 |
| `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) |
## VIP 体验券(分享卡) ## VIP 体验券(分享卡)
@@ -32,46 +33,73 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| 张数 | 1 | 1 | 2 | 3 | 3 | 5 | 5 | 7 | | 张数 | 1 | 1 | 2 | 3 | 3 | 5 | 5 | 7 |
(实测配额表还有 LV9=10、LV10=15)
**卡只能送人,自己领不了自己的**(`CheckCard` 的 `ownerUin` 判定)。 **卡只能送人,自己领不了自己的**(`CheckCard` 的 `ownerUin` 判定)。
好友领取限制:非会员自然月内限领 1 张,体验券仅周期内有效、不可累计。 好友领取限制:非会员自然月内限领 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=<cardID>
👉 复制这条链接发给对方:
https://y.qq.com/m/vip/experience_card/dist/index.html?cardid=<cardID>
```
**② 定时任务路径(默认只读)**——每天签到顺带报个数,不建卡:
```
[分享卡] 本月配额 5 张,已被领 2 张,还能送 3 张
[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡
```
要让定时任务也自动建卡(不建议,会堆废卡)才设 `QM_SHARE_CARD=on`。
### 接口清单 ### 接口清单
| method | 作用 | 需要登录 | | method | 作用 | 需要登录 |
|---|---|---| |---|---|---|
| `GetCanNumConf` | 全等级配额表 `{等级: 张数}` | ❌ | | `GetCanNumConf` | 全等级配额表 `{等级: 张数}` | ❌ |
| `CheckCurrentCardList` | 本月卡列表 + 已送出 uin 列表 | ✅ | | `CheckCurrentCardList` | 本月卡列表 + 已被领 uin 列表 | ✅ |
| `CreatCard` | 建卡,`param: {source: 0}` → 返回 `cardID` + `cardInfo.left` | ✅ | | `CheckCard` | 查某张卡详情 `{cardID}` → `cardInfo.left` / `ownerUin` | ✅ |
| `CheckCard` | 查某张卡详情(`param: {cardID}`) | ✅ | | `CreatCard` | 建卡 `{source: 0}` → `cardID` + `cardInfo.left` | ✅ |
| `GetCard` | **好友领取**用(`param: {cardID, source}`) | ✅ | | `GetCard` | **好友领取**用 `{cardID, source}` | ✅ |
`source` 是落地页 ADTAG 映射:`xqy`=1 / `cdy`=2 / `privilege`=3 / 其他=0。 `source` 是落地页 ADTAG 映射:`xqy`=1 / `cdy`=2 / `privilege`=3 / 其他=0。
错误码:`100205`=当月已领取过 · `100210`=体验券已被领取 · `100211`=领取失败。 错误码:`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=<cardID>
```
链接发给好友,对方打开后自己点「立即领取」即可(脚本不代领,代领会撞 `100205`/`100210`)。
**默认 off 的理由**:`CreatCard` 会真的占掉一张配额,而卡是给人送的、不是自己用的 ——
自动建卡只会堆出没人领的废卡。所以默认只报数,要送人时手动置 `QM_SHARE_CARD=on` 跑一次。
## 凭证获取(web 抓一次,之后无限续) ## 凭证获取(web 抓一次,之后无限续)
1. 浏览器打开 [y.qq.com](https://y.qq.com) 登录(QQ/微信均可) 1. 浏览器打开 [y.qq.com](https://y.qq.com) 登录(QQ/微信均可)

View File

@@ -448,43 +448,74 @@ class QMClient {
* 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。 * 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。
* *
* 语义(官方特权页 https://y.qq.com/m/myvip/v6/privilege_detail.html?id=experience_card): * 语义(官方特权页 https://y.qq.com/m/myvip/v6/privilege_detail.html?id=experience_card):
* 有效期内的豪华绿钻会员每 31 天可按等级赠送「不同非会员好友」3 天体验券, * 有效期内的豪华绿钻每 31 天可按等级赠送「不同非会员好友」3 天体验券,
* LV1-2 各 1 张、LV3 2 张、LV4-5 各 3 张、LV6-7 各 5 张、LV8 7 张。 * LV1-2 各 1 张、LV3 2 张、LV4-5 各 3 张、LV6-7 各 5 张、LV8 7 张(LV9 10、LV10 15)。
* 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。 * 卡只能送人,自己领不了自己的(CheckCard 的 ownerUin 判定)。
* *
* 默认只读:只报「本月还能送几张」,不 CreatCard(CreatCard 会真的占配额)。 * ★ 复用优先(照抄落地页行为):页面上的按钮逻辑是
* QM_SHARE_CARD=on 时才在「配额有剩余 + 本月卡已发完/没卡」时创建一张新卡。 */ * createCard(d ? cardId : "")
async shareCard() { * 即「已经有卡就复用那张,没有才建新的」。所以本方法先扫本月卡,
// ① 等级配额表(不校验登录,任何账号都能读到全等级档位;仅用于展示上限) * 找到 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 || {} 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 listData = (await this.api('music.vip.VipFreeCardSvr', 'CheckCurrentCardList', {})).data || {}
const cards = listData.cardInfoList || [] const cards = listData.cardInfoList || []
const last = cards[cards.length - 1] || {} const last = cards[cards.length - 1] || {}
const sentList = last.reciveUinList || [] // 服务端字段名就是 reciveUinList(拼写如此) const sentList = last.reciveUinList || [] // 服务端字段名就是 reciveUinList(拼写如此)
// 配额来源:本月卡的 sum(服务端实发配额)优先;没有卡时取 confInfo 的最大档位作上限参考。 // 配额来源:本月卡 sum 优先;本月没卡时取 confInfo 最大档位作上限参考。
// 注意不能拿 confInfo[0] 兜底 —— 表里没有 0 号档,取到 undefined 会让 remain 恒为 0。 // 不能拿 confInfo[0] 兜底 —— 表里没有 0 号档,取到 undefined 会让 remain 恒为 0。
const maxQuota = Math.max(0, ...Object.values(conf).map(Number).filter(Number.isFinite)) const maxQuota = Math.max(0, ...Object.values(conf).map(Number).filter(Number.isFinite))
const hasCard = cards.length > 0 && Number.isFinite(Number(last.sum)) const hasCard = cards.length > 0 && Number.isFinite(Number(last.sum))
const quota = hasCard ? Number(last.sum) : maxQuota 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(hasCard
log(`[分享卡] 本月配额 ${quota} 张,已送出 ${sentList.length} 张,剩余 ${remain} 张`) ? `[分享卡] 本月配额 ${quota} 张,已被领 ${sentList.length} 张,还能送 ${canSend} 张`
} else { : `[分享卡] 本月还没建过卡(等级上限参考 ${maxQuota} 张)`)
log(`[分享卡] 本月还没建过卡(等级上限参考 ${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),不创建新卡') log('[分享卡] 只读模式(QM_SHARE_CARD 未置 on),不创建新卡')
return 0 return 0
} }
if (remain <= 0) { if (canSend <= 0) {
log('[分享卡] 本月配额已用完,下月再送') log('[分享卡] 本月配额已用完,下月再送')
return 0 return 0
} }
// ③ 有配额才建卡:CreatCard 返回 cardID + cardInfo.left(该卡可被领次数) // ④ 本月没有可复用的卡,才建新的:CreatCard 返回 cardID + cardInfo.left
const created = (await this.api('music.vip.VipFreeCardSvr', 'CreatCard', { source: 0 })).data || {} const created = (await this.api('music.vip.VipFreeCardSvr', 'CreatCard', { source: 0 })).data || {}
const cardId = created.cardID const cardId = created.cardID
if (!cardId) { if (!cardId) {
@@ -493,7 +524,7 @@ class QMClient {
} }
const left = (created.cardInfo || {}).left const left = (created.cardInfo || {}).left
const url = `${CARD_LANDING}?cardid=${cardId}` const url = `${CARD_LANDING}?cardid=${cardId}`
log(`[分享卡] 已创建:剩余可领 ${left != null ? left : '?'} 次`) log(`[分享卡] 已新建:还能被领 ${left != null ? left : '?'} 次`)
log(`[分享卡] 分享链接:${url}`) log(`[分享卡] 分享链接:${url}`)
this.shareCardUrl = url // 供汇总输出 this.shareCardUrl = url // 供汇总输出
return 0 // 不产成长值,不计入 total return 0 // 不产成长值,不计入 total
@@ -610,12 +641,43 @@ function parseAccounts() {
} }
async function main() { 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 <脚本> <args>)。
// 也支持环境变量 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() const accounts = parseAccounts()
if (!accounts.length) { if (!accounts.length) {
log('[QQ音乐] 未配置 SH_QQMUSIC_KEY(或 QM_KEY/QM_UIN),跳过') log('[QQ音乐] 未配置 SH_QQMUSIC_KEY(或 QM_KEY/QM_UIN),跳过')
return return
} }
const dir = cacheDir() 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 = [] const blocks = []
for (const a of accounts) { for (const a of accounts) {
log(`\n===== 账号 ${a.uin} =====`) log(`\n===== 账号 ${a.uin} =====`)