Files
ql-scripts/scrip_diag_notify.js
zhuhjay 3ce29ebee2 docs(notify): 诊断补第 5b 节,指向平台侧的转发检查
用户实跑结果:sendNotify 加载正常、WEBHOOK 三项前置条件全过、
直打平台两次都 204(含换行/中文/emoji 的完整正文也收下了)。
但 PUSH_PLUS_TOKEN 未设 —— 青龙根本不发 pushplus,所以收到的那条
pushplus 一定来自平台转发,不是脚本自己发的。

补第 5b 节把这条推理写进输出:
- 204 只证明平台收下了,收到 ≠ 转发
- 中间还有两道门:事件要匹配到账号、账号下要有 enabled+active 的
  pushplus 目标
- 给出两个 curl 判据(/api/notify-targets 与 /api/events?category=notify)
  以及 deliveries=N 的读法
- 第 5 节在 PUSH_PLUS_TOKEN 未设时不再只是「跳过」,而是说明
  这条消息必然是平台转发来的
2026-11-05 20:21:02 +08:00

255 lines
13 KiB
JavaScript
Raw Permalink 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 未设 —— 青龙侧不发 pushplus')
console.log(' · 若你收到过 pushplus 消息,那是平台(script-helper)转发来的')
console.log(' → 说明平台侧的通知目标配好了,问题只在「正文被折叠」')
console.log(' → 平台用 template=txt(不折叠),青龙 sendNotify 用 html(折叠)')
} 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 或实名状态有问题')
}
/* ────────── 5b. 平台侧有没有把事件转发出去 ────────── */
hr('5b. 平台侧转发检查')
const base = (() => { try { const u = new URL(finalUrl); return `${u.protocol}//${u.host}` } catch { return '' } })()
console.log(' 平台基址:', base || '(无法从 WEBHOOK_URL 推出)')
console.log('')
console.log(' 第 4 节是 204,说明平台「收下了」。但收到 ≠ 转发,中间还有两道门:')
console.log(' ① 事件要匹配到你的账号(notify_events.user_id 非空,或走 owner 原文透传)')
console.log(' ② 你的账号下要有 enabled=1 且 status=active 的 pushplus 目标')
console.log('')
console.log(' 验证方式(浏览器登录平台后打开开发者工具,或用 curl 带 cookie):')
if (base) {
console.log(` curl -s "${base}/api/notify-targets" -H "cookie: <你的会话 cookie>"`)
console.log(' → items 为空 = 没有目标,平台收到也不会转发(这就是只收到标题的原因)')
console.log(` curl -s "${base}/api/events?category=notify&limit=10" -H "cookie: <你的会话 cookie>"`)
console.log(' → 能看到 action=notify.send 的记录,message 里 deliveries=N')
console.log(' → deliveries=0 表示匹配到了事件但没有任何目标可投')
}
console.log('')
console.log(' 一句话判据:')
console.log(' · 平台 /api/notify-targets 为空 → 去平台「通知」页新建一个 pushplus 目标')
console.log(' · 有目标但 events 里 deliveries=0 → 目标被禁用,或事件没匹配到账号')
/* ────────── 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 无关')
console.log(' · 已修版本会打「成功🎉 HTTP 204」;仍打失败说明仓库还没更新到这个版本')
}
hr('完成')
})().catch(e => console.log(' 异常:', e.message))
}