feat: publish xianyu hunter hermes skill package
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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, chatCompletion,
|
||||
} from './lib/ai.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 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 } });
|
||||
} 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 });
|
||||
});
|
||||
|
||||
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 完成,无需配置外部模型接口`);
|
||||
});
|
||||
Reference in New Issue
Block a user