fix: make xianyu notifications per-item with clickable links
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user