fix: make xianyu notifications per-item with clickable links

This commit is contained in:
2026-05-06 23:30:25 +08:00
parent d5294a3f9b
commit ad48715664
13 changed files with 454 additions and 53 deletions
@@ -2,5 +2,7 @@
"enabled": false,
"notifyOnlyGood": true,
"minScore": 75,
"perItem": true,
"maxNotifyItems": 10,
"channels": []
}
+2 -1
View File
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { buildItemLinks } from './links.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
@@ -147,7 +148,7 @@ export function buildAnalysis(task, { limit = 50 } = {}) {
title: item.title,
price: item.detailPrice || item.price || '',
location: item.sellerLocation || '',
href: item.href || item.url || '',
href: buildItemLinks(item).primaryUrl || item.href || item.url || '',
score: item.hermesAnalysis.score,
verdict: item.hermesAnalysis.verdict,
reason: item.hermesAnalysis.reason,
+2 -1
View File
@@ -1,5 +1,6 @@
import { newPage } from './browser.mjs';
import { randomDelay, extractUserId, sleep, waitIfVerification } from './utils.mjs';
import { enrichItemLinks } from './links.mjs';
const BATCH_SIZE = 15;
@@ -42,7 +43,7 @@ export async function fineFilter(products, requirements, emitLog, shouldStop, cu
try {
const detail = await fetchProductDetail(product, emitLog, shouldStop);
const enriched = { ...product, ...detail };
const enriched = enrichItemLinks({ ...product, ...detail });
const result = localFineJudge(enriched, rules);
enriched.localFineReason = result.reason;
enriched.hermesAnalysisStatus = 'pending';
+62
View File
@@ -0,0 +1,62 @@
const GOofishDesktopBase = 'https://www.goofish.com/item';
const GOofishMobileBase = 'https://h5.m.goofish.com/item';
export function normalizeItemId(value = '') {
const text = String(value || '').trim();
if (!text) return '';
const queryMatch = text.match(/[?&](?:id|itemId)=([0-9]+)/i);
if (queryMatch) return queryMatch[1];
const pathMatch = text.match(/(?:item|itemId)[=/]([0-9]+)/i);
if (pathMatch) return pathMatch[1];
const digits = text.match(/\b([0-9]{8,})\b/);
return digits ? digits[1] : '';
}
export function buildItemLinks(itemOrId = {}) {
const raw = typeof itemOrId === 'object' && itemOrId !== null ? itemOrId : { id: itemOrId };
const id = normalizeItemId(raw.id || raw.itemId || raw.href || raw.url || raw.detailUrl || raw.originalHref || raw.shareUrl);
const source = String(raw.href || raw.url || raw.detailUrl || raw.originalHref || raw.shareUrl || '').trim();
let categoryId = String(raw.categoryId || '').trim();
if (!categoryId && source) {
const m = source.match(/[?&]categoryId=([^&#]+)/i);
if (m) categoryId = decodeURIComponent(m[1]);
}
if (!id) {
return {
id: '',
desktopUrl: source,
mobileUrl: source,
webUrl: source,
primaryUrl: source,
markdownUrl: source,
};
}
const categoryQuery = categoryId ? `&categoryId=${encodeURIComponent(categoryId)}` : '';
const desktopUrl = `${GOofishDesktopBase}?id=${encodeURIComponent(id)}${categoryQuery}`;
const mobileUrl = `${GOofishMobileBase}?id=${encodeURIComponent(id)}`;
return {
id,
desktopUrl,
mobileUrl,
webUrl: desktopUrl,
primaryUrl: mobileUrl,
markdownUrl: mobileUrl,
};
}
export function enrichItemLinks(item = {}) {
const links = buildItemLinks(item);
if (!links.id && !links.primaryUrl) return item;
return {
...item,
id: item.id || links.id,
href: links.primaryUrl || item.href || item.url || '',
mobileHref: links.mobileUrl || item.mobileHref || '',
webHref: links.desktopUrl || item.webHref || '',
originalHref: item.originalHref || item.href || item.url || '',
};
}
export function displayItemUrl(itemOrId = {}) {
return buildItemLinks(itemOrId).primaryUrl || '';
}
+63 -32
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import { buildItemLinks } from './links.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
@@ -12,6 +13,8 @@ const DEFAULT_CONFIG = {
enabled: true,
notifyOnlyGood: true,
minScore: 75,
perItem: true,
maxNotifyItems: 10,
channels: [
{ id: 'feishu-default', type: 'hermes-send-message', name: '飞书当前会话', enabled: true, target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812' },
{ id: 'weixin-default', type: 'hermes-send-message', name: '微信默认会话', enabled: false, target: 'weixin' },
@@ -42,6 +45,8 @@ export function saveNotifyConfig(config = {}) {
enabled: config.enabled !== false,
notifyOnlyGood: config.notifyOnlyGood !== false,
minScore: Math.max(0, Math.min(100, Number(config.minScore ?? 75))),
perItem: config.perItem !== false,
maxNotifyItems: Math.max(1, Math.min(50, Number(config.maxNotifyItems ?? 10) || 10)),
channels: (config.channels || []).map((c, i) => ({
id: c.id || `${c.type || 'channel'}-${Date.now()}-${i}`,
type: c.type || 'hermes-send-message',
@@ -61,24 +66,44 @@ export function getHermesTargetOptions() {
return HERMES_TARGET_OPTIONS;
}
function buildNotificationText(task, summary) {
function selectNotificationItems(summary, minScore) {
return (summary.topItems || [])
.filter(i => i.verdict === 'recommend' || i.score >= minScore)
.slice(0, Number(summary.maxNotifyItems || 10));
}
function buildItemMarkdownLink(item) {
const links = buildItemLinks(item);
const url = links.markdownUrl || links.primaryUrl || item.href || item.url || '';
if (!url) return '';
// Feishu 对裸 URL 的自动识别不稳定;显式 Markdown 链接更容易渲染为可点链接。
return `[🔗 打开闲鱼商品](${url})`;
}
function buildNotificationMessages(task, summary) {
const label = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const items = summary.topItems
.filter(i => i.verdict === 'recommend' || i.score >= summary.minScore)
.slice(0, 5);
const lines = [];
lines.push(`🐟 闲鱼任务${task.name || task.id}」发现 ${summary.recommendedCount} 个推荐候选`);
lines.push(`候选/推荐/谨慎/排除:${summary.total}/${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
for (const item of items) {
lines.push(`${item.score}分|${label[item.verdict] || item.verdict}${item.title || ''}`);
const items = selectNotificationItems(summary, summary.minScore);
return items.map((item, index) => {
const lines = [];
lines.push(`🐟 闲鱼候选 ${index + 1}/${items.length}${label[item.verdict] || item.verdict}${item.score}`);
lines.push(`任务${task.name || task.id}`);
lines.push('');
lines.push(`**${item.title || '(无标题)'}**`);
lines.push(`价格:${item.price || '-'}|地区:${item.location || '-'}`);
if (item.reason) lines.push(`判断:${item.reason}`);
if (item.href) lines.push(item.href);
lines.push('');
}
if (summary.reportPath) lines.push(`报告:${summary.reportPath}`);
return lines.join('\n').trim();
if (item.flags?.length) lines.push(`风险:${item.flags.join('')}`);
const link = buildItemMarkdownLink(item);
if (link) lines.push(link);
const rawUrl = buildItemLinks(item).primaryUrl || item.href || item.url || '';
if (rawUrl) lines.push(`备用链接:${rawUrl}`);
return lines.join('\n').trim();
});
}
function buildNotificationText(task, summary) {
const messages = buildNotificationMessages(task, summary);
if (messages.length) return messages.join('\n\n---\n\n');
return `🐟 闲鱼任务「${task.name || task.id}」暂无达到通知阈值的候选`;
}
async function runHermesSendMessage(target, message) {
@@ -124,26 +149,32 @@ export async function sendNotifications(task, summary, options = {}) {
return { ok: true, skipped: true, reason: '没有达到通知条件' };
}
const message = buildNotificationText(task, { ...summary, minScore });
const messages = buildNotificationMessages(task, { ...summary, minScore, maxNotifyItems: cfg.maxNotifyItems ?? summary.maxNotifyItems ?? 10 });
const message = buildNotificationText(task, { ...summary, minScore, maxNotifyItems: cfg.maxNotifyItems ?? summary.maxNotifyItems ?? 10 });
const perItem = cfg.perItem !== false;
const outboundMessages = perItem && messages.length ? messages : [message];
const results = [];
for (const ch of (cfg.channels || []).filter(c => c.enabled)) {
try {
if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') {
if (!ch.target) throw new Error('请选择 Hermes 通知目标');
await runHermesSendMessage(ch.target, message);
} else if (ch.type === 'bark') {
const base = (ch.url || '').replace(/\/$/, '');
const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base;
await postJson(endpoint, { title: `闲鱼发现候选:${task.name || task.id}`, body: message, group: 'xianyu-hunter' });
} else if (ch.type === 'pushplus') {
await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼发现候选${task.name || task.id}`, content: message, template: 'txt' });
} else {
await postJson(ch.url, { title: `闲鱼发现候选${task.name || task.id}`, message, taskId: task.id });
for (let i = 0; i < outboundMessages.length; i++) {
const outbound = outboundMessages[i];
try {
if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') {
if (!ch.target) throw new Error('请选择 Hermes 通知目标');
await runHermesSendMessage(ch.target, outbound);
} else if (ch.type === 'bark') {
const base = (ch.url || '').replace(/\/$/, '');
const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base;
await postJson(endpoint, { title: `闲鱼候选 ${i + 1}/${outboundMessages.length}${task.name || task.id}`, body: outbound, group: 'xianyu-hunter' });
} else if (ch.type === 'pushplus') {
await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼候选 ${i + 1}/${outboundMessages.length}${task.name || task.id}`, content: outbound, template: 'markdown' });
} else {
await postJson(ch.url, { title: `闲鱼候选 ${i + 1}/${outboundMessages.length}${task.name || task.id}`, message: outbound, taskId: task.id, itemIndex: i + 1 });
}
results.push({ id: ch.id, name: ch.name, itemIndex: i + 1, ok: true });
} catch (err) {
results.push({ id: ch.id, name: ch.name, itemIndex: i + 1, ok: false, error: err.message });
}
results.push({ id: ch.id, name: ch.name, ok: true });
} catch (err) {
results.push({ id: ch.id, name: ch.name, ok: false, error: err.message });
}
}
return { ok: results.every(r => r.ok), skipped: false, message, results };
return { ok: results.every(r => r.ok), skipped: false, message, messages: outboundMessages, perItem, results };
}
+9 -3
View File
@@ -1,5 +1,6 @@
import { newPage } from './browser.mjs';
import { randomDelay, sleep, waitIfVerification } from './utils.mjs';
import { sleep, randomDelay, parsePrice, waitIfVerification } from './utils.mjs';
import { enrichItemLinks } from './links.mjs';
const BASE_SEARCH_URL = 'https://www.goofish.com/search';
@@ -316,7 +317,7 @@ async function applyRegionFilter(page, regionInput, emitLog) {
async function scrapeCurrentPage(page) {
await autoScroll(page);
return page.evaluate(() => {
const scraped = await page.evaluate(() => {
const items = [];
const cards = document.querySelectorAll('a[class*="feeds-item-wrap"]');
@@ -330,10 +331,13 @@ async function scrapeCurrentPage(page) {
const descEl = card.querySelector('[class*="price-desc"]');
const sellerWrap = card.querySelector('[class*="seller-text-wrap"]');
const imgEl = card.querySelector('img[class*="feeds-image"]');
const sourceHref = href.startsWith('http') ? href : `https://www.goofish.com${href}`;
const categoryMatch = sourceHref.match(/[?&]categoryId=([^&#]+)/);
items.push({
id: idMatch[1],
href: href.startsWith('http') ? href : `https://www.goofish.com${href}`,
href: sourceHref,
categoryId: categoryMatch ? decodeURIComponent(categoryMatch[1]) : '',
title: titleEl?.getAttribute('title') || titleEl?.textContent?.trim() || '',
price: priceEl?.textContent?.trim() || '',
priceDesc: descEl?.textContent?.trim() || '',
@@ -344,6 +348,8 @@ async function scrapeCurrentPage(page) {
return items;
});
return scraped.map(item => enrichItemLinks(item));
}
async function autoScroll(page) {
+22 -14
View File
@@ -7,6 +7,7 @@ import { searchProducts } from './search.mjs';
import { coarseFilter, fineFilter } from './filter.mjs';
import { buildAnalysis, renderAnalysisMarkdown } from './analyzer.mjs';
import { sendNotifications } from './notifier.mjs';
import { enrichItemLinks } from './links.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
@@ -456,6 +457,7 @@ export class TaskManager {
}
_serialize(task) {
const normalizedProducts = this._normalizeProducts(task.products || {});
return {
id: task.id,
name: task.name,
@@ -464,12 +466,12 @@ export class TaskManager {
running: task.running,
stopped: task.stopped,
products: {
rawCount: task.products.raw.length,
coarseCount: task.products.coarseFiltered.length,
fineCount: task.products.fineFiltered.length,
raw: task.products.raw.slice(0, 100),
coarseFiltered: task.products.coarseFiltered.slice(0, 50),
fineFiltered: task.products.fineFiltered,
rawCount: normalizedProducts.raw.length,
coarseCount: normalizedProducts.coarseFiltered.length,
fineCount: normalizedProducts.fineFiltered.length,
raw: normalizedProducts.raw.slice(0, 100),
coarseFiltered: normalizedProducts.coarseFiltered.slice(0, 50),
fineFiltered: normalizedProducts.fineFiltered,
},
chatSessions: task.chatSessions || [],
hermesAnalysis: task.hermesAnalysis || null,
@@ -480,15 +482,16 @@ export class TaskManager {
}
_serializeFull(task) {
const normalizedProducts = this._normalizeProducts(task.products || {});
return {
id: task.id,
name: task.name,
config: task.config,
stage: task.stage,
products: {
raw: task.products.raw,
coarseFiltered: task.products.coarseFiltered,
fineFiltered: task.products.fineFiltered,
raw: normalizedProducts.raw,
coarseFiltered: normalizedProducts.coarseFiltered,
fineFiltered: normalizedProducts.fineFiltered,
},
chatSessions: task.chatSessions || [],
chatSessionsFull: task.chatSessionsFull || [],
@@ -499,6 +502,14 @@ export class TaskManager {
};
}
_normalizeProducts(products = {}) {
return {
raw: (products.raw || []).map(item => enrichItemLinks(item)),
coarseFiltered: (products.coarseFiltered || []).map(item => enrichItemLinks(item)),
fineFiltered: (products.fineFiltered || []).map(item => enrichItemLinks(item)),
};
}
_persistToDisk() {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -551,6 +562,7 @@ export class TaskManager {
console.log(` 🔄 任务 ${t.id}: 从旧格式迁移了 ${chatSessionsFull.length} 个聊天会话`);
}
const normalizedProducts = this._normalizeProducts(t.products || {});
const task = {
id: t.id,
name: t.name,
@@ -558,11 +570,7 @@ export class TaskManager {
stage: (t.stage === 'completed' || t.stage === 'stopped') ? t.stage : 'stopped',
running: false,
stopped: true,
products: {
raw: t.products?.raw || [],
coarseFiltered: t.products?.coarseFiltered || [],
fineFiltered: t.products?.fineFiltered || [],
},
products: normalizedProducts,
chatSessions: t.chatSessions || [],
chatSessionsFull,
hermesAnalysis: t.hermesAnalysis || null,
+4 -1
View File
@@ -137,19 +137,22 @@ app.post('/api/notify-test', async (req, res) => {
const task = { id: 'notify-test', name: '通知渠道测试' };
const summary = {
minScore: Number(config.minScore ?? 75),
maxNotifyItems: 1,
total: 1,
recommendedCount: 1,
cautionCount: 0,
rejectedCount: 0,
reportPath: '这是测试通知,不对应真实报告',
topItems: [{
id: '1017096975429',
title: req.body?.title || 'xianyu-hunter 测试通知',
score: 99,
verdict: 'recommend',
price: '测试',
location: '本机',
reason: `测试通知渠道:${targetLabel}`,
href: '',
href: 'https://www.goofish.com/item?id=1017096975429&categoryId=126856267',
categoryId: '126856267',
}],
};
const result = await sendNotifications(task, summary, { force: true, config: { ...config, enabled: true, notifyOnlyGood: false } });
@@ -2,6 +2,7 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { buildItemLinks } from '../lib/links.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
@@ -76,7 +77,7 @@ function renderTask(task, limit) {
for (const { item, score, flags } of items) {
const price = item.detailPrice || item.price || '-';
const loc = item.sellerLocation || '-';
const href = item.href || item.url || '';
const href = buildItemLinks(item).primaryUrl || item.href || item.url || '';
lines.push(`### ${score}分|${item.title || '(无标题)'}`);
lines.push(`- 价格:${price}`);
lines.push(`- 地区/卖家:${loc}${item.sellerName ? ` / ${item.sellerName}` : ''}`);