Files
ql-scripts/qqmusic
zhuhjay 57a49ab7f5 fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
2026-11-07 18:50:04 +08:00
..

QQ音乐自动签到(scrip_qqmusic_checkin.js)

QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node 零依赖。 协议底座与 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(不方便传参时用)
QM_COIN_SIGN 可选 金币中心签到开关,默认 on。off 整体跳过(纯签到+领奖,不花金币、不伪造行为)
QM_COIN_ACT_ID 可选 金币签到 ActID,默认 Z25hHGi。活动换季会变,失效时抓包改这里
QM_COIN_SCENE_ID 可选 金币签到 ScenesID,默认 2
QM_COIN_LOTTERY_ACT_ID 可选 金币抽奖签到 ActID,默认 Z156KEu,同样会换季
WEBHOOK_URL 可选 自定义 webhook(如平台 /api/hooks/ql)。与 PUSH_PLUS_TOKEN 至少配一个
WEBHOOK_METHOD 可选 默认 POST
WEBHOOK_CONTENT_TYPE 可选 默认 application/json
WEBHOOK_BODY 可选 支持 $title / $content 占位;不填则按 Content-Type 给默认形状
WEBHOOK_HEADERS 可选 一行一个 Key: Value,必须是真换行
PUSH_PLUS_TOKEN 可选 pushplus 直发
PUSH_PLUS_TOPIC 可选 pushplus 一对多群组编码(对应 PUSH_PLUS_USER)
PUSH_PLUS_TEMPLATE 可选 默认 txt(不转义 html、不折叠);html 会把换行变 <br>

通知由本脚本自己实现(notify()),不依赖青龙的 sendNotify.js —— 那份文件每轮都会被 /ql/data/deps/* 覆盖,改它不生效。详见「通知发不出去怎么查 · 坑 0」。

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 登录(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 注解独占一行

通知内容

每轮跑完推送一条,格式固定:

✅ QQ音乐签到 · 全部完成

🟢 123456789:成长值 +35
   ✓ 任务中心签到 +15
   ✓ 挂件 retCode 0
   ✓ 领奖·头像挂件 +10
   ✓ 福利站签到 5成长值(本月 12 天)
   △ 领奖·收听节目 未计次,待 App 内收听
   · 无需处理 3 项:福利站礼券、月里程碑、分享卡
   🎁 分享卡 https://y.qq.com/m/vip/experience_card/dist/index.html?cardid=xxx

———
轮次: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底)
节目任务(20)依赖 App 内正常收听计入(播放上报未启用)
时间: 2026/11/4 23:10:05

标题就是结论:✅ 全部完成 / ⚠️ 有未完成项,不用点开就知道要不要管。

每个账号下分四类:

符号 含义
✓ 做到了(带结果,如 +15、retCode 0)
△ 部分完成 / 留待下次(如任务20未计次、写锁)
✗ 失败(接口报错、code 非预期)
· 无需处理(今日已完成 / 未开放),折叠成一行,不占版面

判定规则:有任何 ✗ → 标题变 ⚠️;只有 △ → 仍算完成(有遗留但不影响); 全 ✓/· → ✅。登录态失效单独用 🔴 并直接给处理指引,不进明细列表。

明细来源是 QMClient.steps:每个动作调一次 step(name, status, detail), verdict() 汇总。新增任务时记得补一句 step(),否则它在通知里不可见。

通知发不出去怎么查

坑 0(最狠的一个):仓库的 sendNotify.js 每轮都被覆盖

症状:改了 sendNotify.js,青龙跑起来行为不变;日志里永远是旧版那句话。

原因:青龙 shell/update.sh 的 gen_list_repo 里,文件复制是三步,后一步覆盖前一步:

407:  cp -f $file_notify_js "${dir_scripts}/${uniq_path}"          # ① 面板自带的 sendNotify.js
408:  cp -f $file_notify_py "${dir_scripts}/${uniq_path}"

410:  if [[ $dependence ]]; then
411:    results=$(find . -name "*.js" | egrep "$dependence")
415:      cp -f $_file "${dir_scripts}/${uniq_path}/${file_path}"   # ② 仓库的 sendNotify.js(覆盖①)
417:  fi

420:  if [[ -d $dir_dep ]]; then
421:    cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" &>/dev/null  # ③ /ql/data/deps/*(再覆盖②)
422:  fi

$dir_dep 是 /ql/data/deps,青龙初始化时往里面拷了一份面板自带的 notify.js:

# shell/share.sh:228
if [[ ! -s $dep_notify_js ]]; then
  cp -f "$file_notify_js_sample" "$dep_notify_js"   # sample/notify.js → /ql/data/deps/sendNotify.js
fi

③ 排在最后,所以最终文件永远是面板自带的那份。 仓库里的 sendNotify.js 白改。

验证:

docker exec -it qinglong sh -c '
  md5sum /ql/data/deps/sendNotify.js /ql/data/scripts/zhuhjay_ql-scripts_master/sendNotify.js
'

两个 md5 相同 = 被覆盖了。

结论:通知实现放在仓库根的 common.js,脚本 require('../common') 取用。

const { notify } = require('../common')
await notify('QQ音乐签到', desp)   // → { ok, channels: [{name, ok, detail}] }

common.js 不在 deps 里(deps 只有 notify.py 和 sendNotify.js),所以安全。 后续任何脚本想发通知,同样一行 require('../common') 即可,不用再碰 sendNotify.js。

坑 1:sendNotify 的三个静默 resolve()

如果哪天又接回 sendNotify,注意 webhookNotify 开头逐行 return,不发请求也不报错:

  1. WEBHOOK_URL 或 WEBHOOK_BODY 至少一个含 $title
  2. WEBHOOK_METHOD 必须显式设(空则走 else 分支 resolve)
  3. body 能被 parseString 正确解析(见坑 2)

坑 2:WEBHOOK_BODY 写成单行 JSON 会被解析成空

sendNotify 的 parseString 用 /(\w+):\s*((?:(?!\n\w+:).)*)/g,靠换行 + \w+: 断值。 单行 JSON 没有换行,整个 body 被吞掉,实测解析结果 {}(0 个 key):

'{"title":"$title","content":"$content"}'   → {}          ✗
'title:$title\ncontent:$content'            → 两个 key     ✓

body 变成 {} → 平台 JSON.parse 得到空对象 → 400「title 与 content 不能同时为空」。

本脚本的 buildHookBody 不受这个坑影响 —— 它对 application/json 会先试 JSON.parse,不合法就直接 JSON.stringify({title, content}) 重建,单行多行都行。

坑 3:pushplus 通知栏只显示标题 + 首行

微信服务号模板消息默认不展示具体内容,点「查看详情」才展开全文。 pushplus 官方给了三种解法(文档):

方案 代价
在服务号发「激活消息」 24 小时内 5 条走客服消息,能显示全文
开通 pushplus 会员 付费,模板能多显示一部分
绑定自己的服务号 企业/个体工商户认证,每年 300 元

不是 bug,是通道限制。 要通知栏直接看到明细,换 Bark / Telegram / 企业微信机器人。

自检

# common.js 的 notify:69 条断言(纯函数 + 八种状态码 + Env 兼容性)
node test_common_notify.js
RESULT: PASS  (69 passed, 0 failed)

指向平台的完整配置

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)
金币中心 金币签到 + 领当日奖励 + 金币抽奖签到(QM_COIN_SIGN,默认 on) 低(纯签到领奖,不花金币、不伪造行为)
VIP 体验券 分享卡配额查询 / 建卡(QM_SHARE_CARD=on) 低(只读默认,不消耗配额)
播放上报 任务20 完整 +10 需 App 正常收听(伪造统计默认关) 中(默认关闭)
支付流水任务 4/5/6/12/14/18/21 不可伪造,不碰

状态三态机:Y=已完成零请求(防风控)/ W=进行中直接领 / N=执行动作。 错误码处置:20020 冷却3s重领 · 30005 只读探测后补领 · 10006 写锁即停次日恢复 · 2000 登录态失效推送提示。

金币中心(第三套体系)

与任务中心、福利站完全独立:金币体系走 musicu.fcg + 小程序 comm,另外两套走 u6.y.qq.com + zzc 签名。 给的是金币,不是成长值,所以不计入通知里的「成长值 +N」。

项目 值
网关 https://u.y.qq.com/cgi-bin/musicu.fcg(不签名,裸 JSON)
模块 music.actCenter.ActCenterSignNewSvr
comm { uin, authst, mina:1, appid:'wxada7aab80ba27074', ct:29, cv:0, format:'json' }
方法 GetSignInSummary / GetSignInTaskList / SignIn / AwardPrize

authst 就是 qm_keyst,放在 body 里(不走 Cookie)。没有 g_tk,也不签名。

流程

① GetSignInSummary + GetSignInTaskList   → info.IsSignIn / ContinueTaskList
② 未签 → SignIn { ActID, ScenesID }
③ 找 State===2 的 TaskList 项 → AwardPrize { ActID, TaskID }

retCode 口径:0 成功 · 100004 已领过(幂等,也算成功)· 1000 未登录 · 其他失败。

不做的部分

功能 为什么不做
金币抽奖(drawCoinLottery) 消耗抽奖券,属于「花金币」不是「赚金币」
红包雨 要卡 6 个时段各一次,青龙 cron 得加 6 条
每日任务列表(浏览页 +3 豆那类) 完成上报发生在跳转页内部埋点,要逆向各 H5 协议
定时金币 服务端按各自间隔下发,要维护下次查询时间,收益低

ActID 换季

ActID 是活动实例 ID,活动换季就变。当前硬编码值:

COIN_ACT_ID      = Z25hHGi   (金币中心签到)
COIN_SCENE_ID    = 2
LOTTERY_ACT_ID   = Z156KEu   (金币抽奖签到)

失效时脚本会打:

[金币] 状态查询失败 code=... ret=...
[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID

并记 warn(不是 fail —— 不是登录态问题,也不是脚本 bug)。

修法:App 抓包看 ActCenterSignNewSvr 请求里的 ActID 参数,改环境变量 QM_COIN_ACT_ID, 不用改代码。

本地测试

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())"

node test_sign_run.js                     # 签名对拍(需先 python test_sign_vector.py 生成期望)
node ../test_common_notify.js             # 通知实现(69 条断言,见仓库根 README)
node ../_probe_meta.js                    # 青龙元数据注解

通知相关的测试在仓库根 test_common_notify.js(因为实现搬到了 common.js), 覆盖:

  • parseHookHeaders:多行 / 空行 / 无冒号 / 值含冒号 / 重复 key / 字面量 \n 不切开
  • buildHookBody:内容含双引号、反斜杠、制表符、多行时仍产出合法 JSON
  • notify:mock 服务测 200 / 204 / 201 / 401 / 403 / 400 / 429 / 连不上 八种
  • 无配置、双通道部分失败、Env 兼容性(jd / xiequ 在用)