Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/server.mjs
T

254 lines
8.5 KiB
JavaScript

import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import path from 'path';
import { fileURLToPath } from 'url';
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,
} 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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
maxAge: 0,
setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'),
}));
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
ws.on('error', () => clients.delete(ws));
ws.send(JSON.stringify({
event: 'connected',
data: {
tasks: taskManager.getAllTasks(),
configured: true,
},
}));
});
function broadcast(msg) {
const payload = JSON.stringify(msg);
for (const ws of clients) {
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}
const taskManager = new TaskManager(broadcast);
// ========== Task API ==========
app.get('/api/tasks', (req, res) => {
res.json(taskManager.getAllTasks());
});
app.get('/api/tasks/:id', (req, res) => {
const task = taskManager.getTask(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
app.post('/api/tasks', (req, res) => {
const task = taskManager.createTask(req.body);
if (req.body?.autoStart !== false) taskManager.startTask(task.id);
res.json(taskManager.getTask(task.id));
});
app.put('/api/tasks/:id', (req, res) => {
const task = taskManager.updateTask(req.params.id, req.body || {});
if (!task) return res.status(404).json({ error: 'Task not found or task is running' });
res.json(taskManager.getTask(req.params.id));
});
app.post('/api/tasks/:id/start', (req, res) => {
const task = taskManager.getTask(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
taskManager.startTask(req.params.id, { reset: !!req.body?.reset });
res.json({ ok: true });
});
app.post('/api/tasks/:id/stop', (req, res) => {
taskManager.stopTask(req.params.id);
res.json({ ok: true });
});
app.post('/api/tasks/:id/duplicate', (req, res) => {
const newTask = taskManager.duplicateTask(req.params.id);
if (!newTask) return res.status(404).json({ error: 'Source task not found' });
res.json(newTask);
});
app.delete('/api/tasks/:id', (req, res) => {
taskManager.deleteTask(req.params.id);
res.json({ ok: true });
});
app.post('/api/tasks/:id/clear-cache', (req, res) => {
const result = taskManager.clearTaskCache(req.params.id, {
mode: req.body?.mode || 'all',
minScore: Number(req.body?.minScore ?? 75),
});
if (!result) return res.status(404).json({ error: 'Task not found or task is running' });
res.json(result);
});
app.post('/api/tasks/:id/analyze', async (req, res) => {
const task = taskManager.getTaskFull(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
const analysis = taskManager.analyzeTask(req.params.id, { limit: Number(req.body?.limit || 50) });
const report = renderAnalysisMarkdown(taskManager.getTaskFull(req.params.id), analysis.summary);
analysis.summary.reportPath = report.path;
taskManager.setTaskAnalysis(req.params.id, analysis);
let notification = null;
if (req.body?.notify !== false) {
notification = await sendNotifications(taskManager.getTaskFull(req.params.id), analysis.summary, { force: !!req.body?.forceNotify });
}
res.json({ ok: true, analysis: analysis.summary, reportPath: report.path, notification });
});
app.get('/api/notify-config', (req, res) => {
res.json(getNotifyConfig());
});
app.put('/api/notify-config', (req, res) => {
res.json(saveNotifyConfig(req.body || {}));
});
app.post('/api/notify-test', async (req, res) => {
try {
const config = req.body?.config || getNotifyConfig();
const targetLabel = (config.channels || []).filter(c => c.enabled).map(c => c.name || c.target || c.type).join('、') || '未启用通道';
const task = { id: 'notify-test', name: '通知渠道测试' };
const summary = {
minScore: Number(config.minScore ?? 75),
total: 1,
recommendedCount: 1,
cautionCount: 0,
rejectedCount: 0,
reportPath: '这是测试通知,不对应真实报告',
topItems: [{
title: req.body?.title || 'xianyu-hunter 测试通知',
score: 99,
verdict: 'recommend',
price: '测试',
location: '本机',
reason: `测试通知渠道:${targetLabel}`,
href: '',
}],
};
const result = await sendNotifications(task, summary, { force: true, config: { ...config, enabled: true, notifyOnlyGood: false } });
res.status(result.ok ? 200 : 500).json(result);
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
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: '请输入商品/需求描述后再润色' });
const fallback = buildLocalOptimizedTask({ seed, product, budget, region, currentRequirements });
try {
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 });
}
});
app.get('/api/default-persona', (req, res) => {
res.json({ persona: DEFAULT_PERSONA });
});
app.get('/api/defaults', (req, res) => {
res.json({
persona: DEFAULT_PERSONA,
coarsePrompt: DEFAULT_COARSE_PROMPT,
finePrompt: DEFAULT_FINE_PROMPT,
});
});
// ========== Login / Cookie API ==========
// Open the persistent Playwright Chromium profile for manual Goofish login.
// Cookies are saved by Chromium under ./browser-data and reused by later tasks.
app.get('/api/login/status', async (req, res) => {
try {
const [status, pages] = await Promise.all([
getLoginStatus(),
getOpenPagesInfo(),
]);
res.json({ ok: true, ...status, ...pages });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/open', async (req, res) => {
try {
const logs = [];
const status = await openLoginPage(msg => logs.push(msg));
res.json({ ok: true, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/qr', async (req, res) => {
try {
const logs = [];
const status = await getLoginQr(msg => logs.push(msg));
res.json({ ok: true, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/wait', async (req, res) => {
try {
const logs = [];
const ok = await checkLogin(msg => logs.push(msg));
const status = await getLoginStatus();
res.json({ ok: true, loggedIn: ok && status.loggedIn, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/close-browser', async (req, res) => {
await closeBrowser();
res.json({ ok: true });
});
app.post('/api/login/import-cookies', async (req, res) => {
try {
const logs = [];
const payload = req.body?.cookies ?? req.body?.data ?? req.body;
const result = await importCookiesFromJson(payload, msg => logs.push(msg));
res.json({ ok: true, imported: result.imported, ...result.status, logs });
} catch (err) {
res.status(400).json({ ok: false, error: err.message });
}
});
server.listen(PORT, () => {
console.log(`🚀 闲鱼智能助手已启动: http://localhost:${PORT}`);
console.log(`🦐 分析模式: 项目只负责采集和本地预筛,深度分析由 Hermes 完成,无需配置外部模型接口`);
});