vault backup: 2026-11-09 09:18:58

This commit is contained in:
admin committed 2026-11-09 09:18:58 +08:00
1 parent 856d0203da
commit 75072389db
42 files changed
+5994 -37

No files matched your search

+226
View File
@@ -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);
}
})();