Files
ql-scripts/scrip_diag_notify.js
zhuhjay 3a711d0a47 fix(notify): 平台回 204 被误判为失败 + 诊断工具改名 scrip_
三个改动:

1) sendNotify.js 的 webhook 判据从 statusCode !== 200 改成 2xx 一律成功。
   平台 /api/hooks/ql 按设计回 204(表示已接收且不写 event_logs),
   原判据把 204 当失败 → 每轮日志都打「自定义发送通知消息失败!!」,
   而投递其实成功了。这是纯假警报,也是这次误判的根因。
   同时把状态码打进日志,真失败时能直接看出是 401/403/400/429。

2) _diag_notify.js → scrip_diag_notify.js。
   订阅白名单是 scrip_,不带前缀的文件拉不下来,工具等于不存在。
   加 // cron: 0 0 1 1 * 与 // name:,避免青龙自动建任务时落随机 cron。
   正文补到六段:sendNotify 加载 / env / webhook 三前置条件与 body+headers
   解析复刻 / 直打平台按状态码定位 / pushplus txt-vs-html 模板对比 /
   完整 sendNotify 对照。

3) 文档补齐平台侧的两道硬校验:来源 IP 必须 127.0.0.1 或 172.16/12
   (走公网域名会变成反代地址 → 403),回环带 XFF 一律 403。
   给出指向平台的完整 env 写法(WEBHOOK_BODY 必须多行,单行 JSON
   会被 parseString 整段吞掉 —— 已实测解析结果为 0 个 key)。

验证:node --check(sendNotify + 诊断)/ 5 个 mock 状态码用例
(204/401/403/400/429)全部被正确识别 / _probe_meta 四文件 PASS
2026-11-05 09:34:06 +08:00

229 lines
11 KiB
JavaScript
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.

#!/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, '<br>') : 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))
}