From c7c341e10aca8009e53ec847279f217b5c771cf4 Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Wed, 4 Nov 2026 23:31:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqmusic):=20=E5=8F=96=E5=8D=A1=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=9B=9E=E4=BC=A0=E5=B9=B3=E5=8F=B0=EF=BC=88QM=5FCARD?= =?UTF-8?q?=5FCALLBACK=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动取卡目前要「登青龙 → 点运行 → 翻日志复制链接」。加一条可选回传: 脚本跑完把结果 POST 回平台,由平台转发到微信,手机上直接收到链接。 实现(脚本侧,零依赖 Node 内置 https): - QM_CARD_CALLBACK 回传地址(如 https://<平台域名>/api/hooks/card) - QM_CARD_TOKEN 鉴权 token,缺省回落 QL_HOOK_TOKEN - 回传体只有 {cards:[{uin, ok, url|reason}], at},**不含 cookie/key** —— 走公网不泄凭证 - 回传失败只记日志,绝不让取卡本身算失败(卡已建出、链接已在日志里) - 未配 QM_CARD_CALLBACK 时完全跳过,行为与之前一致 - 顺带把 pushCardResult 加进 module.exports,供单测直接调用 验证:node --check / 签名对拍 6 组 PASS / 4 个回传用例(未配跳过、正常 POST 含 token 头与 body 结构、URL 非法跳过、服务端 500 不抛)全 PASS / _probe_meta 三元数据 PASS --- qqmusic/scrip_qqmusic_checkin.js | 63 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index 155d265..2c9bd42 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -45,6 +45,10 @@ const PLAY_REPORT = String(process.env.QM_PLAY_REPORT || '').toLowerCase() === ' const SHARE_CARD = String(process.env.QM_SHARE_CARD || '').toLowerCase() === 'on' // 分享落地页:cardid 由 CreatCard 返回,好友打开后自己点领取(非会员自然月限领 1 张) const CARD_LANDING = 'https://y.qq.com/m/vip/experience_card/dist/index.html' +// 取卡结果回传地址(平台侧 /api/hooks/card 或自建 n8n/Server酱 中转)。置空则只打印到日志。 +const CARD_CALLBACK = String(process.env.QM_CARD_CALLBACK || '').trim() +// 回传鉴权 token:与平台 QL_HOOK_TOKEN 同一枚(平台 hookGuard 校验它) +const CARD_TOKEN = String(process.env.QM_CARD_TOKEN || process.env.QL_HOOK_TOKEN || '').trim() function time33(s) { let h = 5381 @@ -640,6 +644,53 @@ function parseAccounts() { return list } +/** 取卡结果回传平台(可选)。平台侧 /api/hooks/card 收到后按该用户的通知目标转发到微信。 + * 设计要点: + * - 只回传「取卡结果」,不含 cookie/key —— 走公网也不泄露凭证。 + * - 用 Node 内置 https,零依赖(与脚本其余部分一致)。 + * - 失败只打日志,绝不因回传失败让取卡本身算失败(卡已经建出来了,链接在日志里)。 + * - QM_CARD_CALLBACK 空 → 完全跳过(本地开发/不想接推送的场景)。 */ +function pushCardResult(results) { + if (!CARD_CALLBACK) { + log('[回传] 未配置 QM_CARD_CALLBACK,结果仅打印在本日志里') + return Promise.resolve() + } + const payload = JSON.stringify({ + cards: results, + at: new Date().toISOString(), + }) + return new Promise((resolve) => { + let u + try { u = new URL(CARD_CALLBACK) } catch { log(`[回传] QM_CARD_CALLBACK 不是合法 URL,跳过`); return resolve() } + const lib = u.protocol === 'http:' ? require('http') : https + const req = lib.request({ + host: u.hostname, + port: u.port || (u.protocol === 'http:' ? 80 : 443), + path: u.pathname + u.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(CARD_TOKEN ? { 'x-sh-hook-token': CARD_TOKEN } : {}), + 'Content-Length': Buffer.byteLength(payload), + }, + timeout: 15000, + }, (res) => { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + if (res.statusCode >= 200 && res.statusCode < 300) log(`[回传] 已推送(HTTP ${res.statusCode})`) + else log(`[回传] 失败 HTTP ${res.statusCode}: ${text.slice(0, 160)}`) + resolve() + }) + }) + req.on('timeout', () => { log('[回传] 超时,放弃(链接已在日志里)'); req.destroy(); resolve() }) + req.on('error', (e) => { log(`[回传] 失败: ${e.message}(链接已在日志里)`); resolve() }) + req.write(payload) + req.end() + }) +} + async function main() { // ---- 手动取卡模式(青龙「运行」按钮直接点,给人送卡用)---- // 触发:task qqmusic/scrip_qqmusic_checkin.js -- card @@ -647,6 +698,7 @@ async function main() { // 把 -- 后面的都收进 script_params,otask.sh 再 run_task_command node <脚本> )。 // 也支持环境变量 QM_CARD=1(定时任务里不方便传参时用)。 // 只跑取卡,不跑签到:手动送卡时不想额外刷一遍签到接口。 + // 置了 QM_CARD_CALLBACK 就把结果 POST 回平台,由平台转发到微信(见 pushCardResult)。 const cardOnly = process.argv.includes('card') || String(process.env.QM_CARD || '').toLowerCase() === 'on' || String(process.env.QM_CARD || '') === '1' @@ -659,6 +711,7 @@ async function main() { const dir = cacheDir() if (cardOnly) { + const results = [] for (const a of accounts) { log(`\n===== 取卡 账号 ${a.uin} =====`) try { @@ -667,14 +720,18 @@ async function main() { await cli.shareCard(true) // force=true:无视 QM_SHARE_CARD 开关 if (cli.shareCardUrl) { log(`\n👉 复制这条链接发给对方:\n${cli.shareCardUrl}\n`) + results.push({ uin: a.uin, ok: true, url: cli.shareCardUrl }) } else { log('[分享卡] 本次没有可用的卡(配额用完 / 本月卡都已被领完)') + results.push({ uin: a.uin, ok: false, reason: '配额用完或本月卡都已被领完' }) } } catch (e) { - if (e instanceof QmDead) log(`🔴 ${a.uin}:登录态失效,请重新抓 qm_keyst 更新 env`) - else log(`🔴 ${a.uin}:取卡异常 ${e.message}`) + const reason = e instanceof QmDead ? '登录态失效,请重新抓 qm_keyst 更新 env' : `取卡异常 ${e.message}` + log(`🔴 ${a.uin}:${reason}`) + results.push({ uin: a.uin, ok: false, reason }) } } + await pushCardResult(results) return } @@ -709,4 +766,4 @@ async function main() { if (require.main === module) { main().catch(e => { console.error('[QQ音乐] 未捕获异常:', e); process.exitCode = 1 }) } -module.exports = { time33, zzc, QMClient, parseAccounts } +module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult }