Files
ql-scripts/common.js
zhuhjay 23d35cd942 refactor(common): 通知实现搬进 common.js,所有脚本一行 require 即可用
上一版把 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 覆盖原因。
2026-11-07 02:10:23 +08:00

181 lines
15 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.

/**
* 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,
}