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

@@ -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 <脚本> <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()
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} =====`)