/** * 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 会把换行变
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, }