diff --git a/.gitignore b/.gitignore index 584cad0..2d35e49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ +.DS_Store node_modules/ browser-data/ -data/tasks.json -data/config.json -data/notify-config.json -reports/ -*.log .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 diff --git a/project/xianyu-hunter/data/notify-config.example.json b/project/xianyu-hunter/data/notify-config.example.json index 402319b..5c6182a 100644 --- a/project/xianyu-hunter/data/notify-config.example.json +++ b/project/xianyu-hunter/data/notify-config.example.json @@ -3,4 +3,4 @@ "notifyOnlyGood": true, "minScore": 75, "channels": [] -} \ No newline at end of file +} diff --git a/project/xianyu-hunter/lib/hermes_optimizer.mjs b/project/xianyu-hunter/lib/hermes_optimizer.mjs new file mode 100644 index 0000000..3b3eec5 --- /dev/null +++ b/project/xianyu-hunter/lib/hermes_optimizer.mjs @@ -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 }; diff --git a/project/xianyu-hunter/lib/login.mjs b/project/xianyu-hunter/lib/login.mjs index 4dafc5a..f5ab137 100644 --- a/project/xianyu-hunter/lib/login.mjs +++ b/project/xianyu-hunter/lib/login.mjs @@ -127,18 +127,56 @@ export async function openLoginPage(emitLog = () => {}) { 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 = () => {}) { 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(); - emitLog('正在打开闲鱼 Web 登录入口并生成后台二维码截图...'); + const page = ctx.pages().find(isGoofishOrLoginPage) || ctx.pages()[0] || await ctx.newPage(); + emitLog('正在从闲鱼官网入口打开登录页;如跳转到淘宝/支付宝统一登录,会在日志中标明来源。'); await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); await sleep(2500); let clicked = false; for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) { - const loc = page.locator(selector).first(); - if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) { - await loc.click({ timeout: 3000 }).catch(() => {}); + if (await clickIfVisible(page, selector)) { clicked = true; await sleep(2500); break; @@ -146,26 +184,29 @@ export async function getLoginQr(emitLog = () => {}) { } for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) { - const loc = page.locator(selector).first(); - if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) { - await loc.click({ timeout: 2500 }).catch(() => {}); + if (await clickIfVisible(page, selector)) { await sleep(1500); break; } } + const currentUrl = page.url(); + const redirectedProvider = /login\.taobao\.com|havanaone/.test(currentUrl) ? '淘宝/阿里统一登录' : '闲鱼 Web 登录入口'; const status = await getLoginStatus(page); let qrImage = ''; + let screenshotKind = ''; if (!status.loggedIn) { + const qrLike = await findQrLikeLocator(page); 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); if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`; } if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码'); - else emitLog(`已生成登录截图${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请在后台页面扫码后点“检测登录状态”`); - return { ...status, qrImage, qrCheckedAt: new Date().toISOString() }; + else emitLog(`已从${redirectedProvider}生成${screenshotKind || '登录截图'}${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请扫码后点“刷新账号状态”。当前 URL:${currentUrl}`); + return { ...status, qrImage, qrCheckedAt: new Date().toISOString(), qrSource: redirectedProvider, qrUrl: currentUrl, qrScreenshotKind: screenshotKind }; } export async function getLoginStatus(existingPage = null) { diff --git a/project/xianyu-hunter/lib/task.mjs b/project/xianyu-hunter/lib/task.mjs index 16c97e3..c33c006 100644 --- a/project/xianyu-hunter/lib/task.mjs +++ b/project/xianyu-hunter/lib/task.mjs @@ -5,7 +5,8 @@ import { genId, timestamp } from './utils.mjs'; import { checkLogin } from './login.mjs'; import { searchProducts } from './search.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 DATA_DIR = path.join(__dirname, '..', 'data'); @@ -36,6 +37,8 @@ export class TaskManager { finePrompt: config.finePrompt || '', maxPages: Math.max(1, Math.min(20, Number(config.maxPages || 3))), maxItems: Math.max(1, Math.min(500, Number(config.maxItems || 60))), + autoAnalyze: config.autoAnalyze !== false, + autoNotify: config.autoNotify !== false, schedule: { enabled: !!scheduleEnabled, intervalMinutes: Math.max(0, Number(scheduleIntervalMinutes || 0) || 0), @@ -123,7 +126,7 @@ export class TaskManager { async startTask(id, configUpdates = {}) { const task = this.tasks.get(id); - if (!task || task.running) return; + if (!task || task.running) return null; if (Object.keys(configUpdates || {}).length) { const { reset, scheduled, ...patch } = configUpdates; @@ -151,6 +154,7 @@ export class TaskManager { try { await this._runPipeline(task, emitLog, shouldStop); if (!task.stopped) { + await this._runAutoAnalysis(task, emitLog); task.stage = 'completed'; } } catch (err) { @@ -268,6 +272,52 @@ export class TaskManager { 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() { return [...this.tasks.values()].map(t => this._serialize(t)); } @@ -370,7 +420,7 @@ export class TaskManager { task.chatSessionsFull = []; this._emit(task.id, 'task:chats', task.chatSessions); this._persistToDisk(); - emitLog(`🦐 已关闭联系卖家功能:保留 ${task.products.fineFiltered.length} 个候选商品,后续由 Hermes 读取 data/tasks.json 做深度分析报告`); + emitLog(`🦐 采集和本地预筛完成:保留 ${task.products.fineFiltered.length} 个候选,准备自动执行 Hermes 分析`); emitLog('========== 采集流程结束 =========='); } @@ -547,6 +597,7 @@ export class TaskManager { } _startScheduler() { + if (process.env.XIANHU_DISABLE_SCHEDULER === '1') return; this.schedulerTimer = setInterval(() => this._tickSchedules(), 30000); } diff --git a/project/xianyu-hunter/public/app.js b/project/xianyu-hunter/public/app.js index be64f9d..67e02de 100644 --- a/project/xianyu-hunter/public/app.js +++ b/project/xianyu-hunter/public/app.js @@ -232,6 +232,16 @@ function renderLoginStatus(data) { function renderLoginQr(data) { const panel = document.getElementById('login-qr-panel'); 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) { img.src = data.qrImage; panel.style.display = 'block'; @@ -358,6 +368,8 @@ function collectTaskForm() { maxItems: maxItems ? Number(maxItems) : 60, region, personalSeller, + autoAnalyze: document.getElementById('f-auto-analyze')?.checked !== false, + autoNotify: document.getElementById('f-auto-notify')?.checked !== false, customRequirements, coarsePrompt: coarsePrompt || '', finePrompt: finePrompt || '', @@ -403,7 +415,7 @@ async function optimizeTaskForm() { currentRequirements: document.getElementById('f-requirements').value.trim(), }); fillTaskForm(result.task || {}); - status.textContent = result.source === 'ai' ? '已由 AI 优化' : 'AI不可用,已用本地规则优化'; + status.textContent = result.source === 'hermes' ? '已由 Hermes Agent 优化' : '已用本地规则优化(Hermes 不可用时兜底)'; } catch (err) { status.textContent = `优化失败:${err.message}`; } @@ -419,6 +431,8 @@ function fillTaskForm(taskOrConfig, name = '') { document.getElementById('f-max-items').value = c.maxItems || 60; document.getElementById('f-region').value = c.region || ''; 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-coarse-prompt').value = c.coarsePrompt || defaultCoarsePrompt; document.getElementById('f-fine-prompt').value = c.finePrompt || defaultFinePrompt; diff --git a/project/xianyu-hunter/public/index.html b/project/xianyu-hunter/public/index.html index e9e77c8..e58e312 100644 --- a/project/xianyu-hunter/public/index.html +++ b/project/xianyu-hunter/public/index.html @@ -226,8 +226,8 @@
1. 点击“获取/刷新登录二维码”后,后台会从闲鱼 Web 登录入口生成二维码/登录面板截图,可直接在本页面扫码。
-2. 扫码完成后点击“刷新账号状态”,本页会展示当前账号昵称、头像、Cookie 数和登录墙状态。
-3. Cookie 会自动保存在项目的 browser-data/ 目录,后续采集任务复用这个登录态。
4. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。
+1. 点击“获取/刷新登录二维码”后,后台会先进入闲鱼官网,再沿官方登录链路生成二维码/登录面板截图。
+2. 闲鱼当前可能跳转到淘宝/阿里统一登录;页面会明确显示来源,不再把淘宝登录截图伪装成“闲鱼二维码”。
+3. 扫码完成后点击“刷新账号状态”,本页会展示当前账号昵称、头像、Cookie 数和登录墙状态。
+4. Cookie 会自动保存在项目的 browser-data/ 目录,后续采集任务复用这个登录态。
5. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。