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:
@@ -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/微信均可)
|
||||||
|
|||||||
@@ -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} =====`)
|
||||||
|
|||||||
Reference in New Issue
Block a user