From 5022dc27b22914af7375283ec9eaf679b27ccced Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 9 Nov 2026 09:42:19 +0800 Subject: [PATCH] vault backup: 2026-11-09 09:42:19 --- .obsidian/workspace.json | 2 +- 小鱼/闲鱼消息通知/README.md | 28 +++++++++++++ 小鱼/闲鱼消息通知/config.json | 5 ++- 小鱼/闲鱼消息通知/notify.js | 7 +++- 小鱼/闲鱼消息通知/probe.bat | 1 + 小鱼/闲鱼消息通知/start-watch.bat | 1 + 小鱼/闲鱼消息通知/watch.js | 65 ++++++++++++++++++++++++++++++- 小鱼/闲鱼消息通知/watch.log | 3 -- 8 files changed, 103 insertions(+), 9 deletions(-) delete mode 100644 小鱼/闲鱼消息通知/watch.log diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 34f37bc..ce4fe5d 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -199,6 +199,7 @@ }, "active": "b10e6fd1a0b41e79", "lastOpenFiles": [ + "小鱼/闲鱼消息通知/throttle-test.js", "小鱼/闲鱼消息通知/README.md", "小鱼/闲鱼消息通知/stop-watch.bat", "小鱼/闲鱼消息通知/start-watch-hidden.vbs", @@ -234,7 +235,6 @@ "生财/04_电商AI自动化链路.md", "生财/06_Android程序员AI副业项目筛选.md", "娟/2026年医保社保缴费季度汇总.md", - "妹妹/妹妹身高.md", "AndroidManifest.xml", "思睿/vocab_audio_output/audio/Day17.wav", "思睿/vocab_audio_output/audio/Day18.wav", diff --git a/小鱼/闲鱼消息通知/README.md b/小鱼/闲鱼消息通知/README.md index d0ce004..d037c1f 100644 --- a/小鱼/闲鱼消息通知/README.md +++ b/小鱼/闲鱼消息通知/README.md @@ -15,6 +15,34 @@ 2. **双击 `probe.bat`** → 生成 `probe-result.json`,把结果发给助手校准未读元素的选择器。 3. **双击 `start-watch.bat`** 测试;确认能弹窗后,改用 `start-watch-hidden.vbs` 后台常驻。 +## 防刷屏 / 防乱发 + +`config.json` 里的 `rateLimit` 是 webhook 的闸门: + +```json +"rateLimit": { "minIntervalSeconds": 60, "maxPerHour": 30 } +``` + +- **合并而非丢弃**:60 秒内的多次新消息不会发多条,而是攒起来,到点合成一条"合并 N 条提醒"发出去。信息不丢,群里不刷屏。 +- **小时上限**:企业微信群机器人官方限制 20 条/分钟(超了返回 `45009`),这里默认 30 条/小时,远低于阈值,不会触发封禁。 +- **本地弹窗不走节流**,因为那是给你自己看的;只有转发到群的受限制。 +- **只在未读数变大时触发**,已读、刷新页面、重复渲染都不会误报。 + +## 密钥安全(重要) + +webhook 的 URL 本身就是密码:拿到 URL 的任何人都能往你群里发任意消息。 + +这个文件夹在 **Obsidian 库里**,如果你的库有同步/进 Git,密钥会跟着上云。所以配置文件支持不写明文: + +```json +"webhooks": [ + { "type": "wecom", "url": "env:WECOM_HOOK" } +] +``` + +然后在系统环境变量里设 `WECOM_HOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`。 +(注意:环境变量对当前用户所有程序可见,安全性高于明文但不等于保险箱。) + ## 通知配置 - 只想要本地弹窗/声音:默认即可。 diff --git a/小鱼/闲鱼消息通知/config.json b/小鱼/闲鱼消息通知/config.json index 6a7d800..e6b55fb 100644 --- a/小鱼/闲鱼消息通知/config.json +++ b/小鱼/闲鱼消息通知/config.json @@ -14,9 +14,12 @@ "soundFile": "", "repeatSound": 2 }, + "rateLimit": { + "minIntervalSeconds": 60, + "maxPerHour": 30 + }, "webhooks": [], "heartbeatMinutes": 0, "quietHours": null, "notifyOnDecrease": false } - diff --git a/小鱼/闲鱼消息通知/notify.js b/小鱼/闲鱼消息通知/notify.js index 04d742b..07bace1 100644 --- a/小鱼/闲鱼消息通知/notify.js +++ b/小鱼/闲鱼消息通知/notify.js @@ -26,8 +26,11 @@ async function postJson(url, payload, headers = {}) { async function sendWebhook(hook, title, text, extra) { const type = (hook.type || 'custom').toLowerCase(); - const url = hook.url; - if (!url) throw new Error('webhook.url 为空'); + // url 支持 "env:变量名" —— 密钥不落在配置文件里(Obsidian 库会同步/进 Git) + const url = typeof hook.url === 'string' && hook.url.startsWith('env:') + ? process.env[hook.url.slice(4)] + : hook.url; + if (!url) throw new Error('webhook.url 为空(或 env: 指定的环境变量未设置)'); switch (type) { case 'feishu': // 飞书自定义机器人 diff --git a/小鱼/闲鱼消息通知/probe.bat b/小鱼/闲鱼消息通知/probe.bat index 5ea5c42..e13ff54 100644 --- a/小鱼/闲鱼消息通知/probe.bat +++ b/小鱼/闲鱼消息通知/probe.bat @@ -1,4 +1,5 @@ @echo off +chcp 65001 >nul cd /d "%~dp0" echo === Goofish probe === node probe.js diff --git a/小鱼/闲鱼消息通知/start-watch.bat b/小鱼/闲鱼消息通知/start-watch.bat index 06af4c0..072272f 100644 --- a/小鱼/闲鱼消息通知/start-watch.bat +++ b/小鱼/闲鱼消息通知/start-watch.bat @@ -1,4 +1,5 @@ @echo off +chcp 65001 >nul cd /d "%~dp0" title Goofish Watcher echo === Goofish watcher (visible mode, Ctrl+C to stop) === diff --git a/小鱼/闲鱼消息通知/watch.js b/小鱼/闲鱼消息通知/watch.js index f2268e4..2afda2c 100644 --- a/小鱼/闲鱼消息通知/watch.js +++ b/小鱼/闲鱼消息通知/watch.js @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const { CDP, listPages, EXPR } = require('./cdp'); -const { notify } = require('./notify'); +const { notify, sendWebhook } = require('./notify'); const DIR = __dirname; const CONFIG_PATH = path.join(DIR, 'config.json'); @@ -25,6 +25,7 @@ function log(...parts) { if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) { fs.renameSync(LOG_PATH, LOG_PATH + '.old'); } + if (!fs.existsSync(LOG_PATH)) fs.writeFileSync(LOG_PATH, '\uFEFF', 'utf8'); // BOM: 记事本/编辑器不乱码 fs.appendFileSync(LOG_PATH, line + '\r\n', 'utf8'); } catch { } } @@ -117,6 +118,58 @@ function savePrev(map) { } catch { } } +// ---------- webhook 节流 + 合并(防刷屏、防企微 20条/分钟限流) ---------- +const pushLog = []; // 已推送时间戳 +let pendingPush = []; // 被节流时暂存的提醒,攒批后一次发出 + +function rateState() { + const rl = cfg.rateLimit || {}; + const minI = Math.max(0, Number(rl.minIntervalSeconds ?? 60)) * 1000; + const maxH = Number(rl.maxPerHour ?? 30); + const now = Date.now(); + while (pushLog.length && now - pushLog[0] > 3600000) pushLog.shift(); + if (maxH > 0 && pushLog.length >= maxH) { + return { ok: false, reason: `已达每小时上限 ${maxH} 条` }; + } + const last = pushLog[pushLog.length - 1]; + if (last && now - last < minI) { + return { ok: false, reason: `距上条推送 ${Math.round((now - last) / 1000)}s(下限 ${minI / 1000}s)` }; + } + return { ok: true }; +} + +async function queuePush(lines, pageUrl) { + const hooks = cfg.webhooks || []; + if (!hooks.length) return; + pendingPush.push({ lines, pageUrl, at: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }); + const st = rateState(); + if (!st.ok) { + log(`webhook 节流:${st.reason},已并入待发队列(${pendingPush.length} 条)`); + return; + } + await flushPush(); +} + +async function flushPush() { + if (!pendingPush.length) return; + const hooks = cfg.webhooks || []; + if (!hooks.length) { pendingPush = []; return; } + const batch = pendingPush.slice(); + pendingPush = []; + const lastUrl = batch[batch.length - 1].pageUrl; + const body = batch.map(b => `[${b.at}] ${b.lines.join(' / ')}`).join('\n') + `\n${lastUrl}`; + const title = batch.length > 1 ? `闲鱼新消息(合并 ${batch.length} 条提醒)` : '闲鱼有新消息'; + pushLog.push(Date.now()); + await Promise.all(hooks.map(async (h) => { + try { + const r = await sendWebhook(h, title, body, { url: lastUrl }); + log(`[webhook] ${h.type} -> ${r.status}`); + } catch (e) { + log(`[webhook] ${h.type} 失败: ${e.message}`); + } + })); +} + function inQuietHours() { const q = cfg.quietHours; if (!q || !q.from || !q.to) return false; @@ -185,10 +238,18 @@ async function tick(prev) { log('静默时段,跳过通知:', JSON.stringify(events)); } else { log('检测到新消息:', JSON.stringify(events)); - await notify(cfg, title, text, { url: head.page }, log); + // 本地弹窗不节流(这才是工具的意义);webhook 走节流 + 合并 + await notify({ ...cfg, webhooks: [] }, title, text, { url: head.page }, log); + await queuePush(lines, head.page); } } + // 有积压且节流窗口已过,补发 + if (pendingPush.length) { + const st = rateState(); + if (st.ok) await flushPush(); + } + savePrev(map); return map; } diff --git a/小鱼/闲鱼消息通知/watch.log b/小鱼/闲鱼消息通知/watch.log deleted file mode 100644 index 4026a66..0000000 --- a/小鱼/闲鱼消息通知/watch.log +++ /dev/null @@ -1,3 +0,0 @@ -[2026/11/9 09:17:49] 发送测试通知… -[2026/11/9 09:21:05] 发送测试通知… -[2026/11/9 09:21:05] 测试通知已发出。若无弹窗,请检查 Windows 通知设置里是否允许 PowerShell/脚本宿主通知。