feat: update xianyu web login and Hermes optimizer
This commit is contained in:
+9
-6
@@ -1,9 +1,12 @@
|
|||||||
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
browser-data/
|
browser-data/
|
||||||
data/tasks.json
|
|
||||||
data/config.json
|
|
||||||
data/notify-config.json
|
|
||||||
reports/
|
|
||||||
*.log
|
|
||||||
.runtime/
|
.runtime/
|
||||||
.DS_Store
|
*.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
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
"notifyOnlyGood": true,
|
"notifyOnlyGood": true,
|
||||||
"minScore": 75,
|
"minScore": 75,
|
||||||
"channels": []
|
"channels": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT } from './ai.mjs';
|
||||||
|
|
||||||
|
const HERMES_BIN = process.env.HERMES_BIN || '/Users/chick/.local/bin/hermes';
|
||||||
|
|
||||||
|
function extractFirstJsonObject(text = '') {
|
||||||
|
const raw = String(text || '').trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
|
||||||
|
const direct = tryParse(raw);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const start = raw.indexOf('{');
|
||||||
|
if (start < 0) throw new Error('Hermes 返回中没有 JSON 对象');
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = start; i < raw.length; i++) {
|
||||||
|
const ch = raw[i];
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (ch === '\\') { escape = true; continue; }
|
||||||
|
if (ch === '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
const parsed = tryParse(raw.slice(start, i + 1));
|
||||||
|
if (parsed) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Hermes 返回的 JSON 无法解析');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParse(text) {
|
||||||
|
try { return JSON.parse(text); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalOptimizedTask({ seed = '', product = '', budget = '', region = '', currentRequirements = '' }) {
|
||||||
|
const text = `${product || seed}`.trim();
|
||||||
|
const queries = [...new Set(text.split(/[,,、]/).map(s => s.trim()).filter(Boolean))];
|
||||||
|
if (queries.length === 0 && text) queries.push(text);
|
||||||
|
const budgetNum = Number(String(budget).match(/\d+(?:\.\d+)?/)?.[0] || '') || null;
|
||||||
|
return {
|
||||||
|
name: text ? `${text} 监测` : '闲鱼商品监测',
|
||||||
|
queries: queries.slice(0, 6),
|
||||||
|
priceMin: null,
|
||||||
|
priceMax: budgetNum,
|
||||||
|
region,
|
||||||
|
maxPages: 2,
|
||||||
|
maxItems: 40,
|
||||||
|
personalSeller: true,
|
||||||
|
autoAnalyze: true,
|
||||||
|
autoNotify: true,
|
||||||
|
customRequirements: `${currentRequirements || `目标:${text}${budgetNum ? `,预算${budgetNum}左右` : ''}`};只做采集分析,不自动联系卖家;排除配件/维修/求购/租赁/回收/商家批量/标题党;优先个人自用、描述清楚、价格合理、可核验。`,
|
||||||
|
coarsePrompt: DEFAULT_COARSE_PROMPT,
|
||||||
|
finePrompt: DEFAULT_FINE_PROMPT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTask(candidate, fallback) {
|
||||||
|
const merged = { ...fallback, ...(candidate || {}) };
|
||||||
|
const queries = Array.isArray(merged.queries)
|
||||||
|
? merged.queries.map(q => String(q || '').trim()).filter(Boolean)
|
||||||
|
: fallback.queries;
|
||||||
|
return {
|
||||||
|
name: String(merged.name || fallback.name || '闲鱼商品监测').slice(0, 80),
|
||||||
|
queries: queries.length ? [...new Set(queries)].slice(0, 6) : fallback.queries,
|
||||||
|
priceMin: merged.priceMin == null || merged.priceMin === '' ? null : Number(merged.priceMin),
|
||||||
|
priceMax: merged.priceMax == null || merged.priceMax === '' ? fallback.priceMax : Number(merged.priceMax),
|
||||||
|
region: String(merged.region || fallback.region || '').slice(0, 30),
|
||||||
|
maxPages: Math.max(1, Math.min(20, Number(merged.maxPages || fallback.maxPages || 2))),
|
||||||
|
maxItems: Math.max(1, Math.min(500, Number(merged.maxItems || fallback.maxItems || 40))),
|
||||||
|
personalSeller: merged.personalSeller !== false,
|
||||||
|
autoAnalyze: merged.autoAnalyze !== false,
|
||||||
|
autoNotify: merged.autoNotify !== false,
|
||||||
|
customRequirements: String(merged.customRequirements || fallback.customRequirements || '').slice(0, 1200),
|
||||||
|
coarsePrompt: String(merged.coarsePrompt || fallback.coarsePrompt || DEFAULT_COARSE_PROMPT).slice(0, 2000),
|
||||||
|
finePrompt: String(merged.finePrompt || fallback.finePrompt || DEFAULT_FINE_PROMPT).slice(0, 2000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function optimizeTaskWithHermes(input = {}) {
|
||||||
|
const fallback = buildLocalOptimizedTask(input);
|
||||||
|
const prompt = `你是 Hermes Agent,正在为 BOSS 的闲鱼监控项目润色任务配置。请直接返回一个 JSON 对象,不要 Markdown,不要解释。
|
||||||
|
|
||||||
|
输出字段必须是:
|
||||||
|
{"name":string,"queries":string[],"priceMin":number|null,"priceMax":number|null,"region":string,"maxPages":number,"maxItems":number,"personalSeller":boolean,"autoAnalyze":boolean,"autoNotify":boolean,"customRequirements":string,"coarsePrompt":string,"finePrompt":string}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- queries 生成 3-6 个适合闲鱼/Goofish 搜索的关键词,包含别名、空格/无空格、大小写/中文变体。
|
||||||
|
- customRequirements 要写成可执行筛选规则,明确排除配件、维修、坏件、求购、租赁、回收、商家批量、异常低价、标题党。
|
||||||
|
- 保持安全:只采集和分析,不自动联系卖家、不下单、不承诺交易。
|
||||||
|
- maxPages 建议 1-3,maxItems 建议 20-80。
|
||||||
|
- autoAnalyze 和 autoNotify 默认 true。
|
||||||
|
- 如果预算存在,priceMax 使用预算数字;priceMin 通常为 null。
|
||||||
|
|
||||||
|
用户输入:${JSON.stringify(input, null, 2)}
|
||||||
|
本地兜底参考:${JSON.stringify(fallback, null, 2)}`;
|
||||||
|
|
||||||
|
const stdout = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(HERMES_BIN, ['-z', prompt, '--ignore-rules', '-t', 'terminal'], {
|
||||||
|
cwd: process.env.XIANHU_ROOT || process.cwd(),
|
||||||
|
env: { ...process.env, HERMES_ACCEPT_HOOKS: '1' },
|
||||||
|
});
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error('Hermes 润色超时'));
|
||||||
|
}, Number(process.env.HERMES_OPTIMIZE_TIMEOUT_MS || 90000));
|
||||||
|
child.stdout.on('data', d => { out += d.toString(); });
|
||||||
|
child.stderr.on('data', d => { err += d.toString(); });
|
||||||
|
child.on('error', e => { clearTimeout(timer); reject(e); });
|
||||||
|
child.on('close', code => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0) return reject(new Error((err || out || `Hermes 退出码 ${code}`).slice(0, 800)));
|
||||||
|
resolve(out);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = extractFirstJsonObject(stdout);
|
||||||
|
return { source: 'hermes', task: sanitizeTask(parsed, fallback), raw: stdout.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildLocalOptimizedTask };
|
||||||
@@ -127,18 +127,56 @@ export async function openLoginPage(emitLog = () => {}) {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGoofishOrLoginPage(page) {
|
||||||
|
const url = page.url();
|
||||||
|
return /goofish\.com|login\.taobao\.com|havanaone/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickIfVisible(page, selector, timeout = 1000) {
|
||||||
|
const loc = page.locator(selector).first();
|
||||||
|
if (await loc.isVisible({ timeout }).catch(() => false)) {
|
||||||
|
await loc.click({ timeout: 3000 }).catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findQrLikeLocator(page) {
|
||||||
|
const selectors = [
|
||||||
|
'canvas',
|
||||||
|
'img[src^="data:image"]',
|
||||||
|
'img[src*="qr"]',
|
||||||
|
'img[src*="qrcode"]',
|
||||||
|
'[class*="qrcode"] canvas',
|
||||||
|
'[class*="qrcode"] img',
|
||||||
|
'[class*="qr"] canvas',
|
||||||
|
'[class*="qr"] img',
|
||||||
|
'[class*="login"] canvas',
|
||||||
|
'[class*="login"] img',
|
||||||
|
];
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const loc = page.locator(selector).first();
|
||||||
|
if (await loc.isVisible({ timeout: 1200 }).catch(() => false)) return loc;
|
||||||
|
}
|
||||||
|
for (const frame of page.frames()) {
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const loc = frame.locator(selector).first();
|
||||||
|
if (await loc.isVisible({ timeout: 800 }).catch(() => false)) return loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLoginQr(emitLog = () => {}) {
|
export async function getLoginQr(emitLog = () => {}) {
|
||||||
const ctx = await getBrowserContext();
|
const ctx = await getBrowserContext();
|
||||||
const page = ctx.pages().find(p => /goofish\.com|login\.taobao\.com|havanaone/.test(p.url())) || ctx.pages()[0] || await ctx.newPage();
|
const page = ctx.pages().find(isGoofishOrLoginPage) || ctx.pages()[0] || await ctx.newPage();
|
||||||
emitLog('正在打开闲鱼 Web 登录入口并生成后台二维码截图...');
|
emitLog('正在从闲鱼官网入口打开登录页;如跳转到淘宝/支付宝统一登录,会在日志中标明来源。');
|
||||||
await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
await sleep(2500);
|
await sleep(2500);
|
||||||
|
|
||||||
let clicked = false;
|
let clicked = false;
|
||||||
for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) {
|
for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) {
|
||||||
const loc = page.locator(selector).first();
|
if (await clickIfVisible(page, selector)) {
|
||||||
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
await loc.click({ timeout: 3000 }).catch(() => {});
|
|
||||||
clicked = true;
|
clicked = true;
|
||||||
await sleep(2500);
|
await sleep(2500);
|
||||||
break;
|
break;
|
||||||
@@ -146,26 +184,29 @@ export async function getLoginQr(emitLog = () => {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) {
|
for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) {
|
||||||
const loc = page.locator(selector).first();
|
if (await clickIfVisible(page, selector)) {
|
||||||
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
await loc.click({ timeout: 2500 }).catch(() => {});
|
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
const redirectedProvider = /login\.taobao\.com|havanaone/.test(currentUrl) ? '淘宝/阿里统一登录' : '闲鱼 Web 登录入口';
|
||||||
const status = await getLoginStatus(page);
|
const status = await getLoginStatus(page);
|
||||||
let qrImage = '';
|
let qrImage = '';
|
||||||
|
let screenshotKind = '';
|
||||||
if (!status.loggedIn) {
|
if (!status.loggedIn) {
|
||||||
|
const qrLike = await findQrLikeLocator(page);
|
||||||
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
|
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
|
||||||
const target = await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body');
|
const target = qrLike || (await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body'));
|
||||||
|
screenshotKind = qrLike ? '二维码区域' : '登录面板截图';
|
||||||
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
|
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
|
||||||
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
|
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
|
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
|
||||||
else emitLog(`已生成登录截图${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请在后台页面扫码后点“检测登录状态”`);
|
else emitLog(`已从${redirectedProvider}生成${screenshotKind || '登录截图'}${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请扫码后点“刷新账号状态”。当前 URL:${currentUrl}`);
|
||||||
return { ...status, qrImage, qrCheckedAt: new Date().toISOString() };
|
return { ...status, qrImage, qrCheckedAt: new Date().toISOString(), qrSource: redirectedProvider, qrUrl: currentUrl, qrScreenshotKind: screenshotKind };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLoginStatus(existingPage = null) {
|
export async function getLoginStatus(existingPage = null) {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { genId, timestamp } from './utils.mjs';
|
|||||||
import { checkLogin } from './login.mjs';
|
import { checkLogin } from './login.mjs';
|
||||||
import { searchProducts } from './search.mjs';
|
import { searchProducts } from './search.mjs';
|
||||||
import { coarseFilter, fineFilter } from './filter.mjs';
|
import { coarseFilter, fineFilter } from './filter.mjs';
|
||||||
import { buildAnalysis } from './analyzer.mjs';
|
import { buildAnalysis, renderAnalysisMarkdown } from './analyzer.mjs';
|
||||||
|
import { sendNotifications } from './notifier.mjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const DATA_DIR = path.join(__dirname, '..', 'data');
|
const DATA_DIR = path.join(__dirname, '..', 'data');
|
||||||
@@ -36,6 +37,8 @@ export class TaskManager {
|
|||||||
finePrompt: config.finePrompt || '',
|
finePrompt: config.finePrompt || '',
|
||||||
maxPages: Math.max(1, Math.min(20, Number(config.maxPages || 3))),
|
maxPages: Math.max(1, Math.min(20, Number(config.maxPages || 3))),
|
||||||
maxItems: Math.max(1, Math.min(500, Number(config.maxItems || 60))),
|
maxItems: Math.max(1, Math.min(500, Number(config.maxItems || 60))),
|
||||||
|
autoAnalyze: config.autoAnalyze !== false,
|
||||||
|
autoNotify: config.autoNotify !== false,
|
||||||
schedule: {
|
schedule: {
|
||||||
enabled: !!scheduleEnabled,
|
enabled: !!scheduleEnabled,
|
||||||
intervalMinutes: Math.max(0, Number(scheduleIntervalMinutes || 0) || 0),
|
intervalMinutes: Math.max(0, Number(scheduleIntervalMinutes || 0) || 0),
|
||||||
@@ -123,7 +126,7 @@ export class TaskManager {
|
|||||||
|
|
||||||
async startTask(id, configUpdates = {}) {
|
async startTask(id, configUpdates = {}) {
|
||||||
const task = this.tasks.get(id);
|
const task = this.tasks.get(id);
|
||||||
if (!task || task.running) return;
|
if (!task || task.running) return null;
|
||||||
|
|
||||||
if (Object.keys(configUpdates || {}).length) {
|
if (Object.keys(configUpdates || {}).length) {
|
||||||
const { reset, scheduled, ...patch } = configUpdates;
|
const { reset, scheduled, ...patch } = configUpdates;
|
||||||
@@ -151,6 +154,7 @@ export class TaskManager {
|
|||||||
try {
|
try {
|
||||||
await this._runPipeline(task, emitLog, shouldStop);
|
await this._runPipeline(task, emitLog, shouldStop);
|
||||||
if (!task.stopped) {
|
if (!task.stopped) {
|
||||||
|
await this._runAutoAnalysis(task, emitLog);
|
||||||
task.stage = 'completed';
|
task.stage = 'completed';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -268,6 +272,52 @@ export class TaskManager {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _runAutoAnalysis(task, emitLog) {
|
||||||
|
if (task.config?.autoAnalyze === false) {
|
||||||
|
emitLog('🦐 已关闭自动 Hermes 分析:可在详情页手动执行分析');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!task.products?.fineFiltered?.length) {
|
||||||
|
emitLog('🦐 没有细筛候选,跳过自动 Hermes 分析');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitLog(`🦐 自动执行 Hermes 分析:${task.products.fineFiltered.length} 个候选`);
|
||||||
|
const analysis = this.analyzeTask(task.id, { limit: Number(task.config.maxItems || 50) });
|
||||||
|
if (!analysis?.summary) {
|
||||||
|
emitLog('⚠️ Hermes 分析未生成结果');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = renderAnalysisMarkdown(this.getTaskFull(task.id), analysis.summary);
|
||||||
|
analysis.summary.reportPath = report.path;
|
||||||
|
this.setTaskAnalysis(task.id, analysis);
|
||||||
|
emitLog(`📝 Hermes 分析报告已生成:${report.path}`);
|
||||||
|
} catch (err) {
|
||||||
|
emitLog(`⚠️ Hermes 报告生成失败:${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.config?.autoNotify !== false) {
|
||||||
|
try {
|
||||||
|
const notification = await sendNotifications(this.getTaskFull(task.id), analysis.summary);
|
||||||
|
if (notification?.skipped) {
|
||||||
|
emitLog(`🔕 自动通知跳过:${notification.reason || '未达到通知条件'}`);
|
||||||
|
} else if (notification?.ok) {
|
||||||
|
emitLog('📣 自动通知已发送');
|
||||||
|
} else {
|
||||||
|
const errors = (notification?.results || []).filter(r => !r.ok).map(r => `${r.name || r.id}: ${r.error}`).join(';');
|
||||||
|
emitLog(`⚠️ 自动通知部分失败:${errors || '未知错误'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
emitLog(`⚠️ 自动通知失败:${err.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emitLog('🔕 已关闭自动通知');
|
||||||
|
}
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
getAllTasks() {
|
getAllTasks() {
|
||||||
return [...this.tasks.values()].map(t => this._serialize(t));
|
return [...this.tasks.values()].map(t => this._serialize(t));
|
||||||
}
|
}
|
||||||
@@ -370,7 +420,7 @@ export class TaskManager {
|
|||||||
task.chatSessionsFull = [];
|
task.chatSessionsFull = [];
|
||||||
this._emit(task.id, 'task:chats', task.chatSessions);
|
this._emit(task.id, 'task:chats', task.chatSessions);
|
||||||
this._persistToDisk();
|
this._persistToDisk();
|
||||||
emitLog(`🦐 已关闭联系卖家功能:保留 ${task.products.fineFiltered.length} 个候选商品,后续由 Hermes 读取 data/tasks.json 做深度分析报告`);
|
emitLog(`🦐 采集和本地预筛完成:保留 ${task.products.fineFiltered.length} 个候选,准备自动执行 Hermes 分析`);
|
||||||
emitLog('========== 采集流程结束 ==========');
|
emitLog('========== 采集流程结束 ==========');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +597,7 @@ export class TaskManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_startScheduler() {
|
_startScheduler() {
|
||||||
|
if (process.env.XIANHU_DISABLE_SCHEDULER === '1') return;
|
||||||
this.schedulerTimer = setInterval(() => this._tickSchedules(), 30000);
|
this.schedulerTimer = setInterval(() => this._tickSchedules(), 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,16 @@ function renderLoginStatus(data) {
|
|||||||
function renderLoginQr(data) {
|
function renderLoginQr(data) {
|
||||||
const panel = document.getElementById('login-qr-panel');
|
const panel = document.getElementById('login-qr-panel');
|
||||||
const img = document.getElementById('login-qr-img');
|
const img = document.getElementById('login-qr-img');
|
||||||
|
const title = document.getElementById('login-qr-title');
|
||||||
|
const sub = document.getElementById('login-qr-sub');
|
||||||
|
if (title) title.textContent = data.qrScreenshotKind || '扫码登录';
|
||||||
|
if (sub) {
|
||||||
|
const source = data.qrSource || '闲鱼 Web 登录入口';
|
||||||
|
const redirected = data.qrUrl && /login\.taobao\.com|havanaone/.test(data.qrUrl)
|
||||||
|
? '(已从闲鱼入口跳转到淘宝/阿里统一登录,这是官方登录链路)'
|
||||||
|
: '';
|
||||||
|
sub.textContent = `来源:${source}${redirected}。二维码过期就重新获取。`;
|
||||||
|
}
|
||||||
if (data.qrImage) {
|
if (data.qrImage) {
|
||||||
img.src = data.qrImage;
|
img.src = data.qrImage;
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
@@ -358,6 +368,8 @@ function collectTaskForm() {
|
|||||||
maxItems: maxItems ? Number(maxItems) : 60,
|
maxItems: maxItems ? Number(maxItems) : 60,
|
||||||
region,
|
region,
|
||||||
personalSeller,
|
personalSeller,
|
||||||
|
autoAnalyze: document.getElementById('f-auto-analyze')?.checked !== false,
|
||||||
|
autoNotify: document.getElementById('f-auto-notify')?.checked !== false,
|
||||||
customRequirements,
|
customRequirements,
|
||||||
coarsePrompt: coarsePrompt || '',
|
coarsePrompt: coarsePrompt || '',
|
||||||
finePrompt: finePrompt || '',
|
finePrompt: finePrompt || '',
|
||||||
@@ -403,7 +415,7 @@ async function optimizeTaskForm() {
|
|||||||
currentRequirements: document.getElementById('f-requirements').value.trim(),
|
currentRequirements: document.getElementById('f-requirements').value.trim(),
|
||||||
});
|
});
|
||||||
fillTaskForm(result.task || {});
|
fillTaskForm(result.task || {});
|
||||||
status.textContent = result.source === 'ai' ? '已由 AI 优化' : 'AI不可用,已用本地规则优化';
|
status.textContent = result.source === 'hermes' ? '已由 Hermes Agent 优化' : '已用本地规则优化(Hermes 不可用时兜底)';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = `优化失败:${err.message}`;
|
status.textContent = `优化失败:${err.message}`;
|
||||||
}
|
}
|
||||||
@@ -419,6 +431,8 @@ function fillTaskForm(taskOrConfig, name = '') {
|
|||||||
document.getElementById('f-max-items').value = c.maxItems || 60;
|
document.getElementById('f-max-items').value = c.maxItems || 60;
|
||||||
document.getElementById('f-region').value = c.region || '';
|
document.getElementById('f-region').value = c.region || '';
|
||||||
document.getElementById('f-personal').checked = !!c.personalSeller;
|
document.getElementById('f-personal').checked = !!c.personalSeller;
|
||||||
|
const autoAnalyze = document.getElementById('f-auto-analyze'); if (autoAnalyze) autoAnalyze.checked = c.autoAnalyze !== false;
|
||||||
|
const autoNotify = document.getElementById('f-auto-notify'); if (autoNotify) autoNotify.checked = c.autoNotify !== false;
|
||||||
document.getElementById('f-requirements').value = c.customRequirements || '';
|
document.getElementById('f-requirements').value = c.customRequirements || '';
|
||||||
document.getElementById('f-coarse-prompt').value = c.coarsePrompt || defaultCoarsePrompt;
|
document.getElementById('f-coarse-prompt').value = c.coarsePrompt || defaultCoarsePrompt;
|
||||||
document.getElementById('f-fine-prompt').value = c.finePrompt || defaultFinePrompt;
|
document.getElementById('f-fine-prompt').value = c.finePrompt || defaultFinePrompt;
|
||||||
|
|||||||
@@ -226,8 +226,8 @@
|
|||||||
<div class="login-qr-panel" id="login-qr-panel" style="display:none">
|
<div class="login-qr-panel" id="login-qr-panel" style="display:none">
|
||||||
<div class="login-qr-header">
|
<div class="login-qr-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="login-qr-title">扫码登录</div>
|
<div class="login-qr-title" id="login-qr-title">扫码登录</div>
|
||||||
<div class="login-qr-sub">点击“获取登录二维码”后,在这里直接扫码;二维码过期就重新获取。</div>
|
<div class="login-qr-sub" id="login-qr-sub">点击“获取登录二维码”后,会先从闲鱼官网入口打开登录链路;如跳转到淘宝/阿里统一登录,会在这里标明来源。</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm" onclick="clearLoginQr()">清空</button>
|
<button class="btn btn-sm" onclick="clearLoginQr()">清空</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,10 +240,11 @@
|
|||||||
<button class="btn" onclick="checkLoginStatus()">刷新账号状态</button>
|
<button class="btn" onclick="checkLoginStatus()">刷新账号状态</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-help">
|
<div class="login-help">
|
||||||
<p>1. 点击“获取/刷新登录二维码”后,后台会从闲鱼 Web 登录入口生成二维码/登录面板截图,可直接在本页面扫码。</p>
|
<p>1. 点击“获取/刷新登录二维码”后,后台会先进入闲鱼官网,再沿官方登录链路生成二维码/登录面板截图。</p>
|
||||||
<p>2. 扫码完成后点击“刷新账号状态”,本页会展示当前账号昵称、头像、Cookie 数和登录墙状态。</p>
|
<p>2. 闲鱼当前可能跳转到淘宝/阿里统一登录;页面会明确显示来源,不再把淘宝登录截图伪装成“闲鱼二维码”。</p>
|
||||||
<p>3. Cookie 会自动保存在项目的 <code>browser-data/</code> 目录,后续采集任务复用这个登录态。</p>
|
<p>3. 扫码完成后点击“刷新账号状态”,本页会展示当前账号昵称、头像、Cookie 数和登录墙状态。</p>
|
||||||
<p>4. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。</p>
|
<p>4. Cookie 会自动保存在项目的 <code>browser-data/</code> 目录,后续采集任务复用这个登录态。</p>
|
||||||
|
<p>5. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。</p>
|
||||||
</div>
|
</div>
|
||||||
<pre class="login-log" id="login-log"></pre>
|
<pre class="login-log" id="login-log"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,12 +299,21 @@
|
|||||||
<textarea id="f-requirements" rows="3" placeholder="例如:只要成色9新以上的,电池健康90%以上,价格4500以内优先,最好有原装配件和发票。不要华强北翻新机。"></textarea>
|
<textarea id="f-requirements" rows="3" placeholder="例如:只要成色9新以上的,电池健康90%以上,价格4500以内优先,最好有原装配件和发票。不要华强北翻新机。"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group task-optimize-card">
|
<div class="form-group task-optimize-card">
|
||||||
<label>AI 优化生成 <span class="hint">(根据当前商品/预算/需求生成关键词和筛选条件)</span></label>
|
<label>任务智能润色 <span class="hint">(根据当前商品/预算/需求生成关键词和筛选条件;优先调用 Hermes Agent,失败自动降级本地规则)</span></label>
|
||||||
<div class="persona-actions">
|
<div class="persona-actions">
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="optimizeTaskForm()">AI 优化当前表单</button>
|
<button type="button" class="btn btn-sm btn-primary" onclick="optimizeTaskForm()">润色当前表单</button>
|
||||||
<span class="hint" id="optimize-status"></span>
|
<span class="hint" id="optimize-status"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label><input id="f-auto-analyze" type="checkbox" checked> 采集完成后自动执行 Hermes 分析</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label><input id="f-auto-notify" type="checkbox" checked> 分析后按通知设置自动推送</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">默认全自动:保存并采集 → 本地预筛 → Hermes 分析 → 命中通知;无需再手动点“执行分析”。</div>
|
||||||
<details class="advanced-section" open>
|
<details class="advanced-section" open>
|
||||||
<summary class="advanced-toggle">定时任务</summary>
|
<summary class="advanced-toggle">定时任务</summary>
|
||||||
<div class="form-row" style="margin-top:12px">
|
<div class="form-row" style="margin-top:12px">
|
||||||
@@ -349,6 +359,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="app.js?v=19"></script>
|
<script src="app.js?v=20"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { TaskManager } from './lib/task.mjs';
|
|||||||
import { checkLogin, getLoginStatus, openLoginPage, importCookiesFromJson, getLoginQr } from './lib/login.mjs';
|
import { checkLogin, getLoginStatus, openLoginPage, importCookiesFromJson, getLoginQr } from './lib/login.mjs';
|
||||||
import { closeBrowser, getOpenPagesInfo } from './lib/browser.mjs';
|
import { closeBrowser, getOpenPagesInfo } from './lib/browser.mjs';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PERSONA, DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT, chatCompletion,
|
DEFAULT_PERSONA, DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT,
|
||||||
} from './lib/ai.mjs';
|
} from './lib/ai.mjs';
|
||||||
|
import { optimizeTaskWithHermes, buildLocalOptimizedTask } from './lib/hermes_optimizer.mjs';
|
||||||
import { buildAnalysis, renderAnalysisMarkdown } from './lib/analyzer.mjs';
|
import { buildAnalysis, renderAnalysisMarkdown } from './lib/analyzer.mjs';
|
||||||
import { getNotifyConfig, saveNotifyConfig, sendNotifications } from './lib/notifier.mjs';
|
import { getNotifyConfig, saveNotifyConfig, sendNotifications } from './lib/notifier.mjs';
|
||||||
|
|
||||||
@@ -161,43 +162,17 @@ app.post('/api/notify-test', async (req, res) => {
|
|||||||
app.post('/api/tasks/optimize', async (req, res) => {
|
app.post('/api/tasks/optimize', async (req, res) => {
|
||||||
const { seed = '', product = '', budget = '', region = '', currentRequirements = '' } = req.body || {};
|
const { seed = '', product = '', budget = '', region = '', currentRequirements = '' } = req.body || {};
|
||||||
const base = [product, seed, currentRequirements].filter(Boolean).join('\n');
|
const base = [product, seed, currentRequirements].filter(Boolean).join('\n');
|
||||||
if (!base.trim()) return res.status(400).json({ error: '请输入商品/需求描述后再优化' });
|
if (!base.trim()) return res.status(400).json({ error: '请输入商品/需求描述后再润色' });
|
||||||
|
|
||||||
const fallback = buildLocalOptimizedTask({ seed, product, budget, region, currentRequirements });
|
const fallback = buildLocalOptimizedTask({ seed, product, budget, region, currentRequirements });
|
||||||
try {
|
try {
|
||||||
const prompt = `你是闲鱼二手商品监测任务配置助手。根据用户想找的商品,生成更适合闲鱼搜索和风控筛选的任务配置。只输出JSON,不要解释。
|
const result = await optimizeTaskWithHermes({ seed, product, budget, region, currentRequirements });
|
||||||
字段:name:string, queries:string[], priceMin:number|null, priceMax:number|null, maxPages:number, maxItems:number, personalSeller:boolean, customRequirements:string, coarsePrompt:string, finePrompt:string。
|
res.json({ ok: true, source: result.source || 'hermes', task: result.task });
|
||||||
要求:queries 3-6个,包含常见别名/空格/大小写变体;customRequirements 写清排除配件/维修/求购/商家批量/异常低价;maxItems 保守在20-80。
|
|
||||||
用户输入:${JSON.stringify({ seed, product, budget, region, currentRequirements })}`;
|
|
||||||
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.2, maxTokens: 1200, timeoutMs: 20000 });
|
|
||||||
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
|
|
||||||
const parsed = JSON.parse(cleaned);
|
|
||||||
res.json({ ok: true, source: 'ai', task: { ...fallback, ...parsed } });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.json({ ok: true, source: 'local-fallback', warning: err.message, task: fallback });
|
res.json({ ok: true, source: 'local-fallback', warning: err.message, task: fallback });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildLocalOptimizedTask({ seed = '', product = '', budget = '', region = '', currentRequirements = '' }) {
|
|
||||||
const text = `${product || seed}`.trim();
|
|
||||||
const queries = [...new Set(text.split(/[,,、]/).map(s => s.trim()).filter(Boolean))];
|
|
||||||
if (queries.length === 0 && text) queries.push(text);
|
|
||||||
const budgetNum = Number(String(budget).match(/\d+(?:\.\d+)?/)?.[0] || '') || null;
|
|
||||||
return {
|
|
||||||
name: text ? `${text} 监测` : '闲鱼商品监测',
|
|
||||||
queries: queries.slice(0, 6),
|
|
||||||
priceMin: null,
|
|
||||||
priceMax: budgetNum,
|
|
||||||
region,
|
|
||||||
maxPages: 2,
|
|
||||||
maxItems: 40,
|
|
||||||
personalSeller: true,
|
|
||||||
customRequirements: `${currentRequirements || `目标:${text}${budgetNum ? `,预算${budgetNum}左右` : ''}`};只做采集分析,不自动联系卖家;排除配件/维修/求购/租赁/回收/商家批量/标题党;优先个人自用、描述清楚、价格合理、可核验。`,
|
|
||||||
coarsePrompt: DEFAULT_COARSE_PROMPT,
|
|
||||||
finePrompt: DEFAULT_FINE_PROMPT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/api/default-persona', (req, res) => {
|
app.get('/api/default-persona', (req, res) => {
|
||||||
res.json({ persona: DEFAULT_PERSONA });
|
res.json({ persona: DEFAULT_PERSONA });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -338,19 +338,27 @@ Login UX, persistent-profile lock recovery (`ProcessSingleton` / `SingletonLock`
|
|||||||
|
|
||||||
Web task editing, manual save-only tasks, scheduling, `maxItems`, AI-optimize fallback, API smoke tests, and stale port restart pitfalls are captured in `references/web-task-editing-scheduling-ai-optimize-2026-05.md`.
|
Web task editing, manual save-only tasks, scheduling, `maxItems`, AI-optimize fallback, API smoke tests, and stale port restart pitfalls are captured in `references/web-task-editing-scheduling-ai-optimize-2026-05.md`.
|
||||||
|
|
||||||
|
Web AI optimize fallback and automatic Hermes analysis/notification are captured in `references/web-ai-fallback-and-auto-analysis-2026-05.md`.
|
||||||
|
|
||||||
Web cache cleanup implementation notes are captured in `references/web-cache-cleanup-2026-05.md`.
|
Web cache cleanup implementation notes are captured in `references/web-cache-cleanup-2026-05.md`.
|
||||||
|
|
||||||
Web dashboard homepage/productized empty-state implementation notes are captured in `references/web-home-dashboard-polish-2026-05.md`.
|
Web dashboard homepage/productized empty-state implementation notes are captured in `references/web-home-dashboard-polish-2026-05.md`.
|
||||||
|
|
||||||
Home dashboard clickable overview/workflow cards and deep-link behavior are captured in `references/web-home-clickable-dashboard-cards-2026-05.md`.
|
Home dashboard clickable overview/workflow cards and deep-link behavior are captured in `references/web-home-clickable-dashboard-cards-2026-05.md`.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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`.
|
Return navigation after deep-linking from the home dashboard is captured in `references/web-home-return-navigation-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`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important BOSS UX correction: do not present a direct `login.taobao.com` screenshot as the "Xianyu QR" even though Goofish uses Alibaba/Taobao unified login under the hood. Open/login from `goofish.com` first, label any redirect honestly, and prefer the visible browser login flow when BOSS wants to scan manually.
|
Important BOSS UX correction: do not present a direct `login.taobao.com` screenshot as the "Xianyu QR" even though Goofish uses Alibaba/Taobao unified login under the hood. Open/login from `goofish.com` first, label any redirect honestly, and prefer the visible browser login flow when BOSS wants to scan manually.
|
||||||
|
|
||||||
Real QR-login screenshot flow for Feishu/mobile convenience is captured in `references/qr-login-screenshot-2026-05.md`. When BOSS says Cookie JSON is inconvenient, prefer opening the real Taobao/Goofish login page in the persistent profile, screenshotting the QR, keeping the browser alive briefly, and replying with `MEDIA:/tmp/xianyu-login-qr.png`.
|
Web QR source labeling + Hermes optimize handoff notes are captured in `references/web-qr-source-and-hermes-optimize-2026-05.md`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Public Gitea Publish Verification Notes (2026-05)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
BOSS changed the publishing requirement from private to public for the portable `xianyu-hunter-hermes-skill` package. The repository was created on self-hosted Gitea and pushed as a public repo.
|
||||||
|
|
||||||
|
Verified public repository shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.chickliu.fun/Hermes/xianyu-hunter-hermes-skill
|
||||||
|
```
|
||||||
|
|
||||||
|
Package/repo contents:
|
||||||
|
|
||||||
|
```text
|
||||||
|
README.md
|
||||||
|
install.sh
|
||||||
|
skill/xianyu-hunter-monitor/
|
||||||
|
project/xianyu-hunter/
|
||||||
|
releases/xianyu-hunter-hermes-skill.zip
|
||||||
|
releases/xianyu-hunter-hermes-skill.tar.gz
|
||||||
|
releases/SHA256SUMS.txt
|
||||||
|
.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended public publish sequence
|
||||||
|
|
||||||
|
1. Create or check the Gitea repo via API, explicitly setting `private:false`.
|
||||||
|
2. Build a clean staging worktree from the already-vetted portable package, not directly from the runtime project clone.
|
||||||
|
3. Add `.gitignore` that excludes runtime/sensitive state even if future work occurs in the published repo:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
node_modules/
|
||||||
|
browser-data/
|
||||||
|
data/tasks.json
|
||||||
|
data/config.json
|
||||||
|
data/notify-config.json
|
||||||
|
reports/
|
||||||
|
*.log
|
||||||
|
.runtime/
|
||||||
|
.DS_Store
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Commit and push to `main`.
|
||||||
|
5. If using an HTTPS token in the remote URL for push, immediately scrub the token after push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote set-url origin 'https://gitea.example.com/OWNER/REPO.git'
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
Use both API and unauthenticated/raw access checks where possible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITEA_URL='https://gitea.example.com'
|
||||||
|
OWNER='Hermes'
|
||||||
|
REPO='xianyu-hunter-hermes-skill'
|
||||||
|
TOKEN='...'
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: token $TOKEN" \
|
||||||
|
-o /tmp/gitea_repo_verify.json \
|
||||||
|
-w 'HTTP %{http_code} size=%{size_download}\n' \
|
||||||
|
"$GITEA_URL/api/v1/repos/$OWNER/$REPO"
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
d=json.load(open('/tmp/gitea_repo_verify.json'))
|
||||||
|
print('full_name:', d.get('full_name'))
|
||||||
|
print('html_url:', d.get('html_url'))
|
||||||
|
print('clone_url:', d.get('clone_url'))
|
||||||
|
print('private:', d.get('private'))
|
||||||
|
print('default_branch:', d.get('default_branch'))
|
||||||
|
print('empty:', d.get('empty'))
|
||||||
|
PY
|
||||||
|
|
||||||
|
curl -sS -L -o /tmp/gitea_readme.md \
|
||||||
|
-w 'HTTP %{http_code} size=%{size_download}\n' \
|
||||||
|
"$GITEA_URL/$OWNER/$REPO/raw/branch/main/README.md"
|
||||||
|
head -5 /tmp/gitea_readme.md
|
||||||
|
|
||||||
|
git ls-remote "$GITEA_URL/$OWNER/$REPO.git" HEAD refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected checks:
|
||||||
|
|
||||||
|
- API returns `HTTP 200`.
|
||||||
|
- `private: False` for public repos.
|
||||||
|
- `default_branch: main`.
|
||||||
|
- `empty: False`.
|
||||||
|
- Raw README returns `HTTP 200`.
|
||||||
|
- `git ls-remote` prints `HEAD` and `refs/heads/main` hashes.
|
||||||
|
|
||||||
|
## Reporting to BOSS
|
||||||
|
|
||||||
|
Keep the final response concise and include:
|
||||||
|
|
||||||
|
- Repository URL.
|
||||||
|
- Public/private status confirmation.
|
||||||
|
- Main verification results.
|
||||||
|
- Note that the local remote URL no longer contains the token if a token was used.
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# Portable Skill Package + Gitea Publishing Notes (2026-05)
|
||||||
|
|
||||||
|
## What was packaged
|
||||||
|
|
||||||
|
A portable archive was created for sharing the local `xianyu-hunter-monitor` workflow with other Hermes installations. The useful package shape is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
xianyu-hunter-hermes-skill/
|
||||||
|
README.md
|
||||||
|
install.sh
|
||||||
|
skill/xianyu-hunter-monitor/
|
||||||
|
project/xianyu-hunter/
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended optional repo/release shape when publishing to a Git service:
|
||||||
|
|
||||||
|
```text
|
||||||
|
README.md
|
||||||
|
install.sh
|
||||||
|
skill/xianyu-hunter-monitor/
|
||||||
|
project/xianyu-hunter/
|
||||||
|
releases/
|
||||||
|
xianyu-hunter-hermes-skill.zip
|
||||||
|
xianyu-hunter-hermes-skill.tar.gz
|
||||||
|
SHA256SUMS.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exclude from the package
|
||||||
|
|
||||||
|
Do **not** include local runtime or sensitive state:
|
||||||
|
|
||||||
|
- `browser-data/` — Goofish/Taobao login profile and cookies.
|
||||||
|
- `node_modules/`.
|
||||||
|
- `.git/`.
|
||||||
|
- `.runtime/`.
|
||||||
|
- `reports/`.
|
||||||
|
- `xianyu-server.log` and other logs.
|
||||||
|
- `data/tasks.json`.
|
||||||
|
- `data/config.json`.
|
||||||
|
- real `data/notify-config.json` because it can contain delivery targets or tokens.
|
||||||
|
|
||||||
|
It is safe to include an example notification config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"notifyOnlyGood": true,
|
||||||
|
"minScore": 75,
|
||||||
|
"channels": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal README contents
|
||||||
|
|
||||||
|
The README should explain:
|
||||||
|
|
||||||
|
1. Purpose: Hermes Skill + local Web/CLI 闲鱼采集助手.
|
||||||
|
2. Default safety mode: only collect/filter/analyze; no seller contact, no purchase, no payment.
|
||||||
|
3. Requirements: Hermes Agent, Node.js >= 18, npm, network access to `https://www.goofish.com/`.
|
||||||
|
4. Install:
|
||||||
|
```bash
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
5. Default install destinations:
|
||||||
|
```text
|
||||||
|
~/.hermes/skills/social-media/xianyu-hunter-monitor/
|
||||||
|
~/xianyu-hunter/
|
||||||
|
```
|
||||||
|
6. Optional project destination:
|
||||||
|
```bash
|
||||||
|
XIANHU_INSTALL_DIR="$HOME/apps/xianyu-hunter" bash install.sh
|
||||||
|
```
|
||||||
|
7. Start dashboard:
|
||||||
|
```bash
|
||||||
|
cd ~/xianyu-hunter
|
||||||
|
node tools/xianyu_ops.mjs start --port 3000
|
||||||
|
```
|
||||||
|
8. Login flow: Web UI → `账号登录 / Cookie` → get QR → scan → refresh status.
|
||||||
|
9. Example monitoring command via CLI and natural-language Hermes prompt.
|
||||||
|
10. Troubleshooting: login wall, captcha, port conflict, skill not found.
|
||||||
|
|
||||||
|
## install.sh pattern
|
||||||
|
|
||||||
|
Use a script that installs the skill and project without requiring many decisions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||||
|
SKILL_DEST="$HERMES_HOME/skills/social-media/xianyu-hunter-monitor"
|
||||||
|
PROJECT_DEST="${XIANHU_INSTALL_DIR:-$HOME/xianyu-hunter}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$SKILL_DEST")"
|
||||||
|
rm -rf "$SKILL_DEST"
|
||||||
|
cp -R "$ROOT/skill/xianyu-hunter-monitor" "$SKILL_DEST"
|
||||||
|
|
||||||
|
if [ -e "$PROJECT_DEST" ]; then
|
||||||
|
backup="$PROJECT_DEST.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mv "$PROJECT_DEST" "$backup"
|
||||||
|
fi
|
||||||
|
cp -R "$ROOT/project/xianyu-hunter" "$PROJECT_DEST"
|
||||||
|
cd "$PROJECT_DEST"
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification before sharing
|
||||||
|
|
||||||
|
Run these checks before delivering archives:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OUT=/path/to/xianyu-hunter-hermes-skill
|
||||||
|
for bad in \
|
||||||
|
'project/xianyu-hunter/browser-data' \
|
||||||
|
'project/xianyu-hunter/node_modules' \
|
||||||
|
'project/xianyu-hunter/.git' \
|
||||||
|
'project/xianyu-hunter/reports' \
|
||||||
|
'project/xianyu-hunter/data/tasks.json' \
|
||||||
|
'project/xianyu-hunter/data/config.json' \
|
||||||
|
'project/xianyu-hunter/data/notify-config.json'; do
|
||||||
|
test ! -e "$OUT/$bad" || { echo "BAD_INCLUDED $bad"; exit 2; }
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Also scan for common secret patterns (`sk-...`, `OPENAI_API_KEY=`, `access_token`) while excluding explanatory docs if needed.
|
||||||
|
|
||||||
|
Generate both formats and hashes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf xianyu-hunter-hermes-skill.tar.gz xianyu-hunter-hermes-skill
|
||||||
|
zip -qr xianyu-hunter-hermes-skill.zip xianyu-hunter-hermes-skill
|
||||||
|
shasum -a 256 xianyu-hunter-hermes-skill.tar.gz xianyu-hunter-hermes-skill.zip > SHA256SUMS.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing to self-hosted Gitea
|
||||||
|
|
||||||
|
Ask the user for these details:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Gitea 地址:
|
||||||
|
仓库归属:用户或组织名
|
||||||
|
仓库名:
|
||||||
|
是否新建仓库:是/否
|
||||||
|
公开/私有:public/private
|
||||||
|
认证方式:Personal Access Token / SSH / 已配置 HTTPS 凭据
|
||||||
|
Token(如需 API 建仓或 HTTPS push):
|
||||||
|
```
|
||||||
|
|
||||||
|
If token is provided, prefer a short-lived token with repo create/read/write permission and remind the user they can revoke it after upload.
|
||||||
|
|
||||||
|
For an existing repo, a clone URL plus working SSH/HTTPS credentials is enough. For a new repo, create via Gitea API or ask the user to create it, then push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial xianyu-hunter Hermes skill package"
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin <gitea-clone-url>
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid committing generated runtime state even if the project directory contains it locally.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Portable Skill Package for Other Hermes Instances (2026-05)
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Use this when BOSS asks to "打包这个 skill 发给别人/其他 Hermes 使用" or wants a low-configuration portable delivery of the xianyu-hunter workflow.
|
||||||
|
|
||||||
|
## Package Shape
|
||||||
|
|
||||||
|
Preferred export directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/chick/.Hermes/workspace/exports/xianyu-hunter-hermes-skill/
|
||||||
|
├── skill/xianyu-hunter-monitor/ # copied Hermes skill with references/
|
||||||
|
├── project/xianyu-hunter/ # portable web/CLI project
|
||||||
|
├── README.md # end-user installation/use guide
|
||||||
|
└── install.sh # one-command installer
|
||||||
|
```
|
||||||
|
|
||||||
|
Create both archives for compatibility:
|
||||||
|
|
||||||
|
```text
|
||||||
|
xianyu-hunter-hermes-skill.zip
|
||||||
|
xianyu-hunter-hermes-skill.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
- `~/.hermes/skills/social-media/xianyu-hunter-monitor/` including `SKILL.md` and `references/`.
|
||||||
|
- The local `xianyu-hunter` project source including `server.mjs`, `lib/`, `public/`, `tools/`, `package.json`, `package-lock.json`, docs/assets if useful.
|
||||||
|
- A safe `data/notify-config.example.json` with disabled/empty channels.
|
||||||
|
- Empty `data/.gitkeep` so the directory exists after unpack.
|
||||||
|
- `README.md` with: prerequisites, one-command install, manual install, login flow, start command, sample monitoring command, Hermes usage examples, and safety boundaries.
|
||||||
|
- `install.sh` that copies the skill into `$HERMES_HOME/skills/social-media/xianyu-hunter-monitor`, copies the project to `${XIANHU_INSTALL_DIR:-$HOME/xianyu-hunter}`, runs `npm install`, and runs `npx playwright install chromium`.
|
||||||
|
|
||||||
|
## What to Exclude
|
||||||
|
|
||||||
|
Always exclude runtime/sensitive state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.git/
|
||||||
|
node_modules/
|
||||||
|
browser-data/
|
||||||
|
data/tasks.json
|
||||||
|
data/config.json
|
||||||
|
data/notify-config.json
|
||||||
|
reports/
|
||||||
|
.runtime/
|
||||||
|
*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not include real cookies, historical product data, API keys, notification targets, generated reports, or server logs.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
Before sending the archive:
|
||||||
|
|
||||||
|
1. Verify forbidden paths are absent from the export tree and archive.
|
||||||
|
2. Run a secret scan for common API-key/token patterns outside documentation files.
|
||||||
|
3. Build both `tar.gz` and `zip`.
|
||||||
|
4. Print SHA-256 hashes and archive sizes.
|
||||||
|
5. List an archive sample and confirm it contains `README.md`, `install.sh`, the skill directory, and project source.
|
||||||
|
6. Send the archive with `MEDIA:/absolute/path/to/file` so Feishu uploads it as an attachment.
|
||||||
|
|
||||||
|
Example verification commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/chick/.Hermes/workspace/exports
|
||||||
|
for bad in \
|
||||||
|
'project/xianyu-hunter/browser-data' \
|
||||||
|
'project/xianyu-hunter/node_modules' \
|
||||||
|
'project/xianyu-hunter/.git' \
|
||||||
|
'project/xianyu-hunter/reports' \
|
||||||
|
'project/xianyu-hunter/data/tasks.json' \
|
||||||
|
'project/xianyu-hunter/data/config.json' \
|
||||||
|
'project/xianyu-hunter/data/notify-config.json'; do
|
||||||
|
test ! -e "xianyu-hunter-hermes-skill/$bad" || { echo "BAD_INCLUDED $bad"; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
tar -czf xianyu-hunter-hermes-skill.tar.gz xianyu-hunter-hermes-skill
|
||||||
|
zip -qr xianyu-hunter-hermes-skill.zip xianyu-hunter-hermes-skill
|
||||||
|
shasum -a 256 xianyu-hunter-hermes-skill.tar.gz xianyu-hunter-hermes-skill.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## User-Facing Response Pattern
|
||||||
|
|
||||||
|
Keep the response practical and concise:
|
||||||
|
|
||||||
|
- Say it is packaged.
|
||||||
|
- State sensitive/runtime data was excluded.
|
||||||
|
- Provide both archive attachments if available.
|
||||||
|
- Include the minimal install/start commands.
|
||||||
|
- Include one natural-language Hermes example.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Web AI Optimize Fallback and Automatic Hermes Analysis — 2026-05
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
BOSS reported two UX problems in the local xianyu-hunter Web UI:
|
||||||
|
|
||||||
|
1. 新建任务/任务编辑里的“AI 润色/AI 优化”提示 `AI不可用`,容易误以为功能坏了。
|
||||||
|
2. Hermes 分析需要手动点“执行分析”,BOSS 希望采集完成后自动分析、自动通知。
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The local fork intentionally runs in Hermes-main-control mode and does not require project-level AI API configuration. `/api/tasks/optimize` already caught `chatCompletion()` failures and returned a local fallback task, but the frontend text said `AI不可用,已用本地规则优化`, which surfaced the expected no-AI state as an error.
|
||||||
|
|
||||||
|
Hermes analysis existed as `POST /api/tasks/:id/analyze`, but `TaskManager.startTask()` ended after local detail collection. No automatic analysis/report/notification was triggered from the normal run pipeline.
|
||||||
|
|
||||||
|
## Implemented Shape
|
||||||
|
|
||||||
|
- Rename Web copy from “AI 优化” to “任务智能润色”.
|
||||||
|
- Keep `/api/tasks/optimize` API compatible, but treat local fallback as a successful no-configuration path.
|
||||||
|
- Frontend fallback text: `已用本地规则优化(无需配置 AI)`.
|
||||||
|
- Add task config flags:
|
||||||
|
- `autoAnalyze: true` by default.
|
||||||
|
- `autoNotify: true` by default.
|
||||||
|
- Add visible task form checkboxes:
|
||||||
|
- `采集完成后自动执行 Hermes 分析`
|
||||||
|
- `分析后按通知设置自动推送`
|
||||||
|
- After `_runPipeline()` completes and task is not stopped, call `_runAutoAnalysis()` before setting stage to `completed`.
|
||||||
|
- `_runAutoAnalysis()`:
|
||||||
|
1. Skips if `autoAnalyze === false`.
|
||||||
|
2. Skips if no `products.fineFiltered` candidates.
|
||||||
|
3. Calls `analyzeTask()` with `limit=maxItems`.
|
||||||
|
4. Calls `renderAnalysisMarkdown()` and persists `summary.reportPath`.
|
||||||
|
5. Calls `sendNotifications()` unless `autoNotify === false`.
|
||||||
|
6. Logs success/skip/failure into task logs.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
```text
|
||||||
|
server.mjs
|
||||||
|
lib/task.mjs
|
||||||
|
public/app.js
|
||||||
|
public/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
|
||||||
|
node --check server.mjs
|
||||||
|
node --check lib/task.mjs
|
||||||
|
node --check public/app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart stale port manually if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -nP -iTCP:3000 -sTCP:LISTEN
|
||||||
|
kill -9 <pid>
|
||||||
|
PORT=3000 node server.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
API fallback smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:3000/api/tasks/optimize \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"product":"e3-1245v3 cpu","budget":"150","currentRequirements":"价格150左右,能正常点亮,排除坏件维修配件"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response can be `source: local-fallback`; this is OK. It should include `task.autoAnalyze: true` and `task.autoNotify: true`.
|
||||||
|
|
||||||
|
Task create smoke test should persist config flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:3000/api/tasks \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"验证自动分析任务","queries":["test"],"autoStart":false,"autoAnalyze":true,"autoNotify":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit-style auto-analysis check without starting scheduler:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XIANHU_DISABLE_SCHEDULER=1 node --input-type=module <<'JS'
|
||||||
|
import { TaskManager } from './lib/task.mjs';
|
||||||
|
const tm = new TaskManager(() => {});
|
||||||
|
const task = tm.createTask({ name: '自动分析单元验证', queries: ['e3-1245v3 cpu'], maxItems: 2, autoAnalyze: true, autoNotify: false, autoStart: false });
|
||||||
|
const full = tm.getTaskFull(task.id);
|
||||||
|
full.products.fineFiltered = [
|
||||||
|
{ id:'unit-1', title:'Intel Xeon E3 1245 V3 CPU 正常使用', price:'¥150', detailPrice:'¥150', sellerLocation:'广东', description:'自用拆机,能正常点亮,不是维修件', href:'https://example.com/1' },
|
||||||
|
{ id:'unit-2', title:'E3 CPU 坏件维修练手', price:'¥50', detailPrice:'¥50', sellerLocation:'广东', description:'坏件', href:'https://example.com/2' },
|
||||||
|
];
|
||||||
|
const logs=[];
|
||||||
|
await tm._runAutoAnalysis(full, msg => logs.push(msg));
|
||||||
|
console.log(tm.getTaskFull(task.id).hermesAnalysis);
|
||||||
|
tm.deleteTask(task.id);
|
||||||
|
JS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- If API tests still show missing `autoAnalyze/autoNotify`, the old server process is still listening. `tools/xianyu_ops.mjs stop` may say `no-recorded-server`; use `lsof` and kill the actual PID.
|
||||||
|
- `source: local-fallback` from `/api/tasks/optimize` is not a failure in Hermes mode.
|
||||||
|
- Do not reintroduce project-level AI API gating for task creation or collection.
|
||||||
|
- Do not automatically contact sellers; this automation only performs collect → local prefilter → Hermes analysis → notification.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Web QR login source labeling + Hermes optimize handoff (2026-05)
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
BOSS corrected two UX/architecture problems in the local xianyu-hunter Web dashboard:
|
||||||
|
|
||||||
|
1. The login modal must not present a direct `login.taobao.com` screenshot as if it were a pure “闲鱼二维码”. Goofish/闲鱼 may legitimately redirect to Taobao/Alibaba unified login, but the UI must say this honestly and start from `goofish.com` first.
|
||||||
|
2. “任务智能润色” should call Hermes Agent to generate the task configuration, not the project’s old internal `chatCompletion` / AI API config path.
|
||||||
|
|
||||||
|
## Implemented local flow
|
||||||
|
|
||||||
|
Files in `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
|
||||||
|
|
||||||
|
- `lib/login.mjs`
|
||||||
|
- `getLoginQr()` opens `https://www.goofish.com/` first.
|
||||||
|
- If the official flow redirects to `login.taobao.com` / `havanaone`, the API returns `qrSource: "淘宝/阿里统一登录"` and `qrUrl`.
|
||||||
|
- It prefers screenshotting a QR-like element (`canvas`, QR images, qrcode containers, iframe descendants) over whole-body screenshots.
|
||||||
|
- It logs the true source instead of calling the screenshot “闲鱼二维码” unconditionally.
|
||||||
|
- `public/index.html` / `public/app.js`
|
||||||
|
- Login panel now displays QR source and explains that Taobao/Alibaba unified login is an official redirected login chain.
|
||||||
|
- Help copy says it starts from 闲鱼官网 and no longer disguises Taobao screenshots as Xianyu QR.
|
||||||
|
- `lib/hermes_optimizer.mjs`
|
||||||
|
- Spawns Hermes CLI (`HERMES_BIN` default `/Users/chick/.local/bin/hermes`) with a JSON-only prompt.
|
||||||
|
- Sanitizes returned task fields and falls back to local deterministic rules on failure.
|
||||||
|
- `server.mjs`
|
||||||
|
- `/api/tasks/optimize` imports `optimizeTaskWithHermes` and `buildLocalOptimizedTask` and no longer imports/calls `chatCompletion`.
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
|
||||||
|
node --check server.mjs && node --check lib/login.mjs && node --check lib/hermes_optimizer.mjs && node --check public/app.js
|
||||||
|
|
||||||
|
# If old behavior still appears, kill the actual stale PID on port 3000; xianyu_ops stop may say no-recorded-server.
|
||||||
|
lsof -tiTCP:3000 -sTCP:LISTEN | xargs kill
|
||||||
|
node tools/xianyu_ops.mjs start --port 3000
|
||||||
|
|
||||||
|
curl -sS -X POST http://127.0.0.1:3000/api/tasks/optimize \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"product":"e3-1245v3 cpu","budget":"150左右","currentRequirements":"只看CPU本体,排除主板套装/散热器/坏件/维修/求购"}' | python3 -m json.tool
|
||||||
|
# Expect: source == "hermes" when Hermes CLI is available.
|
||||||
|
|
||||||
|
curl -sS -X POST http://127.0.0.1:3000/api/login/qr \
|
||||||
|
-H 'Content-Type: application/json' -d '{}' | python3 -m json.tool
|
||||||
|
# Expect: qrSource/qrUrl present. If already logged in, qrImage may be empty and logs say no scan is needed.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfall
|
||||||
|
|
||||||
|
After code edits, port 3000 may still be served by a stale Node PID that was not recorded in `.runtime`. If `/api/tasks/optimize` still returns an old warning such as “AI 未配置:请先在控制台设置 API 密钥和模型”, kill the real listening PID with `lsof -tiTCP:3000 -sTCP:LISTEN | xargs kill` and restart.
|
||||||
Reference in New Issue
Block a user