// 闲鱼卖家中心 未读消息监听 // 用法: // 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, sendWebhook } = 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').replace(/^\uFEFF/, '')); 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'); } if (!fs.existsSync(LOG_PATH)) fs.writeFileSync(LOG_PATH, '\uFEFF', 'utf8'); // BOM: 记事本/编辑器不乱码 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 { } } // ---------- 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; 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)); // 本地弹窗不节流(这才是工具的意义);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; } (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); } })();