fix: make xianyu notifications per-item with clickable links
This commit is contained in:
@@ -2,5 +2,7 @@
|
||||
"enabled": false,
|
||||
"notifyOnlyGood": true,
|
||||
"minScore": 75,
|
||||
"perItem": true,
|
||||
"maxNotifyItems": 10,
|
||||
"channels": []
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}` : ''}`);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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=<id>&categoryId=<category>
|
||||
```
|
||||
|
||||
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=<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 <task_id> --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=<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=<id>&peerUserId=<sellerId>
|
||||
```
|
||||
|
||||
Only user-facing product links should prefer h5 mobile URLs.
|
||||
@@ -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=<id>)
|
||||
备用链接:https://h5.m.goofish.com/item?id=<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.
|
||||
Reference in New Issue
Block a user