From 23d35cd94243e6a7ffd70f434852b51dc5f3e176 Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Sat, 7 Nov 2026 02:10:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor(common):=20=E9=80=9A=E7=9F=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=90=AC=E8=BF=9B=20common.js=EF=BC=8C=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=84=9A=E6=9C=AC=E4=B8=80=E8=A1=8C=20require=20?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 notify 内联在 qqmusic 脚本里,只解决了单个脚本。这次搬到仓库根的 common.js,后续任何脚本 require('../common') 就能发通知。 common.js 新增导出: notify(title, content, opts) webhook + pushplus 双通道,任一成功即 ok,永不抛错 notifyConfig(env) 读 env 并给默认值(可注入替身,方便测试) httpPost(url, headers, body) 通用 POST,http/https 自适应 parseHookHeaders(raw) 一行一个 Key: Value buildHookBody(t, c, tpl, ct) 拼 body,JSON 不合法就重建 Env 保持原样未动(jd 与 xiequ 在用)。 顺带删掉第 1 行的 require('node-fetch') —— 它是死代码:Env 内部所有 HTTP 走的是 require('got') + require('tough-cookie'),fetch 只以 $task.fetch(...) 的形式出现在 QuantumultX 分支(宿主 API,不是 node-fetch)。留着它反而会在容器缺 node-fetch 时 让整个文件 require 抛错,连带 jd / xiequ 一起挂。 qqmusic 脚本从 125 行内联实现缩成一行 require('../common'),删掉重复的 httpPost / parseHookHeaders / buildHookBody / notify 四份副本。 测试从 qqmusic/test_notify.js 挪到仓库根 test_common_notify.js,断言从 38 加到 69: - parseHookHeaders 九种输入(新增重复 key 覆盖) - buildHookBody 五种(新增自定义模板 + urlencoded + text/plain) - notifyConfig 十项默认值与 trim - notify 八种状态(200/204/201/401/403/400/429/连不上) - 无配置、双通道互不牵连 - Env 兼容性(确认 node-fetch 已移除、老接口都在) 端到端已验证:common.js 的 notify 发出的 content 与原始 desp 逐字相同(10 行完整), title 正确,header 带 token,HTTP 204 判为成功。 README(根 + qqmusic)新增 common.js 公共库章节,列出 notify 支持的八个环境变量 与「为什么不能放 sendNotify.js」的 deps 覆盖原因。 --- README.md | 39 ++++++ common.js | 178 +++++++++++++++++++++++++- qqmusic/README.md | 30 +++-- qqmusic/scrip_qqmusic_checkin.js | 125 ++---------------- qqmusic/test_notify.js | 170 ------------------------- test_common_notify.js | 210 +++++++++++++++++++++++++++++++ 6 files changed, 452 insertions(+), 300 deletions(-) delete mode 100644 qqmusic/test_notify.js create mode 100644 test_common_notify.js diff --git a/README.md b/README.md index c8f608b..7292032 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,45 @@ > **文件名约定**:订阅白名单是 `scrip_`,所有脚本文件必须带该前缀才会被拉取。 > 详见各目录下的 README。 +## 公共库 `common.js` + +仓库根的 `common.js` 是**依赖文件**(订阅「依赖文件」填 `common` 就会拉下来), +提供两样东西: + +```js +const { Env, notify } = require('../common') + +// 1. Env:四端环境封装(QuantumultX / Surge / Loon / Node),jd 与 xiequ 在用 +const $ = new Env('我的脚本') + +// 2. notify:发通知,webhook + pushplus 双通道,任一成功即 ok,永不抛错 +const r = await notify('标题', '正文') +// → { ok: true, channels: [{name:'webhook', ok:true, detail:'HTTP 204'}, ...] } +``` + +`notify` 读的环境变量: + +| 变量 | 说明 | +|---|---| +| `WEBHOOK_URL` | 自定义 webhook(如平台 `/api/hooks/ql`) | +| `WEBHOOK_METHOD` | 默认 `POST` | +| `WEBHOOK_CONTENT_TYPE` | 默认 `application/json` | +| `WEBHOOK_BODY` | 支持 `$title` / `$content` 占位;不填则按 Content-Type 给默认形状 | +| `WEBHOOK_HEADERS` | 一行一个 `Key: Value`,**必须是真换行** | +| `PUSH_PLUS_TOKEN` | pushplus 直发 | +| `PUSH_PLUS_USER` | pushplus 群组编码(一对多) | +| `PUSH_PLUS_TEMPLATE` | 默认 `txt`(不转义 html、不折叠) | + +**为什么不放在 `sendNotify.js` 里**:青龙订阅复制文件时 `/ql/data/deps/*` 会**最后覆盖**, +而 `deps` 里正好有一份面板自带的 `sendNotify.js`。改那个文件永远不生效。 +`common.js` 不在 `deps` 里,所以安全。详见 [qqmusic/README.md](qqmusic/README.md) 的「坑 0」。 + +自检: + +```bash +node test_common_notify.js # 69 条断言,含八种 HTTP 状态码 +``` + ## 安装使用 diff --git a/common.js b/common.js index fb50270..c5eb1dc 100644 --- a/common.js +++ b/common.js @@ -1,8 +1,180 @@ -const fetch = require('node-fetch'); +/** + * common.js —— 青龙脚本公共库(零外部依赖) + * + * 导出: + * Env(name) 青龙/QuantumultX/Surge/Loon 四端环境封装(原有,jd 与 xiequ 在用) + * notify(t, c) 发通知(新增)。webhook + pushplus 双通道,任一成功即 ok + * httpPost(...) 通用 POST,http/https 自适应 + * parseHookHeaders() 解析 WEBHOOK_HEADERS + * buildHookBody() 拼 webhook body + * + * 为什么 notify 放在这里而不是继续用青龙的 sendNotify.js(2026-11-07 真机定位): + * 青龙 shell/update.sh 的 gen_list_repo 复制顺序是三步,后一步盖前一步 —— + * 407: cp -f $file_notify_js "${dir_scripts}/${uniq_path}" 面板自带的 sendNotify.js + * 415: cp -f $_file "${dir_scripts}/${uniq_path}/..." 仓库里的(覆盖①) + * 421: cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" /ql/data/deps/*(再覆盖②) + * $dir_dep = /ql/data/deps,share.sh:228 在初始化时把面板自带的 sample/notify.js + * 拷成了 /ql/data/deps/sendNotify.js。③ 排在最后 → 最终文件永远是面板自带那份, + * 仓库里对 sendNotify.js 的任何修改都不生效。 + * common.js 不在 deps 里(deps 只有 notify.py 和 sendNotify.js),所以放这里安全。 + * + * 本文件不 require 任何第三方包。第 1 行的 node-fetch 已移除 —— 它是死代码: + * Env 里所有 HTTP 走的是 require("got") + require("tough-cookie"), + * `fetch` 只以 `$task.fetch(...)` 的形式出现在 QuantumultX 分支里(那是宿主 API, + * 不是 node-fetch)。留着它反而会在容器缺 node-fetch 时让 require 整个文件抛错。 + */ +'use strict' +const http = require('http') +const https = require('https') + +/* ══════════════════════ Env:四端环境封装(原有实现,未改动) ══════════════════════ */ // prettier-ignore function Env(t, s) { return new class { constructor(t, s) { this.name = t, this.data = null, this.dataFile = "box.dat", this.logs = [], this.logSeparator = "\n", this.startTime = (new Date).getTime(), Object.assign(this, s), this.log("", `\ud83d\udd14${this.name}, \u5f00\u59cb!`) } isNode() { return "undefined" != typeof module && !!module.exports } isQuanX() { return "undefined" != typeof $task } isSurge() { return "undefined" != typeof $httpClient && "undefined" == typeof $loon } isLoon() { return "undefined" != typeof $loon } getScript(t) { return new Promise(s => { $.get({ url: t }, (t, e, i) => s(i)) }) } runScript(t, s) { return new Promise(e => { let i = this.getdata("@chavy_boxjs_userCfgs.httpapi"); i = i ? i.replace(/\n/g, "").trim() : i; let o = this.getdata("@chavy_boxjs_userCfgs.httpapi_timeout"); o = o ? 1 * o : 20, o = s && s.timeout ? s.timeout : o; const [h, a] = i.split("@"), r = { url: `http://${a}/v1/scripting/evaluate`, body: { script_text: t, mock_type: "cron", timeout: o }, headers: { "X-Key": h, Accept: "*/*" } }; $.post(r, (t, s, i) => e(i)) }).catch(t => this.logErr(t)) } loaddata() { if (!this.isNode()) return {}; { this.fs = this.fs ? this.fs : require("fs"), this.path = this.path ? this.path : require("path"); const t = this.path.resolve(this.dataFile), s = this.path.resolve(process.cwd(), this.dataFile), e = this.fs.existsSync(t), i = !e && this.fs.existsSync(s); if (!e && !i) return {}; { const i = e ? t : s; try { return JSON.parse(this.fs.readFileSync(i)) } catch (t) { return {} } } } } writedata() { if (this.isNode()) { this.fs = this.fs ? this.fs : require("fs"), this.path = this.path ? this.path : require("path"); const t = this.path.resolve(this.dataFile), s = this.path.resolve(process.cwd(), this.dataFile), e = this.fs.existsSync(t), i = !e && this.fs.existsSync(s), o = JSON.stringify(this.data); e ? this.fs.writeFileSync(t, o) : i ? this.fs.writeFileSync(s, o) : this.fs.writeFileSync(t, o) } } lodash_get(t, s, e) { const i = s.replace(/\[(\d+)\]/g, ".$1").split("."); let o = t; for (const t of i) if (o = Object(o)[t], void 0 === o) return e; return o } lodash_set(t, s, e) { return Object(t) !== t ? t : (Array.isArray(s) || (s = s.toString().match(/[^.[\]]+/g) || []), s.slice(0, -1).reduce((t, e, i) => Object(t[e]) === t[e] ? t[e] : t[e] = Math.abs(s[i + 1]) >> 0 == +s[i + 1] ? [] : {}, t)[s[s.length - 1]] = e, t) } getdata(t) { let s = this.getval(t); if (/^@/.test(t)) { const [, e, i] = /^@(.*?)\.(.*?)$/.exec(t), o = e ? this.getval(e) : ""; if (o) try { const t = JSON.parse(o); s = t ? this.lodash_get(t, i, "") : s } catch (t) { s = "" } } return s } setdata(t, s) { let e = !1; if (/^@/.test(s)) { const [, i, o] = /^@(.*?)\.(.*?)$/.exec(s), h = this.getval(i), a = i ? "null" === h ? null : h || "{}" : "{}"; try { const s = JSON.parse(a); this.lodash_set(s, o, t), e = this.setval(JSON.stringify(s), i) } catch (s) { const h = {}; this.lodash_set(h, o, t), e = this.setval(JSON.stringify(h), i) } } else e = $.setval(t, s); return e } getval(t) { return this.isSurge() || this.isLoon() ? $persistentStore.read(t) : this.isQuanX() ? $prefs.valueForKey(t) : this.isNode() ? (this.data = this.loaddata(), this.data[t]) : this.data && this.data[t] || null } setval(t, s) { return this.isSurge() || this.isLoon() ? $persistentStore.write(t, s) : this.isQuanX() ? $prefs.setValueForKey(t, s) : this.isNode() ? (this.data = this.loaddata(), this.data[s] = t, this.writedata(), !0) : this.data && this.data[s] || null } initGotEnv(t) { this.got = this.got ? this.got : require("got"), this.cktough = this.cktough ? this.cktough : require("tough-cookie"), this.ckjar = this.ckjar ? this.ckjar : new this.cktough.CookieJar, t && (t.headers = t.headers ? t.headers : {}, void 0 === t.headers.Cookie && void 0 === t.cookieJar && (t.cookieJar = this.ckjar)) } get(t, s = (() => { })) { t.headers && (delete t.headers["Content-Type"], delete t.headers["Content-Length"]), this.isSurge() || this.isLoon() ? $httpClient.get(t, (t, e, i) => { !t && e && (e.body = i, e.statusCode = e.status), s(t, e, i) }) : this.isQuanX() ? $task.fetch(t).then(t => { const { statusCode: e, statusCode: i, headers: o, body: h } = t; s(null, { status: e, statusCode: i, headers: o, body: h }, h) }, t => s(t)) : this.isNode() && (this.initGotEnv(t), this.got(t).on("redirect", (t, s) => { try { const e = t.headers["set-cookie"].map(this.cktough.Cookie.parse).toString(); this.ckjar.setCookieSync(e, null), s.cookieJar = this.ckjar } catch (t) { this.logErr(t) } }).then(t => { const { statusCode: e, statusCode: i, headers: o, body: h } = t; s(null, { status: e, statusCode: i, headers: o, body: h }, h) }, t => s(t))) } post(t, s = (() => { })) { if (t.body && t.headers && !t.headers["Content-Type"] && (t.headers["Content-Type"] = "application/x-www-form-urlencoded"), delete t.headers["Content-Length"], this.isSurge() || this.isLoon()) $httpClient.post(t, (t, e, i) => { !t && e && (e.body = i, e.statusCode = e.status), s(t, e, i) }); else if (this.isQuanX()) t.method = "POST", $task.fetch(t).then(t => { const { statusCode: e, statusCode: i, headers: o, body: h } = t; s(null, { status: e, statusCode: i, headers: o, body: h }, h) }, t => s(t)); else if (this.isNode()) { this.initGotEnv(t); const { url: e, ...i } = t; this.got.post(e, i).then(t => { const { statusCode: e, statusCode: i, headers: o, body: h } = t; s(null, { status: e, statusCode: i, headers: o, body: h }, h) }, t => s(t)) } } time(t) { let s = { "M+": (new Date).getMonth() + 1, "d+": (new Date).getDate(), "H+": (new Date).getHours(), "m+": (new Date).getMinutes(), "s+": (new Date).getSeconds(), "q+": Math.floor(((new Date).getMonth() + 3) / 3), S: (new Date).getMilliseconds() }; /(y+)/.test(t) && (t = t.replace(RegExp.$1, ((new Date).getFullYear() + "").substr(4 - RegExp.$1.length))); for (let e in s) new RegExp("(" + e + ")").test(t) && (t = t.replace(RegExp.$1, 1 == RegExp.$1.length ? s[e] : ("00" + s[e]).substr(("" + s[e]).length))); return t } msg(s = t, e = "", i = "", o) { const h = t => !t || !this.isLoon() && this.isSurge() ? t : "string" == typeof t ? this.isLoon() ? t : this.isQuanX() ? { "open-url": t } : void 0 : "object" == typeof t && (t["open-url"] || t["media-url"]) ? this.isLoon() ? t["open-url"] : this.isQuanX() ? t : void 0 : void 0; $.isMute || (this.isSurge() || this.isLoon() ? $notification.post(s, e, i, h(o)) : this.isQuanX() && $notify(s, e, i, h(o))), this.logs.push("", "==============\ud83d\udce3\u7cfb\u7edf\u901a\u77e5\ud83d\udce3=============="), this.logs.push(s), e && this.logs.push(e), i && this.logs.push(i) } log(...t) { t.length > 0 ? this.logs = [...this.logs, ...t] : console.log(this.logs.join(this.logSeparator)) } logErr(t, s) { const e = !this.isSurge() && !this.isQuanX() && !this.isLoon(); e ? $.log("", `\u2757\ufe0f${this.name}, \u9519\u8bef!`, t.stack) : $.log("", `\u2757\ufe0f${this.name}, \u9519\u8bef!`, t) } wait(t) { return new Promise(s => setTimeout(s, t)) } done(t = {}) { const s = (new Date).getTime(), e = (s - this.startTime) / 1e3; this.log("", `\ud83d\udd14${this.name}, \u7ed3\u675f! \ud83d\udd5b ${e} \u79d2`), this.log(), (this.isSurge() || this.isQuanX() || this.isLoon()) && $done(t) } }(t, s) } -module.exports = { - Env +/* ══════════════════════ 通知 ══════════════════════ */ + +/** 通用 POST(http/https 自适应),零依赖。永不 reject —— 失败返回 { status: 0, text }。 + * @returns {Promise<{status:number, text:string}>} */ +function httpPost(urlStr, headers, body, timeoutMs = 15000) { + return new Promise((resolve) => { + let u + try { u = new URL(urlStr) } catch { return resolve({ status: 0, text: `非法 URL: ${urlStr}` }) } + const lib = u.protocol === 'http:' ? http : https + const payload = Buffer.from(String(body ?? ''), 'utf8') + const req = lib.request({ + host: u.hostname, + port: u.port || (u.protocol === 'http:' ? 80 : 443), + path: u.pathname + u.search, + method: 'POST', + headers: { ...headers, 'Content-Length': payload.length }, + timeout: timeoutMs, + }, (res) => { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') })) + }) + req.on('timeout', () => { req.destroy(); resolve({ status: 0, text: 'request timeout' }) }) + req.on('error', e => resolve({ status: 0, text: e.message })) + req.write(payload) + req.end() + }) +} + +/** 解析 WEBHOOK_HEADERS:一行一个 `Key: Value`,空行与无冒号行忽略。 + * 环境变量里必须是**真换行**;写成字面量 \n(两个字符)整段会被当成一个 header。 */ +function parseHookHeaders(raw) { + const out = {} + for (const line of String(raw || '').split('\n')) { + const s = line.trim() + if (!s) continue + const i = s.indexOf(':') + if (i <= 0) continue + out[s.slice(0, i).trim()] = s.slice(i + 1).trim() + } + return out +} + +/** 拼 webhook body。tpl 支持 $title / $content 占位。 + * JSON 分支会先试 JSON.parse,不合法就 JSON.stringify 重建 —— 这样内容里的 + * 双引号/换行/反斜杠都不会破坏报文(青龙 parseString 那个坑在源头绕开)。 */ +function buildHookBody(title, content, tpl, contentType) { + const ct = String(contentType || 'application/json').trim() + const body = String(tpl || '').trim() + || (ct === 'application/json' + ? '{"title":"$title","content":"$content"}' + : 'title=$title&content=$content') + const filled = body.replace(/\$title/g, title).replace(/\$content/g, content) + if (ct === 'application/json') { + try { JSON.parse(filled); return filled } catch { /* 落下面重建 */ } + return JSON.stringify({ title, content }) + } + if (ct === 'application/x-www-form-urlencoded') { + return `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` + } + return filled +} + +/** 从环境变量读通知配置(每次调用都重读,方便测试与运行时改 env)。 */ +function notifyConfig(env) { + const e = env || process.env + return { + webhookUrl: String(e.WEBHOOK_URL || '').trim(), + webhookBody: String(e.WEBHOOK_BODY || '').trim(), + webhookMethod: String(e.WEBHOOK_METHOD || 'POST').trim().toUpperCase(), + webhookCt: String(e.WEBHOOK_CONTENT_TYPE || 'application/json').trim(), + webhookHeaders: String(e.WEBHOOK_HEADERS || ''), + pushPlusToken: String(e.PUSH_PLUS_TOKEN || '').trim(), + pushPlusTopic: String(e.PUSH_PLUS_USER || '').trim(), + // txt 不转义 html、不折叠;html 会把换行变
+ pushPlusTemplate: String(e.PUSH_PLUS_TEMPLATE || 'txt').trim(), + } +} + +/** + * 发通知。webhook + pushplus 双通道,任一成功即 ok。**永不抛错**。 + * + * @param {string} title 标题 + * @param {string} content 正文 + * @param {{silent?:boolean, env?:object}} [opts] + * silent=true 时不打日志(测试用);env 可注入替身环境 + * @returns {Promise<{ok:boolean, channels:Array<{name:string,ok:boolean,detail:string}>}>} + * + * 行为要点: + * - 2xx 一律算成功。平台 /api/hooks/ql 按设计回 204,判 !==200 会误报失败。 + * - 两路都试,互不牵连。 + * - 两路都没配时打一行明确提示,不静默。 + */ +async function notify(title, content, opts = {}) { + const cfg = notifyConfig(opts.env) + const say = opts.silent ? () => {} : (...a) => console.log(...a) + const channels = [] + + if (cfg.webhookUrl) { + const headers = { 'Content-Type': cfg.webhookCt, ...parseHookHeaders(cfg.webhookHeaders) } + const body = buildHookBody(title, content, cfg.webhookBody, cfg.webhookCt) + const r = await httpPost(cfg.webhookUrl, headers, body) + const ok = r.status >= 200 && r.status < 300 + channels.push({ name: 'webhook', ok, detail: `HTTP ${r.status || 'ERR'}` }) + say(ok + ? `[通知] webhook 成功(HTTP ${r.status})` + : `[通知] webhook 失败:HTTP ${r.status || 'ERR'} ${String(r.text).slice(0, 160)}`) + } else { + channels.push({ name: 'webhook', ok: false, detail: 'WEBHOOK_URL 未设' }) + } + + if (cfg.pushPlusToken) { + const payload = JSON.stringify({ + token: cfg.pushPlusToken, + title, + content, + template: cfg.pushPlusTemplate, + ...(cfg.pushPlusTopic ? { topic: cfg.pushPlusTopic } : {}), + }) + const r = await httpPost('https://www.pushplus.plus/send', { 'Content-Type': 'application/json' }, payload) + let j = {} + try { j = JSON.parse(r.text) } catch { /* 非 JSON */ } + const ok = j.code === 200 + channels.push({ + name: 'pushplus', ok, + detail: ok ? `msgId ${j.data || ''}` : `code ${j.code} ${j.msg || String(r.text).slice(0, 80)}`, + }) + say(ok ? '[通知] pushplus 成功' : `[通知] pushplus 失败:${j.code} ${j.msg || ''}`) + } else { + channels.push({ name: 'pushplus', ok: false, detail: 'PUSH_PLUS_TOKEN 未设' }) + } + + if (!cfg.webhookUrl && !cfg.pushPlusToken) { + say('[通知] 未配置任何通道(WEBHOOK_URL / PUSH_PLUS_TOKEN 都为空),本轮不发推送') + } + return { ok: channels.some(c => c.ok), channels } +} + +module.exports = { + Env, + notify, + notifyConfig, + httpPost, + parseHookHeaders, + buildHookBody, } diff --git a/qqmusic/README.md b/qqmusic/README.md index 95eb4fb..78c7025 100644 --- a/qqmusic/README.md +++ b/qqmusic/README.md @@ -233,8 +233,15 @@ docker exec -it qinglong sh -c ' 两个 md5 相同 = 被覆盖了。 -**结论:本脚本不再 `require('../sendNotify')`。** 通知逻辑内联在 -`scrip_qqmusic_checkin.js` 的 `notify()` 里(约 90 行,零依赖),行为完全由仓库决定。 +**结论:通知实现放在仓库根的 `common.js`,脚本 `require('../common')` 取用。** + +```js +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()` @@ -275,12 +282,12 @@ pushplus 官方给了三种解法([文档](https://pushplus.plus/doc/help/show ### 自检 ```bash -# 纯函数 + 六种状态码(200/204/201/401/403/400/429)+ 无配置 + 双通道 -node qqmusic/test_notify.js +# common.js 的 notify:69 条断言(纯函数 + 八种状态码 + Env 兼容性) +node test_common_notify.js ``` ``` -RESULT: PASS (38 passed, 0 failed) +RESULT: PASS (69 passed, 0 failed) ``` ### 指向平台的完整配置 @@ -321,15 +328,16 @@ 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_notify.js # 通知实现:38 条断言(含六种状态码) node test_sign_run.js # 签名对拍(需先 python test_sign_vector.py 生成期望) +node ../test_common_notify.js # 通知实现(69 条断言,见仓库根 README) node ../_probe_meta.js # 青龙元数据注解 ``` -`test_notify.js` 覆盖: +通知相关的测试在仓库根 `test_common_notify.js`(因为实现搬到了 `common.js`), +覆盖: -- `parseHookHeaders`:多行 / 空行 / 无冒号 / 值含冒号 / 字面量 `\n` 不切开 -- `buildHookBody`:内容含双引号、反斜杠、制表符时仍产出合法 JSON -- `notify`:mock 服务测 **200 / 204 / 201 / 401 / 403 / 400 / 429** 七种状态码 -- 无配置、双通道部分失败 +- `parseHookHeaders`:多行 / 空行 / 无冒号 / 值含冒号 / 重复 key / 字面量 `\n` 不切开 +- `buildHookBody`:内容含双引号、反斜杠、制表符、多行时仍产出合法 JSON +- `notify`:mock 服务测 **200 / 204 / 201 / 401 / 403 / 400 / 429 / 连不上** 八种 +- 无配置、双通道部分失败、`Env` 兼容性(jd / xiequ 在用) diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index c492f2c..63a380b 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -50,25 +50,19 @@ 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() -/* ---------------- 通知:自带实现,不 require 青龙 sendNotify ---------------- - * 为什么不复用青龙的 sendNotify.js(2026-11-07 真机定位): +/* ---------------- 通知:复用仓库根 common.js 的 notify() ---------------- + * 为什么不 require 青龙的 sendNotify.js(2026-11-07 真机定位): * shell/update.sh 的 gen_list_repo 复制顺序是三步 —— * ① cp -f $file_notify_js "${dir_scripts}/${uniq_path}" 面板自带的 sendNotify.js * ② cp -f <仓库依赖文件> "${dir_scripts}/${uniq_path}/" 仓库里的 sendNotify.js(覆盖①) * ③ cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" /ql/data/deps/*(再覆盖②) - * $dir_dep = /ql/data/deps,share.sh 初始化时往里面拷了一份面板自带的 notify.js。 + * $dir_dep = /ql/data/deps,share.sh:228 初始化时往里面拷了一份面板自带的 notify.js。 * ③ 排在最后,所以仓库里的 sendNotify.js 每轮都被覆盖,改它永远不生效。 - * 自己实现 = 彻底断开这条依赖链,行为完全由本文件决定。 - * 支持两路:自定义 webhook(指向平台)+ pushplus 直发。两者都没配就只打日志。 */ -const WEBHOOK_URL = String(process.env.WEBHOOK_URL || '').trim() -const WEBHOOK_BODY = String(process.env.WEBHOOK_BODY || '').trim() -const WEBHOOK_METHOD = String(process.env.WEBHOOK_METHOD || 'POST').trim().toUpperCase() -const WEBHOOK_CT = String(process.env.WEBHOOK_CONTENT_TYPE || 'application/json').trim() -const WEBHOOK_HEADERS = String(process.env.WEBHOOK_HEADERS || '') -const PUSH_PLUS_TOKEN = String(process.env.PUSH_PLUS_TOKEN || '').trim() -const PUSH_PLUS_TOPIC = String(process.env.PUSH_PLUS_USER || '').trim() -// pushplus 模板:txt 不转义 html、不折叠;html 会把换行变
-const PUSH_PLUS_TEMPLATE = String(process.env.PUSH_PLUS_TEMPLATE || 'txt').trim() + * + * common.js 不在 deps 里(deps 只有 notify.py 和 sendNotify.js),所以把通知实现放 + * 那里是安全的,且后续任何脚本都能 require('../common') 直接用。 */ +const { notify } = require('../common') + function time33(s) { let h = 5381 @@ -746,107 +740,6 @@ function parseAccounts() { return list } -/** 通用 HTTP 请求(http/https 自适应),零依赖。供通知与回传共用。 */ -function httpPost(urlStr, headers, body, timeoutMs = 15000) { - return new Promise((resolve) => { - let u - try { u = new URL(urlStr) } catch { return resolve({ status: 0, text: `非法 URL: ${urlStr}` }) } - const lib = u.protocol === 'http:' ? require('http') : https - const payload = Buffer.from(body, 'utf8') - const req = lib.request({ - host: u.hostname, - port: u.port || (u.protocol === 'http:' ? 80 : 443), - path: u.pathname + u.search, - method: 'POST', - headers: { ...headers, 'Content-Length': payload.length }, - timeout: timeoutMs, - }, (res) => { - const chunks = [] - res.on('data', c => chunks.push(c)) - res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') })) - }) - req.on('timeout', () => { req.destroy(); resolve({ status: 0, text: 'request timeout' }) }) - req.on('error', e => resolve({ status: 0, text: e.message })) - req.write(payload) - req.end() - }) -} - -/** 解析 WEBHOOK_HEADERS:一行一个 `Key: Value`,空行忽略。 - * 注意环境变量里必须是真换行;写成字面量 \n(两个字符)整段会被当成一个 header。 */ -function parseHookHeaders(raw) { - const out = {} - for (const line of String(raw || '').split('\n')) { - const s = line.trim() - if (!s) continue - const i = s.indexOf(':') - if (i <= 0) continue - out[s.slice(0, i).trim()] = s.slice(i + 1).trim() - } - return out -} - -/** 拼 webhook body。WEBHOOK_BODY 支持 $title / $content 占位; - * 没配 BODY 时按 Content-Type 给默认形状。 */ -function buildHookBody(title, content) { - const tpl = WEBHOOK_BODY || (WEBHOOK_CT === 'application/json' ? '{"title":"$title","content":"$content"}' : 'title=$title&content=$content') - const filled = tpl.replace(/\$title/g, title).replace(/\$content/g, content) - if (WEBHOOK_CT === 'application/json') { - // 占位替换会把内容里的引号/换行带进 JSON,必须重新序列化一次才合法 - try { JSON.parse(filled); return filled } catch { /* 落下面重建 */ } - return JSON.stringify({ title, content }) - } - if (WEBHOOK_CT === 'application/x-www-form-urlencoded') { - return `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` - } - return filled -} - -/** 发通知。返回 { ok, channels: [{name, ok, detail}] },永不抛错。 - * 设计要点: - * - 2xx 一律算成功。平台 /api/hooks/ql 按设计回 204,判 !== 200 会误报失败。 - * - 两路都试,任一路成功即 ok(不因 pushplus 挂了就吞掉 webhook 的成功)。 - * - 失败只打日志,绝不让通知问题把签到本身算失败。 */ -async function notify(title, content) { - const channels = [] - - if (WEBHOOK_URL) { - const headers = { 'Content-Type': WEBHOOK_CT, ...parseHookHeaders(WEBHOOK_HEADERS) } - const body = buildHookBody(title, content) - const r = await httpPost(WEBHOOK_URL, headers, body) - const ok = r.status >= 200 && r.status < 300 - channels.push({ name: 'webhook', ok, detail: `HTTP ${r.status || 'ERR'}` }) - log(ok - ? `[通知] webhook 成功(HTTP ${r.status})` - : `[通知] webhook 失败:HTTP ${r.status || 'ERR'} ${r.text.slice(0, 160)}`) - } else { - channels.push({ name: 'webhook', ok: false, detail: 'WEBHOOK_URL 未设' }) - } - - if (PUSH_PLUS_TOKEN) { - const payload = JSON.stringify({ - token: PUSH_PLUS_TOKEN, - title, - content, - template: PUSH_PLUS_TEMPLATE, - ...(PUSH_PLUS_TOPIC ? { topic: PUSH_PLUS_TOPIC } : {}), - }) - const r = await httpPost('https://www.pushplus.plus/send', { 'Content-Type': 'application/json' }, payload) - let j = {} - try { j = JSON.parse(r.text) } catch { /* 非 JSON */ } - const ok = j.code === 200 - channels.push({ name: 'pushplus', ok, detail: ok ? `msgId ${j.data || ''}` : `code ${j.code} ${j.msg || r.text.slice(0, 80)}` }) - log(ok ? `[通知] pushplus 成功` : `[通知] pushplus 失败:${j.code} ${j.msg || ''}`) - } else { - channels.push({ name: 'pushplus', ok: false, detail: 'PUSH_PLUS_TOKEN 未设' }) - } - - if (!WEBHOOK_URL && !PUSH_PLUS_TOKEN) { - log('[通知] 未配置任何通道(WEBHOOK_URL / PUSH_PLUS_TOKEN 都为空),本轮不发推送') - } - return { ok: channels.some(c => c.ok), channels } -} - /** 取卡结果回传平台(可选)。平台侧 /api/hooks/card 收到后按该用户的通知目标转发到微信。 * 设计要点: * - 只回传「取卡结果」,不含 cookie/key —— 走公网也不泄露凭证。 @@ -991,4 +884,4 @@ async function main() { if (require.main === module) { main().catch(e => { console.error('[QQ音乐] 未捕获异常:', e); process.exitCode = 1 }) } -module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult, notify, buildHookBody, parseHookHeaders } +module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult } diff --git a/qqmusic/test_notify.js b/qqmusic/test_notify.js deleted file mode 100644 index dfa8f0d..0000000 --- a/qqmusic/test_notify.js +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env node -/** - * test_notify.js —— 验证脚本自带的 notify() 实现(不依赖青龙 sendNotify) - * - * 覆盖: - * 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key - * 2. buildHookBody —— JSON 内容含引号换行时的转义、urlencoded、纯文本 - * 3. notify —— mock 服务测 200/204/401/403/400/429 六种状态码 - * 4. 无配置 —— 两路都没配时的行为 - * - * 用法: node qqmusic/test_notify.js - * 退出码 0 = 全过 - */ -'use strict' -const http = require('http') - -let pass = 0, fail = 0 -const ok = (name, cond, extra = '') => { - if (cond) { pass++; console.log(` ✓ ${name}`) } - else { fail++; console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`) } -} -const eq = (name, got, want) => ok(name, got === want, `got=${JSON.stringify(got)} want=${JSON.stringify(want)}`) - -/* ══════════ 1-2. 纯函数(不需要 env) ══════════ */ -const { buildHookBody, parseHookHeaders } = require('./scrip_qqmusic_checkin.js') - -console.log('\n── 1. parseHookHeaders ──') -eq('单行', JSON.stringify(parseHookHeaders('X-A: 1')), '{"X-A":"1"}') -eq('两行', JSON.stringify(parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}') -eq('空行忽略', JSON.stringify(parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}') -eq('无冒号忽略', JSON.stringify(parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}') -eq('值含冒号', JSON.stringify(parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}') -eq('空串', JSON.stringify(parseHookHeaders('')), '{}') -eq('值前后空格被 trim', JSON.stringify(parseHookHeaders(' X-A : v ')), '{"X-A":"v"}') -// 字面量 \n(两个字符)不该被切开 —— 这正是青龙 sendNotify 的坑 -eq('字面量 \\n 不切开', Object.keys(parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1) - -console.log('\n── 2. buildHookBody(默认 JSON 模板)──') -{ - const b = buildHookBody('标题', '第一行\n第二行') - let j = null - try { j = JSON.parse(b) } catch { /* 下面判 */ } - ok('产出是合法 JSON', j !== null, b) - eq('title 正确', j && j.title, '标题') - eq('content 换行保留', j && j.content, '第一行\n第二行') -} -{ - // 内容里带引号 —— 占位替换后必须仍然合法 - const b = buildHookBody('T', '他说"你好"然后走了') - let j = null - try { j = JSON.parse(b) } catch { /* */ } - ok('内容含双引号仍是合法 JSON', j !== null, b) - eq('引号原样保留', j && j.content, '他说"你好"然后走了') -} -{ - // 内容里带反斜杠与制表符 - const b = buildHookBody('T', 'C:\\path\\to\tfile') - let j = null - try { j = JSON.parse(b) } catch { /* */ } - ok('含反斜杠/制表符仍合法', j !== null, b) - eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile') -} - -/* ══════════ 3. notify —— mock 服务 ══════════ */ -console.log('\n── 3. notify(mock 服务,六种状态码)──') - -// 注意:WEBHOOK_URL 等是模块加载时读的常量,所以要改 env 后重新 require -const MOD = require.resolve('./scrip_qqmusic_checkin.js') -function reloadWith(env) { - for (const k of ['WEBHOOK_URL', 'WEBHOOK_BODY', 'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', - 'WEBHOOK_HEADERS', 'PUSH_PLUS_TOKEN', 'PUSH_PLUS_USER', 'PUSH_PLUS_TEMPLATE']) delete process.env[k] - Object.assign(process.env, env) - delete require.cache[MOD] - return require(MOD) -} - -function startServer(status, body = '') { - return new Promise((resolve) => { - const seen = [] - const srv = http.createServer((req, res) => { - const chunks = [] - req.on('data', c => chunks.push(c)) - req.on('end', () => { - seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }) - res.writeHead(status, { 'Content-Type': 'application/json' }) - res.end(body) - }) - }) - srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen })) - }) -} - -;(async () => { - const cases = [ - { status: 204, wantOk: true, label: '204 平台正常返回(★ 旧版误判为失败的就是这个)' }, - { status: 200, wantOk: true, label: '200 普通成功' }, - { status: 201, wantOk: true, label: '201 也是 2xx' }, - { status: 401, wantOk: false, label: '401 token 错' }, - { status: 403, wantOk: false, label: '403 IP 不在白名单' }, - { status: 400, wantOk: false, label: '400 body 畸形' }, - { status: 429, wantOk: false, label: '429 超频' }, - ] - - for (const c of cases) { - const { srv, port, seen } = await startServer(c.status) - const m = reloadWith({ - WEBHOOK_URL: `http://127.0.0.1:${port}/api/hooks/ql`, - WEBHOOK_METHOD: 'POST', - WEBHOOK_CONTENT_TYPE: 'application/json', - WEBHOOK_BODY: 'title:$title\ncontent:$content', - WEBHOOK_HEADERS: 'X-Sh-Hook-Token: tok123\nX-Sh-Hook-Id: qinglong', - }) - // 静音 notify 内部的 log - const origLog = console.log - console.log = () => {} - const r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15') - console.log = origLog - - const wh = r.channels.find(x => x.name === 'webhook') - ok(`${c.status} → ${c.wantOk ? 'ok' : 'not ok'}(${c.label})`, wh.ok === c.wantOk, JSON.stringify(wh)) - if (c.status === 204 || c.status === 200) { - ok(` 请求确实发出去了`, seen.length === 1, `seen=${seen.length}`) - const sent = seen[0] - ok(` method = POST`, sent.method === 'POST', sent.method) - ok(` header 带 token`, sent.headers['x-sh-hook-token'] === 'tok123', sent.headers['x-sh-hook-token']) - let j = null - try { j = JSON.parse(sent.body) } catch { /* */ } - ok(` body 是合法 JSON`, j !== null, sent.body.slice(0, 120)) - eq(` title`, j && j.title, 'QQ音乐签到') - ok(` content 含明细`, !!(j && j.content && j.content.includes('任务中心签到')), j && j.content) - } - srv.close() - } - - /* 4. 无配置 */ - console.log('\n── 4. 两路都没配 ──') - { - const m = reloadWith({}) - const origLog = console.log - const lines = [] - console.log = (...a) => lines.push(a.join(' ')) - const r = await m.notify('T', 'C') - console.log = origLog - ok('ok = false', r.ok === false) - ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | ')) - } - - /* 5. 只有 webhook 挂、pushplus 通 —— 不应因一路失败而整体失败 */ - console.log('\n── 5. 双通道:webhook 失败但 pushplus 成功 ──') - { - const { srv, port } = await startServer(500) - const m = reloadWith({ - WEBHOOK_URL: `http://127.0.0.1:${port}/x`, - WEBHOOK_METHOD: 'POST', - PUSH_PLUS_TOKEN: 'faketoken_for_shape_only', - }) - // pushplus 会真的发出去 —— 这里只校验 webhook 那一半,避免污染真实账号 - const origLog = console.log - console.log = () => {} - const r = await m.notify('T', 'C') - console.log = origLog - const wh = r.channels.find(x => x.name === 'webhook') - ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh)) - ok('channels 里有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name))) - srv.close() - } - - console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) - process.exit(fail === 0 ? 0 : 1) -})() diff --git a/test_common_notify.js b/test_common_notify.js new file mode 100644 index 0000000..2c85fbb --- /dev/null +++ b/test_common_notify.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * test_common_notify.js —— 验证 common.js 的 notify 实现 + * + * 覆盖: + * 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key / 字面量 \n + * 2. buildHookBody —— JSON 转义(引号/反斜杠/制表符)、urlencoded、纯文本、自定义模板 + * 3. notify —— mock 服务测 200/204/201/401/403/400/429/0 + * 4. notifyConfig —— env 读取与默认值 + * 5. 无配置 / 双通道部分失败 + * 6. Env 兼容性 —— 老接口(jd / xiequ 在用)没被破坏 + * + * 用法: node test_common_notify.js + * 退出码 0 = 全过 + */ +'use strict' +const http = require('http') +const m = require('./common.js') + +let pass = 0, fail = 0 +const ok = (name, cond, extra = '') => { + if (cond) { pass++; console.log(` ✓ ${name}`) } + else { fail++; console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`) } +} +const eq = (name, got, want) => ok(name, got === want, `got=${JSON.stringify(got)} want=${JSON.stringify(want)}`) + +/* ══════════ 1. parseHookHeaders ══════════ */ +console.log('\n── 1. parseHookHeaders ──') +eq('单行', JSON.stringify(m.parseHookHeaders('X-A: 1')), '{"X-A":"1"}') +eq('两行', JSON.stringify(m.parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}') +eq('空行忽略', JSON.stringify(m.parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}') +eq('无冒号忽略', JSON.stringify(m.parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}') +eq('值含冒号', JSON.stringify(m.parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}') +eq('空串', JSON.stringify(m.parseHookHeaders('')), '{}') +eq('值前后空格 trim', JSON.stringify(m.parseHookHeaders(' X-A : v ')), '{"X-A":"v"}') +eq('字面量 \\n 不切开', Object.keys(m.parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1) +eq('重复 key 后者覆盖', m.parseHookHeaders('X-A: 1\nX-A: 2')['X-A'], '2') + +/* ══════════ 2. buildHookBody ══════════ */ +console.log('\n── 2. buildHookBody ──') +{ + const b = m.buildHookBody('标题', '第一行\n第二行', '', 'application/json') + let j = null; try { j = JSON.parse(b) } catch { /* */ } + ok('默认模板产出合法 JSON', j !== null, b) + eq('title', j && j.title, '标题') + eq('换行保留', j && j.content, '第一行\n第二行') +} +{ + const b = m.buildHookBody('T', '他说"你好"然后走了', 'title:$title\ncontent:$content', 'application/json') + let j = null; try { j = JSON.parse(b) } catch { /* */ } + ok('内容含双引号仍合法', j !== null, b) + eq('引号原样保留', j && j.content, '他说"你好"然后走了') +} +{ + const b = m.buildHookBody('T', 'C:\\path\\to\tfile', '', 'application/json') + let j = null; try { j = JSON.parse(b) } catch { /* */ } + ok('含反斜杠/制表符仍合法', j !== null, b) + eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile') +} +{ + const b = m.buildHookBody('T', 'a\nb', 'title:$title\ncontent:$content', 'application/json') + let j = null; try { j = JSON.parse(b) } catch { /* */ } + ok('多行模板 + 多行内容 仍合法', j !== null, b) + eq('多行内容完整', j && j.content, 'a\nb') +} +{ + const b = m.buildHookBody('标题', 'a&b=c', '', 'application/x-www-form-urlencoded') + eq('urlencoded 编码', b, 'title=%E6%A0%87%E9%A2%98&content=a%26b%3Dc') +} +{ + const b = m.buildHookBody('T', 'C', 'x=$title&y=$content', 'text/plain') + eq('纯文本替换', b, 'x=T&y=C') +} + +/* ══════════ 3. notifyConfig ══════════ */ +console.log('\n── 3. notifyConfig ──') +{ + const c = m.notifyConfig({}) + eq('默认 method', c.webhookMethod, 'POST') + eq('默认 content-type', c.webhookCt, 'application/json') + eq('默认 pushplus 模板', c.pushPlusTemplate, 'txt') + eq('空 URL', c.webhookUrl, '') + eq('空 token', c.pushPlusToken, '') +} +{ + const c = m.notifyConfig({ + WEBHOOK_URL: ' http://x/ ', WEBHOOK_METHOD: 'post', + PUSH_PLUS_TOKEN: ' tk ', PUSH_PLUS_USER: 'grp', PUSH_PLUS_TEMPLATE: 'html', + }) + eq('URL 被 trim', c.webhookUrl, 'http://x/') + eq('method 大写', c.webhookMethod, 'POST') + eq('token 被 trim', c.pushPlusToken, 'tk') + eq('topic', c.pushPlusTopic, 'grp') + eq('template 可改', c.pushPlusTemplate, 'html') +} + +/* ══════════ 4. notify(mock 服务) ══════════ */ +console.log('\n── 4. notify(mock 服务)──') + +function startServer(status, body = '') { + return new Promise((resolve) => { + const seen = [] + const srv = http.createServer((req, res) => { + const chunks = [] + req.on('data', c => chunks.push(c)) + req.on('end', () => { + seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(body) + }) + }) + srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen })) + }) +} + +;(async () => { + const cases = [ + { status: 204, wantOk: true, label: '204 平台正常返回(★ 旧 sendNotify 误判为失败的就是这个)' }, + { status: 200, wantOk: true, label: '200' }, + { status: 201, wantOk: true, label: '201 也是 2xx' }, + { status: 401, wantOk: false, label: '401 token 错' }, + { status: 403, wantOk: false, label: '403 IP 不在白名单' }, + { status: 400, wantOk: false, label: '400 body 畸形' }, + { status: 429, wantOk: false, label: '429 超频' }, + ] + + for (const c of cases) { + const { srv, port, seen } = await startServer(c.status) + const r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15', { + silent: true, + env: { + WEBHOOK_URL: `http://127.0.0.1:${port}/api/hooks/ql`, + WEBHOOK_METHOD: 'POST', + WEBHOOK_CONTENT_TYPE: 'application/json', + WEBHOOK_BODY: 'title:$title\ncontent:$content', + WEBHOOK_HEADERS: 'X-Sh-Hook-Token: tok123\nX-Sh-Hook-Id: qinglong', + }, + }) + const wh = r.channels.find(x => x.name === 'webhook') + ok(`${c.status} → ${c.wantOk ? 'ok' : 'not ok'}(${c.label})`, wh.ok === c.wantOk, JSON.stringify(wh)) + if (c.wantOk) { + ok(' 请求发出去了', seen.length === 1, `seen=${seen.length}`) + const s = seen[0] + ok(' method POST', s.method === 'POST', s.method) + ok(' header 带 token', s.headers['x-sh-hook-token'] === 'tok123', s.headers['x-sh-hook-token']) + let j = null; try { j = JSON.parse(s.body) } catch { /* */ } + ok(' body 合法 JSON', j !== null, s.body.slice(0, 120)) + eq(' title', j && j.title, 'QQ音乐签到') + ok(' content 含明细', !!(j && j.content && j.content.includes('任务中心签到')), j && j.content) + } + srv.close() + } + + /* 连不上 */ + console.log('\n── 4b. 连不上 ──') + { + const r = await m.notify('T', 'C', { + silent: true, + env: { WEBHOOK_URL: 'http://127.0.0.1:1/nope', WEBHOOK_METHOD: 'POST' }, + }) + const wh = r.channels.find(x => x.name === 'webhook') + ok('连接失败记为 not ok', wh.ok === false, JSON.stringify(wh)) + ok('detail 标 ERR', wh.detail.includes('ERR'), wh.detail) + } + + /* ══════════ 5. 无配置 / 双通道 ══════════ */ + console.log('\n── 5. 无配置 ──') + { + const lines = [] + const orig = console.log + console.log = (...a) => lines.push(a.join(' ')) + const r = await m.notify('T', 'C', { env: {} }) + console.log = orig + ok('ok = false', r.ok === false) + ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | ')) + ok('channels 有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name))) + } + + console.log('\n── 5b. webhook 失败但 pushplus 成功 ──') + { + const { srv, port } = await startServer(500) + // pushplus 那半会真发 —— 用假 token,pushplus 返回非 200 code,同样能验证「互不牵连」 + const r = await m.notify('T', 'C', { + silent: true, + env: { WEBHOOK_URL: `http://127.0.0.1:${port}/x`, WEBHOOK_METHOD: 'POST', PUSH_PLUS_TOKEN: 'fake_token_for_shape' }, + }) + const wh = r.channels.find(x => x.name === 'webhook') + const pp = r.channels.find(x => x.name === 'pushplus') + ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh)) + ok('pushplus 那路也跑了', pp.detail !== 'PUSH_PLUS_TOKEN 未设', JSON.stringify(pp)) + srv.close() + } + + /* ══════════ 6. Env 兼容性 ══════════ */ + console.log('\n── 6. Env 兼容性(jd / xiequ 在用)──') + { + ok('Env 是函数', typeof m.Env === 'function') + const e = new m.Env('测试脚本') + eq('name', e.name, '测试脚本') + ok('isNode()', e.isNode() === true) + ok('有 get/post', typeof e.get === 'function' && typeof e.post === 'function') + ok('有 getdata/setdata', typeof e.getdata === 'function' && typeof e.setdata === 'function') + ok('有 log/msg/wait/done', ['log', 'msg', 'wait', 'done'].every(k => typeof e[k] === 'function')) + // 关键:node-fetch 移除后,require 整个文件不该抛错 + ok('不再 require node-fetch', !require('fs').readFileSync('./common.js', 'utf8').includes("require('node-fetch')")) + } + + console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) + process.exit(fail === 0 ? 0 : 1) +})()