上一版把 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 覆盖原因。
181 lines
15 KiB
JavaScript
181 lines
15 KiB
JavaScript
/**
|
||
* 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) }
|
||
|
||
/* ══════════════════════ 通知 ══════════════════════ */
|
||
|
||
/** 通用 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 会把换行变 <br>
|
||
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,
|
||
}
|