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. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。


       
@@ -298,12 +299,21 @@
           
         
         
- +
- +
+
+
+ +
+
+ +
+
+
默认全自动:保存并采集 → 本地预筛 → Hermes 分析 → 命中通知;无需再手动点“执行分析”。
定时任务
@@ -349,6 +359,6 @@ - + diff --git a/project/xianyu-hunter/reports/.gitkeep b/project/xianyu-hunter/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/project/xianyu-hunter/server.mjs b/project/xianyu-hunter/server.mjs index 883a1b5..f9b0e3d 100644 --- a/project/xianyu-hunter/server.mjs +++ b/project/xianyu-hunter/server.mjs @@ -7,8 +7,9 @@ import { TaskManager } from './lib/task.mjs'; import { checkLogin, getLoginStatus, openLoginPage, importCookiesFromJson, getLoginQr } from './lib/login.mjs'; import { closeBrowser, getOpenPagesInfo } from './lib/browser.mjs'; import { - DEFAULT_PERSONA, DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT, chatCompletion, + DEFAULT_PERSONA, DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT, } from './lib/ai.mjs'; +import { optimizeTaskWithHermes, buildLocalOptimizedTask } from './lib/hermes_optimizer.mjs'; import { buildAnalysis, renderAnalysisMarkdown } from './lib/analyzer.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) => { const { seed = '', product = '', budget = '', region = '', currentRequirements = '' } = req.body || {}; 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 }); try { - const prompt = `你是闲鱼二手商品监测任务配置助手。根据用户想找的商品,生成更适合闲鱼搜索和风控筛选的任务配置。只输出JSON,不要解释。 -字段:name:string, queries:string[], priceMin:number|null, priceMax:number|null, maxPages:number, maxItems:number, personalSeller:boolean, customRequirements:string, coarsePrompt:string, finePrompt:string。 -要求: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 } }); + const result = await optimizeTaskWithHermes({ seed, product, budget, region, currentRequirements }); + res.json({ ok: true, source: result.source || 'hermes', task: result.task }); } catch (err) { 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) => { res.json({ persona: DEFAULT_PERSONA }); }); diff --git a/skill/xianyu-hunter-monitor/SKILL.md b/skill/xianyu-hunter-monitor/SKILL.md index 397370e..fa40fce 100644 --- a/skill/xianyu-hunter-monitor/SKILL.md +++ b/skill/xianyu-hunter-monitor/SKILL.md @@ -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 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 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`. +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`. +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. -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 diff --git a/skill/xianyu-hunter-monitor/references/gitea-public-publish-verification-2026-05.md b/skill/xianyu-hunter-monitor/references/gitea-public-publish-verification-2026-05.md new file mode 100644 index 0000000..f5ca232 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/gitea-public-publish-verification-2026-05.md @@ -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. diff --git a/skill/xianyu-hunter-monitor/references/portable-package-and-gitea-publishing-2026-05.md b/skill/xianyu-hunter-monitor/references/portable-package-and-gitea-publishing-2026-05.md new file mode 100644 index 0000000..4446593 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/portable-package-and-gitea-publishing-2026-05.md @@ -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 +git push -u origin main +``` + +Avoid committing generated runtime state even if the project directory contains it locally. diff --git a/skill/xianyu-hunter-monitor/references/portable-skill-package-2026-05.md b/skill/xianyu-hunter-monitor/references/portable-skill-package-2026-05.md new file mode 100644 index 0000000..37f914c --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/portable-skill-package-2026-05.md @@ -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. diff --git a/skill/xianyu-hunter-monitor/references/web-ai-fallback-and-auto-analysis-2026-05.md b/skill/xianyu-hunter-monitor/references/web-ai-fallback-and-auto-analysis-2026-05.md new file mode 100644 index 0000000..9f4562a --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-ai-fallback-and-auto-analysis-2026-05.md @@ -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 +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. diff --git a/skill/xianyu-hunter-monitor/references/web-qr-source-and-hermes-optimize-2026-05.md b/skill/xianyu-hunter-monitor/references/web-qr-source-and-hermes-optimize-2026-05.md new file mode 100644 index 0000000..c4d258c --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-qr-source-and-hermes-optimize-2026-05.md @@ -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.