From ad48715664900040e8b92f3d987357694fea2835 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 6 May 2026 23:30:25 +0800 Subject: [PATCH] fix: make xianyu notifications per-item with clickable links --- .../data/notify-config.example.json | 2 + project/xianyu-hunter/lib/analyzer.mjs | 3 +- project/xianyu-hunter/lib/filter.mjs | 3 +- project/xianyu-hunter/lib/links.mjs | 62 ++++++++ project/xianyu-hunter/lib/notifier.mjs | 95 ++++++++----- project/xianyu-hunter/lib/search.mjs | 12 +- project/xianyu-hunter/lib/task.mjs | 36 +++-- project/xianyu-hunter/server.mjs | 5 +- project/xianyu-hunter/tools/hermes_report.mjs | 3 +- skill/xianyu-hunter-monitor/SKILL.md | 6 + .../gitea-portable-package-sync-2026-05.md | 133 ++++++++++++++++++ .../item-link-normalization-2026-05.md | 62 ++++++++ ...item-notifications-feishu-links-2026-05.md | 85 +++++++++++ 13 files changed, 454 insertions(+), 53 deletions(-) create mode 100644 project/xianyu-hunter/lib/links.mjs create mode 100644 skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md create mode 100644 skill/xianyu-hunter-monitor/references/item-link-normalization-2026-05.md create mode 100644 skill/xianyu-hunter-monitor/references/per-item-notifications-feishu-links-2026-05.md diff --git a/project/xianyu-hunter/data/notify-config.example.json b/project/xianyu-hunter/data/notify-config.example.json index 5c6182a..b17d23c 100644 --- a/project/xianyu-hunter/data/notify-config.example.json +++ b/project/xianyu-hunter/data/notify-config.example.json @@ -2,5 +2,7 @@ "enabled": false, "notifyOnlyGood": true, "minScore": 75, + "perItem": true, + "maxNotifyItems": 10, "channels": [] } diff --git a/project/xianyu-hunter/lib/analyzer.mjs b/project/xianyu-hunter/lib/analyzer.mjs index 0d6ff11..4fc34ce 100644 --- a/project/xianyu-hunter/lib/analyzer.mjs +++ b/project/xianyu-hunter/lib/analyzer.mjs @@ -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, diff --git a/project/xianyu-hunter/lib/filter.mjs b/project/xianyu-hunter/lib/filter.mjs index 533088a..8fcda14 100644 --- a/project/xianyu-hunter/lib/filter.mjs +++ b/project/xianyu-hunter/lib/filter.mjs @@ -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'; diff --git a/project/xianyu-hunter/lib/links.mjs b/project/xianyu-hunter/lib/links.mjs new file mode 100644 index 0000000..1c62ee5 --- /dev/null +++ b/project/xianyu-hunter/lib/links.mjs @@ -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 || ''; +} diff --git a/project/xianyu-hunter/lib/notifier.mjs b/project/xianyu-hunter/lib/notifier.mjs index 1bedf5f..f7b9daf 100644 --- a/project/xianyu-hunter/lib/notifier.mjs +++ b/project/xianyu-hunter/lib/notifier.mjs @@ -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 }; } diff --git a/project/xianyu-hunter/lib/search.mjs b/project/xianyu-hunter/lib/search.mjs index c6370b1..85b6d46 100644 --- a/project/xianyu-hunter/lib/search.mjs +++ b/project/xianyu-hunter/lib/search.mjs @@ -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) { diff --git a/project/xianyu-hunter/lib/task.mjs b/project/xianyu-hunter/lib/task.mjs index c33c006..070c665 100644 --- a/project/xianyu-hunter/lib/task.mjs +++ b/project/xianyu-hunter/lib/task.mjs @@ -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, diff --git a/project/xianyu-hunter/server.mjs b/project/xianyu-hunter/server.mjs index f9b0e3d..36e7154 100644 --- a/project/xianyu-hunter/server.mjs +++ b/project/xianyu-hunter/server.mjs @@ -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 } }); diff --git a/project/xianyu-hunter/tools/hermes_report.mjs b/project/xianyu-hunter/tools/hermes_report.mjs index f09a9d4..ccaa48e 100644 --- a/project/xianyu-hunter/tools/hermes_report.mjs +++ b/project/xianyu-hunter/tools/hermes_report.mjs @@ -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}` : ''}`); diff --git a/skill/xianyu-hunter-monitor/SKILL.md b/skill/xianyu-hunter-monitor/SKILL.md index fa40fce..5cc5fc4 100644 --- a/skill/xianyu-hunter-monitor/SKILL.md +++ b/skill/xianyu-hunter-monitor/SKILL.md @@ -348,10 +348,16 @@ Home dashboard clickable overview/workflow cards and deep-link behavior are capt Portable skill/project packaging, safe archive exclusions, install script shape, and self-hosted Gitea publishing information requirements are captured in `references/portable-package-and-gitea-publishing-2026-05.md`. +Gitea portable package sync workflow for updating the published package from the local messy working tree is captured in `references/gitea-portable-package-sync-2026-05.md`. + Public Gitea publish verification, token-scrubbed remote handling, and final reporting checklist are captured in `references/gitea-public-publish-verification-2026-05.md`. Return navigation after deep-linking from the home dashboard is captured in `references/web-home-return-navigation-2026-05.md`. +Item link normalization for mobile-clickable reports/notifications is captured in `references/item-link-normalization-2026-05.md`. + +Per-item notifications and Feishu Markdown clickable-link formatting are captured in `references/per-item-notifications-feishu-links-2026-05.md`. + Portable export packaging for sharing this skill/project with other Hermes instances is captured in `references/portable-skill-package-2026-05.md`. diff --git a/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md b/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md new file mode 100644 index 0000000..76b48e1 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md @@ -0,0 +1,133 @@ +# Sync local xianyu-hunter changes into the Gitea portable package (2026-05) + +## Trigger + +BOSS says “同步更新 gitea” after local xianyu-hunter project changes. In this setup the local working clone may still have upstream `origin=https://github.com/Disrush/xianyu-hunter.git`, while the public Gitea repo is the portable package: + +```text +https://gitea.chickliu.fun/Hermes/xianyu-hunter-hermes-skill +``` + +Do **not** push the messy local working clone directly to GitHub/upstream or to a new guessed Gitea repo. Update the existing portable package repo by cloning it to a temp workdir and rsyncing sanitized contents into `project/xianyu-hunter/` plus the skill into `skill/xianyu-hunter-monitor/`. + +## Proven workflow + +```bash +cd /Users/chick/.Hermes/workspace/research/xianyu-hunter + +set -a +source ~/.hermes/env/gitea.env +set +a +OWNER="${GITEA_OWNER:-Hermes}" +REPO=xianyu-hunter-hermes-skill + +rm -rf /tmp/xianyu-hunter-gitea-sync +git clone "$GITEA_URL/$OWNER/$REPO.git" /tmp/xianyu-hunter-gitea-sync + +SRC=/Users/chick/.Hermes/workspace/research/xianyu-hunter +DST=/tmp/xianyu-hunter-gitea-sync/project/xianyu-hunter +SKILL_SRC=/Users/chick/.hermes/skills/social-media/xianyu-hunter-monitor +SKILL_DST=/tmp/xianyu-hunter-gitea-sync/skill/xianyu-hunter-monitor + +rsync -a --delete \ + --exclude='.git/' \ + --exclude='node_modules/' \ + --exclude='browser-data/' \ + --exclude='.runtime/' \ + --exclude='xianyu-server.log' \ + --exclude='*.log' \ + --exclude='data/config.json' \ + --exclude='data/tasks.json' \ + --exclude='data/notify-config.json' \ + --exclude='reports/*.md' \ + "$SRC/" "$DST/" + +mkdir -p "$DST/reports" +touch "$DST/reports/.gitkeep" +rsync -a --delete "$SKILL_SRC/" "$SKILL_DST/" +``` + +Preserve/restore package examples that should remain committed even if absent locally, e.g. `project/xianyu-hunter/data/notify-config.example.json`: + +```bash +cat > /tmp/xianyu-hunter-gitea-sync/project/xianyu-hunter/data/notify-config.example.json <<'EOF' +{ + "enabled": false, + "notifyOnlyGood": true, + "minScore": 75, + "channels": [] +} +EOF +``` + +Refresh the package `.gitignore` so runtime data cannot be committed: + +```bash +cat > /tmp/xianyu-hunter-gitea-sync/.gitignore <<'EOF' +.DS_Store +node_modules/ +browser-data/ +.runtime/ +*.log +project/xianyu-hunter/node_modules/ +project/xianyu-hunter/browser-data/ +project/xianyu-hunter/.runtime/ +project/xianyu-hunter/data/config.json +project/xianyu-hunter/data/tasks.json +project/xianyu-hunter/data/notify-config.json +project/xianyu-hunter/reports/*.md +EOF +``` + +## Verification before commit + +```bash +cd /tmp/xianyu-hunter-gitea-sync +node --check project/xianyu-hunter/server.mjs +node --check project/xianyu-hunter/lib/login.mjs +node --check project/xianyu-hunter/lib/hermes_optimizer.mjs +node --check project/xianyu-hunter/public/app.js + +git status --short --ignored +git ls-files | grep -E 'browser-data|data/(config|tasks|notify-config)\.json|node_modules|\.runtime|reports/.*\.md' || true +``` + +Expected sensitive-file grep output is empty. Review `git diff --stat` and representative diffs before committing. + +## Commit and push safely + +```bash +cd /tmp/xianyu-hunter-gitea-sync +git config user.name 'Hermes Agent' +git config user.email 'Hermes@Hermes.Hermes' +git add . +git commit -m 'feat: update xianyu web login and Hermes optimizer' + +git remote set-url origin "https://${GITEA_USERNAME:-Hermes}:${GITEA_TOKEN}@${GITEA_URL#https://}/$OWNER/$REPO.git" +git push origin main +# scrub token immediately +git remote set-url origin "$GITEA_URL/$OWNER/$REPO.git" +git remote -v +``` + +## Post-push verification + +```bash +curl -sS -H "Authorization: token $GITEA_TOKEN" \ + -o /tmp/gitea_repo_verify.json \ + -w 'HTTP %{http_code} size=%{size_download}\n' \ + "$GITEA_URL/api/v1/repos/$OWNER/$REPO" + +git ls-remote "$GITEA_URL/$OWNER/$REPO.git" HEAD refs/heads/main + +for f in \ + project/xianyu-hunter/lib/hermes_optimizer.mjs \ + project/xianyu-hunter/lib/login.mjs \ + skill/xianyu-hunter-monitor/references/web-qr-source-and-hermes-optimize-2026-05.md; do + curl -sS -L -o /tmp/gitea_raw_check \ + -w "$f HTTP %{http_code} size=%{size_download}\n" \ + "$GITEA_URL/$OWNER/$REPO/raw/branch/main/$f" +done +``` + +Report the repo URL, commit hash, and verification results back to BOSS in the final visible reply. Do not leave an empty response after tool calls. diff --git a/skill/xianyu-hunter-monitor/references/item-link-normalization-2026-05.md b/skill/xianyu-hunter-monitor/references/item-link-normalization-2026-05.md new file mode 100644 index 0000000..0c5e746 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/item-link-normalization-2026-05.md @@ -0,0 +1,62 @@ +# Xianyu Item Link Normalization — 2026-05 + +## Context + +BOSS reported that generated Xianyu/Goofish candidate links were not reliably clickable from Feishu/mobile messages. Existing data often stored desktop links like: + +```text +https://www.goofish.com/item?id=&categoryId= +``` + +These can return HTTP 200 in curl but may not deep-link well from messaging clients. Mobile item links are more reliable for user-facing reports/notifications: + +```text +https://h5.m.goofish.com/item?id= +``` + +## Implemented pattern + +Add/use `lib/links.mjs`: + +- `normalizeItemId(value)` extracts item IDs from `id`, `itemId`, full item URLs, IM URLs, and raw text. +- `buildItemLinks(itemOrId)` returns desktop and mobile URLs. +- `enrichItemLinks(item)` preserves original URL in `originalHref`, stores desktop URL in `webHref`, mobile URL in `mobileHref`, and sets user-facing `href` to the mobile URL. + +Integration points: + +- `lib/search.mjs`: after scraping desktop card href/category, call `enrichItemLinks(item)` before returning results. +- `lib/filter.mjs`: after merging detail fields, call `enrichItemLinks({ ...product, ...detail })` so fine-filtered candidates keep clickable mobile links. +- `lib/task.mjs`: normalize products during load/serialize/persist so existing `data/tasks.json` records become compatible without manual migration. +- `lib/analyzer.mjs`, `lib/notifier.mjs`, `tools/hermes_report.mjs`: use `buildItemLinks(item).primaryUrl` when rendering reports and notifications. + +## Verification commands + +```bash +cd /Users/chick/.Hermes/workspace/research/xianyu-hunter +node --check lib/links.mjs && node --check lib/search.mjs && node --check lib/filter.mjs && node --check lib/task.mjs && node --check lib/analyzer.mjs && node --check lib/notifier.mjs && node --check tools/hermes_report.mjs + +node --input-type=module - <<'NODE' +import { buildItemLinks, enrichItemLinks } from './lib/links.mjs'; +console.log(buildItemLinks({ id: '1017096975429', href: 'https://www.goofish.com/item?id=1017096975429&categoryId=126856267' })); +console.log(enrichItemLinks({ href: '/item?id=1039232668094&categoryId=126856522' })); +NODE + +node tools/hermes_report.mjs --task --limit 3 --out /tmp/xianyu-link-test.md +sed -n '1,80p' /tmp/xianyu-link-test.md +``` + +Expected user-facing links in reports/notifications: + +```text +https://h5.m.goofish.com/item?id= +``` + +## Pitfall + +Do not replace `chatUrl` with mobile item links. Seller chat still needs desktop IM URLs such as: + +```text +https://www.goofish.com/im?itemId=&peerUserId= +``` + +Only user-facing product links should prefer h5 mobile URLs. diff --git a/skill/xianyu-hunter-monitor/references/per-item-notifications-feishu-links-2026-05.md b/skill/xianyu-hunter-monitor/references/per-item-notifications-feishu-links-2026-05.md new file mode 100644 index 0000000..553a18d --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/per-item-notifications-feishu-links-2026-05.md @@ -0,0 +1,85 @@ +# Xianyu Per-Item Notifications + Feishu Clickable Links — 2026-05 + +## Context + +BOSS reported two UX issues after initial link normalization: + +1. Feishu still did not show reliable clickable product links. +2. Notifications should behave like the original project: each product should be pushed as its own message, not bundled into one long report-style message. + +A key pitfall was that the running `server.mjs` process still used the old notifier module until the service was restarted. Always restart the Node service after patching notification rendering. + +## Implemented pattern + +`lib/notifier.mjs` now defaults to: + +```json +{ + "perItem": true, + "maxNotifyItems": 10 +} +``` + +Notification rendering now: + +- Selects candidates by `verdict === 'recommend' || score >= minScore`. +- Sends up to `maxNotifyItems` items. +- Sends one message per item when `perItem !== false`. +- Uses explicit Feishu-compatible Markdown link syntax: + +```md +[🔗 打开闲鱼商品](https://h5.m.goofish.com/item?id=) +备用链接:https://h5.m.goofish.com/item?id= +``` + +The backup plain h5 URL is kept because Feishu/clients sometimes vary in Markdown rendering. + +## Verification + +After patching, run: + +```bash +cd /Users/chick/.Hermes/workspace/research/xianyu-hunter +node --check lib/notifier.mjs && node --check server.mjs +``` + +Dry-run without sending: + +```bash +node --input-type=module - <<'NODE' +import fs from 'fs'; +import { buildAnalysis } from './lib/analyzer.mjs'; +import { sendNotifications } from './lib/notifier.mjs'; +const data = JSON.parse(fs.readFileSync('data/tasks.json','utf8')); +const task = data.at(-1); +const analysis = buildAnalysis(task, { limit: 3 }); +const res = await sendNotifications(task, { ...analysis.summary, maxNotifyItems: 3 }, { + force: true, + config: { enabled: true, notifyOnlyGood: false, minScore: 0, perItem: true, maxNotifyItems: 3, channels: [] } +}); +console.log(res.messages.join('\n\n---\n\n')); +NODE +``` + +Restart exact Node server process, then test through API: + +```bash +# kill old node server.mjs PID, then start tracked/background process +PORT=3000 node server.mjs + +curl -sS -X POST http://127.0.0.1:3000/api/notify-test \ + -H 'Content-Type: application/json' \ + -d '{"title":"xianyu-hunter 单商品链接测试"}' | python3 -m json.tool +``` + +Expected API response includes: + +- `perItem: true` +- `messages: [ ... ]` +- message text contains `[🔗 打开闲鱼商品](https://h5.m.goofish.com/item?id=...)` +- `results[].itemIndex` for each individual pushed item + +## Notes + +- `cfg.perItem === false` can still be used to send a bundled message for webhook channels if needed. +- `chatUrl` should remain a desktop IM URL; only user-facing product links use h5 mobile URLs.