vault backup: 2026-11-09 09:18:58
This commit is contained in:
1 parent
856d0203da
commit
75072389db
42 files changed
+5994
-37
No files matched your search
@@ -0,0 +1,226 @@
|
||||
// 闲鱼卖家中心 未读消息监听
|
||||
// 用法:
|
||||
// node watch.js 正常轮询
|
||||
// node watch.js --test 只发一条测试通知,验证 Toast / webhook 是否通
|
||||
// node watch.js --dump 每次轮询打印看到的未读元素(调试用)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { CDP, listPages, EXPR } = require('./cdp');
|
||||
const { notify } = require('./notify');
|
||||
|
||||
const DIR = __dirname;
|
||||
const CONFIG_PATH = path.join(DIR, 'config.json');
|
||||
const LOG_PATH = path.join(DIR, 'watch.log');
|
||||
const STATE_PATH = path.join(DIR, 'state.json');
|
||||
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
const ARGS = process.argv.slice(2);
|
||||
const TEST = ARGS.includes('--test');
|
||||
const DUMP = ARGS.includes('--dump');
|
||||
|
||||
function log(...parts) {
|
||||
const line = `[${new Date().toLocaleString('zh-CN', { hour12: false })}] ${parts.join(' ')}`;
|
||||
console.log(line);
|
||||
try {
|
||||
if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) {
|
||||
fs.renameSync(LOG_PATH, LOG_PATH + '.old');
|
||||
}
|
||||
fs.appendFileSync(LOG_PATH, line + '\r\n', 'utf8');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const conns = new Map(); // targetId -> CDP
|
||||
|
||||
async function pollPages() {
|
||||
const pages = await listPages(cfg.debugPort, cfg.urlMatch);
|
||||
const states = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const p of pages) {
|
||||
seen.add(p.id);
|
||||
let cdp = conns.get(p.id);
|
||||
if (!cdp || cdp.closed) {
|
||||
cdp = new CDP(p.webSocketDebuggerUrl);
|
||||
try {
|
||||
await cdp.connect();
|
||||
conns.set(p.id, cdp);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const st = await cdp.eval(EXPR);
|
||||
if (st) states.push(st);
|
||||
} catch (e) {
|
||||
log('读取页面失败(可能正在刷新):', e.message);
|
||||
try { cdp.close(); } catch { }
|
||||
conns.delete(p.id);
|
||||
}
|
||||
}
|
||||
for (const id of [...conns.keys()]) {
|
||||
if (!seen.has(id)) { try { conns.get(id).close(); } catch { } conns.delete(id); }
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function pathAllowed(p, filter) {
|
||||
if (!filter) return true;
|
||||
if (filter.includePathRegex) {
|
||||
if (!new RegExp(filter.includePathRegex).test(p)) return false;
|
||||
}
|
||||
if (filter.excludePathRegex) {
|
||||
if (new RegExp(filter.excludePathRegex).test(p)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 把页面状态压平成 { key: 未读数 }
|
||||
function extractCounters(states) {
|
||||
const map = new Map();
|
||||
const detail = [];
|
||||
for (const st of states) {
|
||||
const page = (st.url || '').split('?')[0];
|
||||
|
||||
for (const b of st.badges || []) {
|
||||
if (cfg.badgeFilter?.requireRed && !b.red) continue;
|
||||
if (!pathAllowed(b.p, cfg.badgeFilter)) continue;
|
||||
const n = parseInt(String(b.t).replace('+', ''), 10);
|
||||
if (!Number.isFinite(n) || n <= 0) continue;
|
||||
const key = `${page}||${b.p}`;
|
||||
map.set(key, Math.max(n, map.get(key) || 0));
|
||||
detail.push({ key, n, t: b.t, p: b.p, page, parentText: b.parentText });
|
||||
}
|
||||
|
||||
if (cfg.titleRegex) {
|
||||
try {
|
||||
const m = (st.title || '').match(new RegExp(cfg.titleRegex));
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (Number.isFinite(n) && n > 0) map.set(`title||${page}`, n);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
return { map, detail };
|
||||
}
|
||||
|
||||
function loadPrev() {
|
||||
try { return new Map(Object.entries(JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')).counters || {})); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function savePrev(map) {
|
||||
try {
|
||||
fs.writeFileSync(STATE_PATH, JSON.stringify({ savedAt: new Date().toISOString(), counters: Object.fromEntries(map) }, null, 2), 'utf8');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function inQuietHours() {
|
||||
const q = cfg.quietHours;
|
||||
if (!q || !q.from || !q.to) return false;
|
||||
const now = new Date();
|
||||
const mins = now.getHours() * 60 + now.getMinutes();
|
||||
const [fh, fm] = String(q.from).split(':').map(Number);
|
||||
const [th, tm] = String(q.to).split(':').map(Number);
|
||||
const from = fh * 60 + fm, to = th * 60 + tm;
|
||||
return from <= to ? (mins >= from && mins < to) : (mins >= from || mins < to);
|
||||
}
|
||||
|
||||
async function tick(prev) {
|
||||
let states;
|
||||
try {
|
||||
states = await pollPages();
|
||||
} catch (e) {
|
||||
log(`调试端口 ${cfg.debugPort} 连不上: ${e.message}(Edge 关了吗?)`);
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (!states.length) {
|
||||
log(`没找到 goofish 标签页。请在调试端口的 Edge 里打开 seller.goofish.com 消息页。`);
|
||||
return prev;
|
||||
}
|
||||
|
||||
const { map, detail } = extractCounters(states);
|
||||
|
||||
if (DUMP) {
|
||||
log(`页面 ${states.length} 个 | 命中计数器 ${map.size} 个`);
|
||||
detail.forEach(d => log(` ● ${d.t} ${d.p} parent="${d.parentText}"`));
|
||||
if (!map.size) {
|
||||
log(' (没有命中的未读元素,用 node probe.js 看候选)');
|
||||
}
|
||||
}
|
||||
|
||||
if (!prev) {
|
||||
log(`已建立基线:当前未读计数器 ${map.size} 个 -> ${JSON.stringify(Object.fromEntries(map))}`);
|
||||
savePrev(map);
|
||||
return map;
|
||||
}
|
||||
|
||||
const events = [];
|
||||
for (const [k, v] of map) {
|
||||
const before = prev.get(k) || 0;
|
||||
if (v > before) {
|
||||
const d = detail.find(x => x.key === k);
|
||||
events.push({ key: k, from: before, to: v, page: k.split('||')[0], p: d ? d.p : '', parentText: d ? d.parentText : '' });
|
||||
}
|
||||
}
|
||||
if (cfg.notifyOnDecrease) {
|
||||
for (const [k, v] of prev) {
|
||||
if (!map.has(k) && v > 0) events.push({ key: k, from: v, to: 0, page: k.split('||')[0], p: k.split('||')[1] || '', parentText: '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
const total = [...map.values()].reduce((a, b) => a + b, 0);
|
||||
const head = events[0];
|
||||
const title = `闲鱼有新消息(未读约 ${total})`;
|
||||
const lines = events.map(e => {
|
||||
const where = e.p ? e.p.split(' > ').slice(-1)[0] : '页面';
|
||||
return `· ${where}:${e.from} → ${e.to}`;
|
||||
});
|
||||
const text = `${lines.join('\n')}\n${head.page}`;
|
||||
if (inQuietHours()) {
|
||||
log('静默时段,跳过通知:', JSON.stringify(events));
|
||||
} else {
|
||||
log('检测到新消息:', JSON.stringify(events));
|
||||
await notify(cfg, title, text, { url: head.page }, log);
|
||||
}
|
||||
}
|
||||
|
||||
savePrev(map);
|
||||
return map;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (TEST) {
|
||||
log('发送测试通知…');
|
||||
await notify(cfg, '闲鱼消息提醒 · 测试', `这是一条测试通知。\n本地 Toast / 声音 / webhook 配置检查。\n${new Date().toLocaleString('zh-CN')}`, {}, log);
|
||||
log('测试通知已发出。若无弹窗,请检查 Windows 通知设置里是否允许 PowerShell/脚本宿主通知。');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`启动监听 | 端口 ${cfg.debugPort} | 页面匹配 "${cfg.urlMatch}" | 轮询 ${cfg.pollSeconds}s | webhook ${(cfg.webhooks || []).length} 个`);
|
||||
|
||||
let prev = loadPrev();
|
||||
if (prev) log(`已载入上次状态(${prev.size} 个计数器)`);
|
||||
|
||||
let lastHeartbeat = Date.now();
|
||||
process.on('SIGINT', () => { log('收到 Ctrl+C,退出'); process.exit(0); });
|
||||
process.on('uncaughtException', (e) => log('未捕获异常:', e.message));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
prev = await tick(prev);
|
||||
} catch (e) {
|
||||
log('轮询异常:', e.message);
|
||||
}
|
||||
if (cfg.heartbeatMinutes > 0 && Date.now() - lastHeartbeat > cfg.heartbeatMinutes * 60000) {
|
||||
lastHeartbeat = Date.now();
|
||||
const s = prev ? [...prev.values()].reduce((a, b) => a + b, 0) : 0;
|
||||
log(`心跳:运行中,当前未读合计 ${s}`);
|
||||
}
|
||||
await sleep(Math.max(2, cfg.pollSeconds) * 1000);
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user