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,75 @@
|
||||
// Minimal Chrome DevTools Protocol client (Node 22 global WebSocket, no npm deps).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class CDP {
|
||||
constructor(wsUrl) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
connect(timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
this.ws = ws;
|
||||
const timer = setTimeout(() => { try { ws.close(); } catch {} reject(new Error('connect timeout')); }, timeoutMs);
|
||||
ws.onopen = () => { clearTimeout(timer); resolve(this); };
|
||||
ws.onerror = () => { clearTimeout(timer); reject(new Error('ws error')); };
|
||||
ws.onclose = () => {
|
||||
this.closed = true;
|
||||
for (const [, p] of this.pending) p.rej(new Error('ws closed'));
|
||||
this.pending.clear();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch { return; }
|
||||
if (m.id && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id);
|
||||
this.pending.delete(m.id);
|
||||
if (m.error) p.rej(new Error(m.error.message || 'cdp error'));
|
||||
else p.res(m.result);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
send(method, params = {}, timeoutMs = 15000) {
|
||||
if (this.closed) return Promise.reject(new Error('ws closed'));
|
||||
const id = ++this.id;
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { res, rej });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
setTimeout(() => {
|
||||
if (this.pending.has(id)) { this.pending.delete(id); rej(new Error('cdp timeout: ' + method)); }
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async eval(expression, timeoutMs = 15000) {
|
||||
const r = await this.send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture: true
|
||||
}, timeoutMs);
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'eval failed');
|
||||
}
|
||||
return r.result ? r.result.value : undefined;
|
||||
}
|
||||
|
||||
close() { try { this.ws.close(); } catch {} this.closed = true; }
|
||||
}
|
||||
|
||||
async function listPages(port, urlMatch) {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) });
|
||||
const all = await res.json();
|
||||
return all.filter(t => t.type === 'page' && typeof t.url === 'string'
|
||||
&& t.url.includes(urlMatch) && !t.url.startsWith('devtools://'));
|
||||
}
|
||||
|
||||
const EXPR = fs.readFileSync(path.join(__dirname, 'probe-expression.js'), 'utf8');
|
||||
|
||||
module.exports = { CDP, listPages, EXPR };
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"debugPort": 9222,
|
||||
"urlMatch": "goofish.com",
|
||||
"pollSeconds": 5,
|
||||
"titleRegex": "\\((\\d+)\\)",
|
||||
"badgeFilter": {
|
||||
"requireRed": true,
|
||||
"includePathRegex": "",
|
||||
"excludePathRegex": ""
|
||||
},
|
||||
"notify": {
|
||||
"toast": true,
|
||||
"sound": true,
|
||||
"soundFile": "",
|
||||
"repeatSound": 2
|
||||
},
|
||||
"webhooks": [],
|
||||
"heartbeatMinutes": 0,
|
||||
"quietHours": null,
|
||||
"notifyOnDecrease": false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0launch-edge.ps1" %*
|
||||
pause
|
||||
@@ -0,0 +1,48 @@
|
||||
param(
|
||||
[int]$Port = 9222,
|
||||
[string]$StartUrl = "https://seller.goofish.com/"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$candidates = @(
|
||||
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
"C:\Program Files\Microsoft\Edge\Application\msedge.exe"
|
||||
)
|
||||
$edge = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $edge) { Write-Host "Edge not found. Edit the path in launch-edge.ps1."; exit 1 }
|
||||
|
||||
$profileDir = Join-Path $PSScriptRoot "edge-profile"
|
||||
New-Item -ItemType Directory -Force -Path $profileDir | Out-Null
|
||||
|
||||
# A dedicated --user-data-dir is required: Edge 136+ ignores the debug port on the default profile.
|
||||
$args = @(
|
||||
"--remote-debugging-port=$Port",
|
||||
"--user-data-dir=$profileDir",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-features=Translate",
|
||||
$StartUrl
|
||||
)
|
||||
|
||||
$running = Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like "*--remote-debugging-port=$Port*" }
|
||||
|
||||
if ($running) {
|
||||
Write-Host "Watcher Edge already running on port $Port."
|
||||
} else {
|
||||
Start-Process -FilePath $edge -ArgumentList $args
|
||||
Write-Host "Edge launched on debug port $Port (profile: $profileDir)"
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
try {
|
||||
$v = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/json/version" -UseBasicParsing -TimeoutSec 5
|
||||
Write-Host "Debug endpoint OK:"
|
||||
Write-Host $v.Content
|
||||
} catch {
|
||||
Write-Host "Debug endpoint not reachable yet: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "NEXT: log in to Goofish in this window, open the MESSAGES page, keep the tab open."
|
||||
@@ -0,0 +1,103 @@
|
||||
// 通知模块:Windows Toast + 提示音 + 各种 webhook 转发
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
function toast(title, body, soundRepeat) {
|
||||
const ps1 = path.join(__dirname, 'toast.ps1');
|
||||
const exe = process.platform === 'win32' ? (process.env.DSH_PWSH || 'powershell') : null;
|
||||
if (!exe) return;
|
||||
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, title, body, String(soundRepeat)];
|
||||
try {
|
||||
// stdio:'ignore' keeps this working even in restricted environments (no pipes needed)
|
||||
const child = execFile(exe, args, { windowsHide: true, stdio: 'ignore' }, () => {});
|
||||
if (child && child.unref) child.unref();
|
||||
} catch (e) { /* 通知失败不影响监听 */ }
|
||||
}
|
||||
|
||||
async function postJson(url, payload, headers = {}) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
||||
}
|
||||
|
||||
async function sendWebhook(hook, title, text, extra) {
|
||||
const type = (hook.type || 'custom').toLowerCase();
|
||||
const url = hook.url;
|
||||
if (!url) throw new Error('webhook.url 为空');
|
||||
|
||||
switch (type) {
|
||||
case 'feishu': // 飞书自定义机器人
|
||||
return postJson(url, { msg_type: 'text', content: { text: `${title}\n${text}` } });
|
||||
|
||||
case 'wecom': // 企业微信群机器人
|
||||
case 'dingtalk': // 钉钉自定义机器人
|
||||
return postJson(url, { msgtype: 'text', text: { content: `${title}\n${text}` } });
|
||||
|
||||
case 'bark': { // 手机推送 iOS: https://api.day.app/你的KEY
|
||||
const base = url.replace(/\/+$/, '');
|
||||
const r = await fetch(`${base}/${encodeURIComponent(title)}/${encodeURIComponent(text)}?group=Goofish&level=timeSensitive`, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
||||
}
|
||||
|
||||
case 'serverchan': { // Server酱 https://sctapi.ftqq.com/你的KEY.send
|
||||
const body = new URLSearchParams({ title, desp: text });
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
||||
}
|
||||
|
||||
case 'pushplus': { // http://www.pushplus.plus/send
|
||||
return postJson(url, { token: hook.token || '', title, content: text, template: 'txt' });
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
const method = (hook.method || 'POST').toUpperCase();
|
||||
let body;
|
||||
if (method === 'GET') {
|
||||
body = undefined;
|
||||
} else if (typeof hook.body === 'string') {
|
||||
body = hook.body.replaceAll('{{title}}', title).replaceAll('{{text}}', text).replaceAll('{{url}}', extra?.url || '');
|
||||
} else {
|
||||
body = JSON.stringify({ title, text, ...extra });
|
||||
}
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': typeof hook.body === 'string' ? 'text/plain; charset=utf-8' : 'application/json', ...(hook.headers || {}) },
|
||||
body: method === 'GET' ? undefined : body,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`未知 webhook 类型: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(cfg, title, text, extra = {}, logger = console.log) {
|
||||
if (cfg.notify?.toast) {
|
||||
toast(title, text, cfg.notify?.repeatSound ?? 1);
|
||||
}
|
||||
const hooks = cfg.webhooks || [];
|
||||
if (!hooks.length) return;
|
||||
await Promise.all(hooks.map(async (h) => {
|
||||
try {
|
||||
const r = await sendWebhook(h, title, text, extra);
|
||||
logger(`[webhook] ${h.type} ${h.url.slice(0, 60)} -> ${r.status}`);
|
||||
} catch (e) {
|
||||
logger(`[webhook] ${h.type} 失败: ${e.message}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { notify, toast, sendWebhook };
|
||||
@@ -0,0 +1,87 @@
|
||||
(() => {
|
||||
const MAX = 120;
|
||||
const path = (el) => {
|
||||
const parts = [];
|
||||
let n = el;
|
||||
while (n && n.nodeType === 1 && parts.length < 5) {
|
||||
let s = n.tagName.toLowerCase();
|
||||
if (n.id) { s += '#' + n.id; parts.unshift(s); break; }
|
||||
const cls = (n.className || '').toString().trim().split(/\s+/).filter(Boolean).slice(0, 3);
|
||||
if (cls.length) s += '.' + cls.join('.');
|
||||
parts.unshift(s);
|
||||
n = n.parentElement;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
|
||||
const out = {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
visible: document.visibilityState,
|
||||
time: new Date().toISOString(),
|
||||
domSize: 0,
|
||||
badges: [],
|
||||
classHits: [],
|
||||
titleNumbers: [],
|
||||
iframes: []
|
||||
};
|
||||
|
||||
try {
|
||||
const all = document.querySelectorAll('*');
|
||||
out.domSize = all.length;
|
||||
|
||||
// 1) small numeric nodes that look like an unread counter
|
||||
for (const el of all) {
|
||||
if (out.badges.length >= MAX) break;
|
||||
if (el.children.length > 1) continue;
|
||||
const txt = (el.textContent || '').trim();
|
||||
if (!/^\d{1,4}\+?$/.test(txt)) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 6 || r.height < 6 || r.width > 140 || r.height > 60) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
const bg = (cs.backgroundColor.match(/\d+/g) || []).map(Number);
|
||||
const fg = (cs.color.match(/\d+/g) || []).map(Number);
|
||||
const bgRed = bg.length >= 3 && bg[0] > 150 && bg[1] < 120 && bg[2] < 120;
|
||||
const fgRed = fg.length >= 3 && fg[0] > 150 && fg[1] < 110 && fg[2] < 110;
|
||||
out.badges.push({
|
||||
p: path(el),
|
||||
t: txt,
|
||||
red: bgRed || fgRed,
|
||||
bg: cs.backgroundColor,
|
||||
color: cs.color,
|
||||
fs: cs.fontSize,
|
||||
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
|
||||
parentText: ((el.parentElement && el.parentElement.textContent) || '').trim().slice(0, 60)
|
||||
});
|
||||
}
|
||||
|
||||
// 2) nodes carrying unread-ish class names
|
||||
const sel = '[class*="unread"],[class*="badge"],[class*="Unread"],[class*="Badge"],[class*="red-dot"],[class*="redDot"],[class*="num"],[class*="count"],[class*="Count"]';
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
if (out.classHits.length >= MAX) break;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
out.classHits.push({
|
||||
p: path(el),
|
||||
cls: (el.className || '').toString().slice(0, 160),
|
||||
t: (el.textContent || '').trim().slice(0, 40),
|
||||
bg: cs.backgroundColor,
|
||||
display: cs.display,
|
||||
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]
|
||||
});
|
||||
}
|
||||
|
||||
// 3) numbers inside the tab title
|
||||
const tn = (document.title || '').match(/\d{1,4}/g) || [];
|
||||
out.titleNumbers = tn.slice(0, 6);
|
||||
|
||||
// 4) frames (message panes are sometimes iframed)
|
||||
for (const f of document.querySelectorAll('iframe')) {
|
||||
out.iframes.push({ src: f.src || '', p: path(f) });
|
||||
}
|
||||
} catch (e) {
|
||||
out.error = String(e && e.message || e);
|
||||
}
|
||||
|
||||
return out;
|
||||
})()
|
||||
@@ -0,0 +1,56 @@
|
||||
// 探针:连接已打开的 Edge,把每个 goofish 页面的候选"未读"元素打印/保存出来。
|
||||
// 用法: node probe.js [输出文件]
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { CDP, listPages, EXPR } = require('./cdp');
|
||||
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
|
||||
|
||||
(async () => {
|
||||
const outFile = process.argv[2] || path.join(__dirname, 'probe-result.json');
|
||||
let pages;
|
||||
try {
|
||||
pages = await listPages(cfg.debugPort, cfg.urlMatch);
|
||||
} catch (e) {
|
||||
console.error(`无法连接调试端口 ${cfg.debugPort}:${e.message}`);
|
||||
console.error('请先双击 launch-edge.bat 启动 Edge,并在那个窗口登录闲鱼、打开消息页。');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!pages.length) {
|
||||
console.error(`没有找到包含 "${cfg.urlMatch}" 的标签页。请在启动的 Edge 窗口里打开 seller.goofish.com 消息页。`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const p of pages) {
|
||||
const cdp = new CDP(p.webSocketDebuggerUrl);
|
||||
try {
|
||||
await cdp.connect();
|
||||
const st = await cdp.eval(EXPR);
|
||||
results.push(st);
|
||||
console.log('='.repeat(70));
|
||||
console.log('URL :', st.url);
|
||||
console.log('TITLE :', st.title);
|
||||
console.log('DOM :', st.domSize, ' nodes, visibility:', st.visible);
|
||||
console.log('红点候选 badges:', st.badges.length);
|
||||
st.badges.slice(0, 40).forEach(b => {
|
||||
console.log(` ${b.red ? '[RED]' : '[ ]'} "${b.t}" fs=${b.fs} bg=${b.bg} rect=${b.rect}`);
|
||||
console.log(` ${b.p}`);
|
||||
console.log(` parentText: ${JSON.stringify(b.parentText)}`);
|
||||
});
|
||||
console.log('class 命中:', st.classHits.length);
|
||||
st.classHits.slice(0, 25).forEach(h => {
|
||||
console.log(` "${h.t}" cls=${h.cls} rect=${h.rect}`);
|
||||
console.log(` ${h.p}`);
|
||||
});
|
||||
console.log('title 内数字:', JSON.stringify(st.titleNumbers));
|
||||
if (st.iframes.length) console.log('iframes:', JSON.stringify(st.iframes));
|
||||
} catch (e) {
|
||||
console.error('探针失败:', p.url, e.message);
|
||||
} finally {
|
||||
cdp.close();
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outFile, JSON.stringify(results, null, 2), 'utf8');
|
||||
console.log('\n完整结果已保存:', outFile);
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][string]$Body,
|
||||
[int]$Sound = 1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$shown = $false
|
||||
|
||||
try {
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||
|
||||
$t = [System.Security.SecurityElement]::Escape($Title)
|
||||
$b = [System.Security.SecurityElement]::Escape($Body)
|
||||
$xmlText = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
<text>$t</text>
|
||||
<text>$b</text>
|
||||
<text placement="attribution">Goofish Watcher</text>
|
||||
</binding>
|
||||
</visual>
|
||||
<audio silent="true"/>
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($xmlText)
|
||||
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
|
||||
$appIds = @(
|
||||
'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe',
|
||||
'Microsoft.PowerShell',
|
||||
'GoofishWatcher'
|
||||
)
|
||||
foreach ($id in $appIds) {
|
||||
try {
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id).Show($toast)
|
||||
$shown = $true
|
||||
break
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if ($Sound -gt 0) {
|
||||
for ($i = 0; $i -lt $Sound; $i++) {
|
||||
try { [System.Media.SystemSounds]::Exclamation.Play() } catch { }
|
||||
if ($i -lt $Sound - 1) { Start-Sleep -Milliseconds 500 }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $shown) {
|
||||
try {
|
||||
$sh = New-Object -ComObject WScript.Shell
|
||||
$null = $sh.Popup("$Title`n$Body", 8, "Goofish Watcher", 64)
|
||||
} catch { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
[2026/11/9 09:17:49] 发送测试通知…
|
||||
Reference in new issue
Block a user