#!/usr/bin/env node // cron: 0 0 1 1 * // name: QQ音乐通知诊断 /** * 通知链路诊断(青龙里跑:node scrip_diag_notify.js) * * 专治「pushplus 只收到一行标题」和「自定义发送通知消息失败」这两个现象。 * 逐段定位:sendNotify 加载 → env → webhook 配置 → 直打平台 /api/hooks/ql * → 直连 pushplus 对比模板 → 走一遍完整 sendNotify。 * * token 一律脱敏。只读配置 + 发测试消息,不改任何东西。 * 手动跑一次即可,cron 设成 1 月 1 日(等于不自动跑)。 */ 'use strict' const https = require('https') const http = require('http') const mask = (v, n = 6) => (!v ? '(未设)' : `${v.slice(0, n)}…(len=${v.length})`) const hr = t => console.log(`\n══════════ ${t} ══════════`) const TITLE = 'QQ音乐签到' const DESP = [ '✅ QQ音乐签到 · 全部完成', '', '🟡 1006764347:成长值 +0', ' ✓ 节目开始标记 已挂', ' △ 领奖·主题装扮 code 30005', ' △ 领奖·收听节目 未计次,待 App 内收听', ' · 无需处理 12 项:任务中心签到、挂件、装扮、听书行为…', '', '———', '轮次: 00:01 / 06:02 双轮', '时间: ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }), ].join('\n') /** 发一个请求,返回 {status, headers, body};超时/网络错误也返回而不抛 */ function req(url, { method = 'POST', headers = {}, body } = {}) { return new Promise((resolve) => { let u try { u = new URL(url) } catch (e) { return resolve({ status: 0, headers: {}, body: `URL 非法: ${e.message}` }) } const payload = body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) const lib = u.protocol === 'http:' ? http : https const r = lib.request({ host: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, method, headers: { ...headers, ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) }, timeout: 15000, }, (res) => { const cs = [] res.on('data', c => cs.push(c)) res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(cs).toString('utf8') })) }) r.on('timeout', () => { r.destroy(); resolve({ status: 0, headers: {}, body: 'TIMEOUT(15s)' }) }) r.on('error', e => resolve({ status: 0, headers: {}, body: e.message })) if (payload) r.write(payload) r.end() }) } /* ────────────────── 1. sendNotify 加载 ────────────────── */ hr('1. sendNotify 加载') let sn = null try { sn = require('./sendNotify') console.log(' ✓ 加载成功,sendNotify =', typeof sn.sendNotify) } catch (e) { console.log(' ✗ 加载失败:', e.message.split('\n')[0]) console.log(' → 脚本里 require 失败会打一行日志并跳过推送,一条都发不出') console.log(' → 修:docker exec -it qinglong pnpm install got') } /* ────────────────── 2. 通知 env ────────────────── */ hr('2. 通知 env') const ENVS = ['PUSH_PLUS_TOKEN', 'PUSH_PLUS_USER', 'WEBHOOK_URL', 'WEBHOOK_BODY', 'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', 'WEBHOOK_HEADERS', 'SKIP_PUSH_TITLE'] const SENSITIVE = /TOKEN|URL|BODY|HEADERS/i let envHit = 0 for (const k of ENVS) { const v = process.env[k] if (v) envHit++ console.log(` ${v ? '✓' : '·'} ${k.padEnd(20)} ${v ? (SENSITIVE.test(k) ? mask(v) : v) : '(未设)'}`) } if (!envHit) console.log(' ⚠️ 一个都没读到 —— 青龙「环境变量」是否保存/是否启用') /* ────────────────── 3. webhook 配置分析 ────────────────── */ hr('3. webhook 配置分析') const WU = process.env.WEBHOOK_URL || '' const WB = process.env.WEBHOOK_BODY || '' const WM = process.env.WEBHOOK_METHOD || '' const WCT = process.env.WEBHOOK_CONTENT_TYPE || '' const WH = process.env.WEBHOOK_HEADERS || '' console.log(' URL 含 $title :', WU.includes('$title')) console.log(' BODY 含 $title:', WB.includes('$title')) console.log(' METHOD :', WM || '(空 → 静默跳过,不发也不报错)') console.log(' CONTENT_TYPE :', WCT || '(空)') // 三个前置条件(webhookNotify 逐行 return,缺一个就静默跳过) if (!WU.includes('$title') && !WB.includes('$title')) { console.log('\n ✗ 条件① 不过:URL 与 BODY 都没有 $title → 第一行就 resolve(),什么都不发') } if (!WM) { console.log('\n ✗ 条件② 不过:WEBHOOK_METHOD 为空 → 走 else 分支 resolve(),什么都不发') } // body 解析复刻:parseString 靠「换行 + \w+:」断值,单行 JSON 会被整段吞掉 if (WB) { console.log('\n --- BODY 解析复刻(sendNotify 的 parseString)---') const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g const matches = {} let m while ((m = regex.exec(WB)) !== null) { if (!matches[m[1]]) matches[m[1]] = m[2].trim() } const keys = Object.keys(matches) console.log(' 解析出:', keys.length ? keys.join(', ') : '(0 个 key)') if (keys.length === 0) { console.log(' ✗ 条件③ 不过:BODY 非空但解析出 0 个 key') console.log(' 单行 JSON 会被整段吞掉(已实测),最终发出去的是 {}') console.log(' → 平台 JSON.parse("{}") 得到空对象 → 400 title 与 content 不能同时为空') console.log(' 修:BODY 改成多行 —— title:$title 换行 content:$content') } } // headers 解析复刻:split('\n'),若 env 里是字面量 \n(两字符)就分不开 if (WH) { console.log('\n --- HEADERS 解析复刻(sendNotify 的 parseHeaders)---') const lines = WH.split('\n') console.log(' 按换行切出:', lines.length, '行', lines.length === 1 && WH.includes('\\n') ? '(⚠️ 疑似字面量 \\n)' : '') for (const line of lines) { const i = line.indexOf(':') if (i < 0) { console.log(' ✗ 无冒号,被丢弃:', line.slice(0, 40)); continue } const k = line.slice(0, i).trim().toLowerCase() const v = line.slice(i + 1).trim() console.log(` ${k} = ${/token|key|secret/i.test(k) ? mask(v) : v}`) if (v.includes('\\n')) console.log(' ⚠️ 值里含字面量 \\n —— 整行没被切开,token 会带上后半段') } } /* ────────────────── 4. 直打平台 /api/hooks/ql ────────────────── */ hr('4. 直打平台 /api/hooks/ql') if (!WU) { console.log(' · WEBHOOK_URL 未设,跳过') } else { // 还原 sendNotify 实际会请求的 URL($title/$content 替换后) const finalUrl = WU.replaceAll('$title', encodeURIComponent(TITLE)).replaceAll('$content', encodeURIComponent(DESP)) let host = '?', path = '?' try { const u = new URL(finalUrl); host = u.host; path = u.pathname } catch { /* ignore */ } console.log(' 目标:', host + path) console.log(' query 长度:', (() => { try { return new URL(finalUrl).search.length } catch { return '?' } })(), '(把正文塞进 URL 会超长,平台只读 body)') // 复刻 parseHeaders const hdrs = {} if (WH) for (const line of WH.split('\n')) { const i = line.indexOf(':') if (i > 0) hdrs[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim() } ;(async () => { console.log('\n ── 用 sendNotify 相同的头 + 一个最小合法 body 试一次 ──') const probe = { title: '诊断探针', content: '来自 scrip_diag_notify.js 的连通性测试,可忽略' } const r1 = await req(finalUrl, { method: WM || 'POST', headers: { 'Content-Type': 'application/json', ...hdrs }, body: JSON.stringify(probe), }) console.log(` HTTP ${r1.status} body=${r1.body.slice(0, 200) || '(空)'}`) if (r1.status === 204) { console.log(' ✓ 平台已接收(204 = 成功,且不写 event_logs)') console.log(' ⚠️ 但 sendNotify 的成功判据是 statusCode !== 200 → 204 会被它当成失败') console.log(' 「自定义发送通知消息失败!!」这句是假警报,实际投递成功了') } else if (r1.status === 401) { console.log(' ✗ 401:x-sh-hook-token 不对,或 WEBHOOK_HEADERS 没被正确解析') } else if (r1.status === 403) { console.log(' ✗ 403:来源 IP 不在白名单(只放 127.0.0.1 与 172.16/12),或回环带了 XFF') } else if (r1.status === 400) { console.log(' ✗ 400:body 不是合法 JSON,或 title/content 全空') } else if (r1.status === 429) { console.log(' ✗ 429:超过 60/分 或 1000/天') } else if (r1.status === 0) { console.log(' ✗ 连不上:', r1.body, '(容器网络/域名解析)') } console.log('\n ── 再发一条含中文与换行的真实正文(验证 JSON 转义)──') const r2 = await req(finalUrl, { method: WM || 'POST', headers: { 'Content-Type': 'application/json', ...hdrs }, body: JSON.stringify({ title: TITLE, content: DESP }), }) console.log(` HTTP ${r2.status} body=${r2.body.slice(0, 200) || '(空)'}`) if (r2.status === 204) console.log(' ✓ 完整正文(含换行/中文/emoji)平台也收下了') else if (r2.status !== r1.status) console.log(' ⚠️ 与上一条状态不同 → 正文内容触发了额外校验') /* ────────── 5. 直连 pushplus 模板对比 ────────── */ hr('5. 直连 pushplus(模板对比)') const PT = process.env.PUSH_PLUS_TOKEN || '' if (!PT) { console.log(' · PUSH_PLUS_TOKEN 未设,跳过') } else { for (const tpl of ['txt', 'html']) { const content = tpl === 'html' ? DESP.replace(/\n/g, '
') : DESP const r = await req('https://www.pushplus.plus/send', { headers: { 'Content-Type': 'application/json' }, body: { token: PT, title: `${TITLE}(${tpl} 测试)`, content, template: tpl }, }) let j = {} try { j = JSON.parse(r.body) } catch { /* 非 JSON */ } console.log(` [${tpl}] HTTP ${r.status} code=${j.code} msg=${j.msg || ''} content=${content.length}字`) } console.log('\n → 微信里收到两条,对比哪条正文完整:') console.log(' · txt 完整 / html 只有一行 → 换 template=txt') console.log(' · 两条都只有一行 → 微信通知卡片本来就折叠,点开才展开全文') console.log(' · 两条都收不到 → PUSH_PLUS_TOKEN 或实名状态有问题') } /* ────────── 6. 走一遍完整 sendNotify ────────── */ hr('6. 完整 sendNotify') if (!sn) { console.log(' 跳过(未加载)') } else { console.log(' 调用 sendNotify("QQ音乐签到", desp)…\n') await sn.sendNotify(TITLE, DESP) console.log('\n 对照第 4 节:') console.log(' · 若这里出现「自定义发送通知消息失败」但第 4 节是 204 → 假警报,投递成功') console.log(' · 若第 4 节 401/403 → webhook 头或 IP 的问题,与 sendNotify 无关') } hr('完成') })().catch(e => console.log(' 异常:', e.message)) }