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

+75
View File
@@ -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 };
+21
View File
@@ -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
+48
View File
@@ -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."
+103
View File
@@ -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;
})()
+56
View File
@@ -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);
})();
+59
View File
@@ -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 { }
}
+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);
}
})();
+1
View File
@@ -0,0 +1 @@
[2026/11/9 09:17:49] 发送测试通知…