Files
ql-scripts/qqmusic/README.md
zhuhjay f5c9ca6fa1 feat(qqmusic): 标题带结论 + 首行放摘要,折叠状态也能看关键信息
用户截图确认:pushplus 的微信卡片只显示「标题 QQ音乐签到 + 正文第一行」,
后面的明细全折叠。查 pushplus 官方文档,这是平台默认行为 ——
服务号模板消息默认不展示具体内容,必须点「查看详情」。

三条官方解法都要额外成本(发「激活消息」24h 内限 5 条 / 开会员 /
绑定自己认证的服务号每年 300 元),所以不改通道,改信息密度:

- 标题从固定的「QQ音乐签到」改成三态:QQ音乐签到 ✅ / 🟡 / ⚠️
- 正文第一行从「✅ QQ音乐签到 · 全部完成」改成带数字的摘要:
  「✅ 全部完成 · 成长值 +35」「✅ 已完成 · 成长值 +15 · 1 项待处理」
- 引入三态结论 state:done / warn(有 △ 但无 ✗,仍算完成)/ fail
- 调用 sendNotify 时第四参传空串,去掉尾部「本通知 By:github.com/...」

标题不放数字的理由:平台抑制指纹是 sha256(kind|credId|title去数字),
数字本来就被 titleKeyOf 剥掉,带 +35 / +15 算同一指纹,抑制不会失效。

顺带修 main 里的累加:blocks 从数组改成 {body,total,warn},
成长值合计与待处理项数在拼装前算好,摘要行才能直接用。

验证:node --check / 签名对拍 6 组 PASS / 5 个场景
(全好、有遗留、有失败、登录失效、双账号)的 state+标题+首行逐字比对 PASS /
_probe_meta 四文件 PASS
2026-11-05 20:43:22 +08:00

336 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# QQ音乐自动签到(scrip_qqmusic_checkin.js)
QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node 零依赖。
协议底座与 [LDP924/auto-checkin](https://github.com/LDP924/auto-checkin)(Python)逐字节同源:
`u6.y.qq.com/cgi-bin/musics.fcg` 网关 + `zzc` 签名(sha1 重组 + 置乱表 + b64 去尾)+ `qm_keyst` cookie。
> **文件名必须带 `scrip_` 前缀**:本仓库的青龙订阅规则白名单是 `scrip_`,
> 不带前缀的文件不会被订阅拉取(脚本、测试、文档同一约定)。
## 环境变量
| 变量 | 必填 | 说明 |
|---|---|---|
| `SH_QQMUSIC_KEY` | ✅ | 多账号:青龙同名 env 加多条(注入自动 `&` 连接)。**单条格式 `<uin>#<qm_keyst>`**,如 `123456789#Q_H_L_xxxx`。也接受整串 cookie(含 `qm_keyst=` 与 `uin=o...`) |
| `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` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 |
| `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) |
## VIP 体验券(分享卡)
`music.vip.VipFreeCardSvr`,2026-11-04 从体验券落地页
`y.qq.com/m/vip/experience_card/dist/index.html` 的 `index.036b2e716.js` 逆出。
接口在 u6 网关实测路由通(`GetCanNumConf` 用假 key 也返回真实配额表,说明该 method 不校验登录;
`CreatCard` / `CheckCurrentCardList` 返回 `1000`=未登录)。
### 这卡是什么
有效期内的豪华绿钻会员,每 31 天可按等级**赠送给不同的非会员好友** 3 天体验券:
| 等级 | LV1 | LV2 | LV3 | LV4 | LV5 | LV6 | LV7 | LV8 |
|---|---|---|---|---|---|---|---|---|
| 张数 | 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=<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 | 作用 | 需要登录 |
|---|---|---|
| `GetCanNumConf` | 全等级配额表 `{等级: 张数}` | ❌ |
| `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`=领取失败。
## 凭证获取(web 抓一次,之后无限续)
1. 浏览器打开 [y.qq.com](https://y.qq.com) 登录(QQ/微信均可)
2. F12 → Application → Cookies → `https://y.qq.com` → 复制 `qm_keyst` 与 `uin`(`o123456789` 去掉 `o`)
3. 青龙新建环境变量 `SH_QQMUSIC_KEY`,值 `uin#qm_keyst`
**为什么抓一次就够**:签名/`g_tk` 全是本地算法(无盐、无设备绑定、无动态 token);
`qm_keyst` 名义 72h,但续期接口 `QQConnectLogin.LoginServer.QQLogin` 旧 key 不吊销——
脚本每次运行先续期,新 key 写缓存滚动,环境变量永远不用再动。
只有脚本断跑超 72h 且缓存丢失(推送出现 🔴 2000)才需要重新抓。
## 缓存与订阅安全
续期产物存 `/ql/data/qqmusic_cache/qm_<uin>.json`(**订阅仓库之外**)。
git 的 reset/clean 只动 tracked 文件,untracked 目录在 `git pull` 下天然安全;
且脚本读缓存优先、env 兜底,缓存丢失仅退化为"72h 内重新抓一次"。
## 定时
```
cron: 1 0,6 * * *
```
每天 00:01 + 06:02 双轮:任务20(收听节目)跨天滚动翻账本 + 福利站 0 点翻新兜底。
### 青龙自动建任务的元数据约定(踩坑记录 2026-11-04)
订阅勾了「自动添加定时任务」时,青龙从**脚本内容**里正则提取任务名与 cron,两个坑:
| 项 | 青龙的提取规则 | 必须怎么写 |
|---|---|---|
| **任务名** | `/name:\s*(.+)/`(**不锚定行首**),取**全文第一个**命中 | 脚本第 3 行写 `* name: QQ音乐自动签到`。**全文任何位置都不能再出现「name+冒号」的连写**——`https.request({ hostname: u.hostname })` 会被抓成任务名 `u.hostname,`(真机已复现) |
| **cron** | `/\bcron:\s*([^\r\n]+)/`(**必须带冒号**) | 写 `* cron: 1 0,6 * * *`,且**独占一行、后面不跟注释**(同一行剩余文字会被一起吞进 schedule) |
两个都抓不到时的兜底:任务名 = 文件名 `scrip_qqmusic_checkin.js`;cron = 随机 `M H * * *`。
所以脚本里:
- `https.request` 的选项用 `host:`(Node 的 `hostname` 别名)而不是 `hostname:`
- cron 注解独占一行
## 通知内容
### 为什么标题和首行才是关键
pushplus 的微信渠道用的是**公众号模板消息**,官方明确:
> 为了避免不必要的麻烦,使用 pushplus 的服务号推送模板消息**默认是不展示具体的消息内容的**,
> 需要点击链接后查看内容详情。
> —— [pushplus 文档 · 如何直接在服务号中显示推送内容](https://pushplus.plus/doc/help/showmessage.html)
所以通知栏能看到的只有:**标题 + 正文第一行**。后面的明细一律折叠。
三条官方解法(都要额外成本):
| 方案 | 代价 |
|---|---|
| 在服务号发「激活消息」 | 24 小时内只有 5 条走客服消息,之后要重新激活 |
| 开通 pushplus 会员 | 付费,模板能多显示一部分 |
| 绑定自己的服务号 | 企业/个体工商户认证,每年 300 元 |
**所以在不改通道的前提下,唯一能做的就是:把关键信息压进标题和首行。**
### 现在的格式
```
标题:QQ音乐签到 ✅
首行:✅ 全部完成 · 成长值 +35
🟢 1006764347:成长值 +35
✓ 任务中心签到 +15
✓ 领奖·头像挂件 +10
· 无需处理 1 项:福利站礼券
———
轮次: 00:01 / 06:02 双轮
时间: 2026/11/5 20:10:33
```
**折叠状态下就能看到 `QQ音乐签到 ✅` 和 `✅ 全部完成 · 成长值 +35`**,够判断要不要点开。
### 三态结论
| state | 标题 | 首行 | 触发条件 |
|---|---|---|---|
| `done` | `QQ音乐签到 ✅` | `✅ 全部完成 · 成长值 +N` | 全 `✓` 或 `·` |
| `warn` | `QQ音乐签到 🟡` | `✅ 已完成 · 成长值 +N · K 项待处理` | 有 `△` 但无 `✗` |
| `fail` | `QQ音乐签到 ⚠️` | `⚠️ 有未完成项 · 成长值 +N · K 项待处理` | 有 `✗` 或登录失效 |
`warn` 仍算「完成」——任务20 等 App 内收听属于正常遗留,不该报成失败。
### 明细的四个符号
| 符号 | 含义 |
|---|---|
| `✓` | 做到了(带结果,如 `+15`、`retCode 0`) |
| `△` | 部分完成 / 留待下次(任务20未计次、写锁) |
| `✗` | 失败(接口报错、code 非预期) |
| `·` | 无需处理(今日已完成 / 未开放),**折叠成一行** |
`·` 折叠而不是省略 —— 折叠能看出「今天该做的都做了」,省略会让人怀疑漏跑。
明细来源是 `QMClient.steps`:每个动作调一次 `step(name, status, detail)`,
`verdict()` 汇总。新增任务时记得补一句 `step()`,否则它在通知里不可见。
### 两个细节
**① 标题只放状态、不放数字。** 平台的抑制指纹是
`sha256(kind|credId|title.replace(/[\d\s]+/g, ''))` —— 数字本来就会被剥掉,
标题带 `+35` 和 `+15` 算同一个指纹,不会破坏「1 小时同指纹只发一条」。
**② 调用时第四参传空串**,否则 `sendNotify` 会往正文尾部追加
`本通知 By:https://github.com/whyour/qinglong`,这行会一起进 `notify_events.content`。
```js
await sendNotifyFn(title, desp, {}, '')
```
## 通知发不出去怎么查
```bash
# 青龙里手动跑一次(cron 设成 1 月 1 日,不会自动跑)
node scrip_diag_notify.js
```
六段逐项定位,token 全程脱敏:
```
1. sendNotify 加载 ← 容器缺 got 时 require 抛错,被静默吞掉
2. 通知 env ← 青龙「环境变量」是否真的保存了
3. webhook 配置分析 ← 三个前置条件 + body/headers 解析复刻
4. 直打平台 /api/hooks/ql ← 按状态码定位卡在哪一道校验
5. 直连 pushplus 模板对比 ← txt vs html,确认是不是展示折叠
6. 完整 sendNotify ← 与第 4 节对照,区分通道问题与展示问题
```
### 坑 0:平台回 204,原版 sendNotify 判成失败(已修)
`/api/hooks/ql` 按设计返回 **204**(表示已接收,且不写 event_logs),
而原版 `sendNotify.js` 的判据是 `resp.statusCode !== 200`:
```js
if (resp.statusCode !== 200) {
console.log(`自定义发送通知消息失败!!\n${resp.body}`); // 204 也走这里
}
```
**所以「自定义发送通知消息失败!!」是假警报 —— 平台其实收到了。**
已改成 2xx 一律算成功,并把状态码打进日志:
```js
if (resp.statusCode < 200 || resp.statusCode >= 300) {
console.log(`自定义发送通知消息失败!!HTTP ${resp.statusCode}\n${resp.body}`);
} else {
console.log(`自定义发送通知消息成功🎉。HTTP ${resp.statusCode}\n${resp.body || ''}`);
}
```
### 坑 1:`require('../sendNotify')` 静默失败
原本 `catch { /* 仅控制台 */ }`,缺 `got` 时**一条通知都发不出去且日志无痕**。现在会打一行:
```
[通知] sendNotify 不可用(Cannot find module 'got'),本轮不发推送
```
修:`docker exec -it qinglong pnpm install got`
### 坑 2:WEBHOOK_BODY 写成单行 JSON 会被解析成空
`sendNotify` 的 `parseString` 用 `/(\w+):\s*((?:(?!\n\w+:).)*)/g`,**靠换行 + `\w+:` 断值**。
单行 JSON 没有换行,整个 body 被吞掉,实测解析结果 `{}`(0 个 key):
```js
'{"title":"$title","content":"$content"}' → {} ✗
'title:$title\ncontent:$content' → 两个 key ✓
```
body 变成 `{}` → 平台 `JSON.parse` 得到空对象 → 400「title 与 content 不能同时为空」。
### 坑 3:webhook 三个前置条件,缺一个就静默跳过
`webhookNotify` 开头逐行 return,**不发请求也不报错**:
1. `WEBHOOK_URL` 或 `WEBHOOK_BODY` **至少一个**含 `$title`
2. `WEBHOOK_METHOD` **必须显式设**(空则走 else 分支 resolve)
3. body 能被 `parseString` 正确解析(见坑 2)
### 坑 4:pushplus 只显示一行 ≠ 内容丢了
微信通知卡片默认只展示开头,点开才展开全文。要确认是折叠还是真丢:
第 5 节会同时发 `txt` 和 `html` 两条,对比哪个正文完整。
- txt 完整 / html 一行 → 把 pushplus 目标改成 `template=txt`
- 两条都一行 → 微信卡片折叠,点开即全文,不是 bug
### 指向平台的完整配置
```bash
WEBHOOK_URL=https://<平台域名>/api/hooks/ql
WEBHOOK_METHOD=POST
WEBHOOK_CONTENT_TYPE=application/json
WEBHOOK_BODY=title:$title
content:$content
WEBHOOK_HEADERS=Content-Type: application/json
x-sh-hook-token: <平台 QL_HOOK_TOKEN>
```
平台侧还有两道硬校验(`hookGuard`):
- **来源 IP 必须是 127.0.0.1 或 172.16/12**(docker 内网)。青龙与平台不同容器时,
走公网域名会让源 IP 变成反代地址 → 403。要用**容器内网地址**(如 `http://script-helper:3000`)。
- 回环地址带 `X-Forwarded-For` 一律 403(说明穿过了反代)。
## 任务覆盖
| 层 | 内容 | 风险 |
|---|---|---|
| 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) |
| 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) |
| VIP 体验券 | 分享卡配额查询 / 建卡(`QM_SHARE_CARD=on`) | 低(只读默认,不消耗配额) |
| 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) |
| 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 |
状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。
错误码处置:`20020` 冷却3s重领 · `30005` 只读探测后补领 · `10006` 写锁即停次日恢复 · `2000` 登录态失效推送提示。
## 本地测试
```bash
node --check scrip_qqmusic_checkin.js # 语法
node scrip_qqmusic_checkin.js # 无 env 时优雅跳过
SH_QQMUSIC_KEY='123#Q_H_L_x' node -e "console.log(require('./scrip_qqmusic_checkin').parseAccounts())"
```