feat: publish xianyu hunter hermes skill package

This commit is contained in:
2026-05-06 19:26:45 +08:00
commit 6384156097
59 changed files with 9627 additions and 0 deletions
@@ -0,0 +1,68 @@
#!/usr/bin/env node
import fs from 'fs';
const baseUrl = process.env.XIANHU_BASE_URL || 'http://127.0.0.1:3000';
const args = process.argv.slice(2);
console.error('提示:Hermes 主控模式优先使用 tools/xianyu_ops.mjs create/run;本脚本保留为兼容入口。');
function usage() {
console.log(`Usage:
node tools/create_monitor_task.mjs --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求>
Environment:
XIANHU_BASE_URL=http://127.0.0.1:3000
Example:
node tools/create_monitor_task.mjs --name 'Mac mini M4 监测' --queries 'Mac mini M4,macmini m4' --price-max 3300 --region 成都 --pages 2 --requirements '自用,国行,成色好,无拆修,预算3300以内,只做筛选分析,不联系卖家,交给 Hermes 分析'
`);
}
function take(flag) {
const i = args.indexOf(flag);
if (i === -1) return undefined;
return args[i + 1];
}
if (args.includes('-h') || args.includes('--help')) {
usage();
process.exit(0);
}
const name = take('--name') || `闲鱼监测-${new Date().toISOString().slice(0, 10)}`;
const queriesRaw = take('--queries');
const requirements = take('--requirements') || take('--req');
if (!queriesRaw || !requirements) {
usage();
process.exit(2);
}
const payload = {
name,
queries: queriesRaw.split(/[,]/).map(s => s.trim()).filter(Boolean),
priceMin: take('--price-min') ? Number(take('--price-min')) : null,
priceMax: take('--price-max') ? Number(take('--price-max')) : null,
region: take('--region') || '',
personalSeller: args.includes('--personal-seller'),
maxPages: take('--pages') ? Number(take('--pages')) : 3,
customRequirements: requirements,
coarsePrompt: `你是闲鱼商品监测助手。只做标题相关性初筛,目标是帮用户发现值得进一步分析的候选商品。只有标题与需求明确矛盾、明显是配件/维修/求购/租赁/广告时才排除;拿不准必须保留。返回纯JSON数组。`,
finePrompt: `你是闲鱼商品风险分析助手。根据商品详情判断是否值得用户人工查看。只做采集后的分析准备,实际深度分析由 Hermes 完成。优先标记:型号/规格不符、价格异常、疑似商家批量、维修/配件/求购、描述矛盾、成色/保修/拆修风险。拿不准可以保留,但理由必须提示待人工确认。`,
};
const res = await fetch(`${baseUrl}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const text = await res.text();
if (!res.ok) {
console.error(`HTTP ${res.status}: ${text}`);
process.exit(1);
}
try {
const data = JSON.parse(text);
console.log(JSON.stringify({ ok: true, task: { id: data.id, name: data.name, stage: data.stage }, url: `${baseUrl}/` }, null, 2));
} catch {
console.log(text);
}
@@ -0,0 +1,35 @@
# Xianyu Hermes Scheduled Monitor Prompt Template
Use this as the self-contained prompt when creating a Hermes cron job for 闲鱼 monitoring.
```
你是 Hermes,帮 BOSS 定时执行闲鱼采集和分析。严格分析模式,不联系卖家、不下单、不承诺交易。
工作目录:/Users/chick/.Hermes/workspace/research/xianyu-hunter
任务参数:
- 名称:<任务名称>
- 关键词:<关键词1,关键词2>
- 价格上限:<可选>
- 地区:<可选>
- 最大页数:1-3
- 需求:<自然语言需求与风险排除条件>
执行步骤:
1. 在工作目录运行:
node tools/xianyu_ops.mjs run --name '<任务名称>' --queries '<关键词1,关键词2>' --price-max <价格上限> --region '<地区>' --pages <页数> --requirements '<需求>' --limit 30
2. 如果提示未登录或验证码,需要在最终回复中明确告诉 BOSS 需要手动登录/过验证码;不要尝试绕过。
3. 读取生成的 reports/<task_id>-hermes-report.md。
4. 用中文给 BOSS 输出简洁排序:推荐优先级、价格、地区、链接、风险点、建议人工确认的问题。
5. 不要联系卖家。
```
Example cron creation from chat/tooling context:
```text
Schedule: every 6h
Prompt: paste the filled template above
Workdir: /Users/chick/.Hermes/workspace/research/xianyu-hunter
Enabled toolsets: terminal,file
Deliver: origin
```
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const TASKS_FILE = path.join(ROOT, 'data', 'tasks.json');
const REPORT_DIR = path.join(ROOT, 'reports');
function parseArgs(argv) {
const args = { limit: 30 };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--task' || a === '--task-id') args.taskId = argv[++i];
else if (a === '--limit') args.limit = Number(argv[++i] || 30);
else if (a === '--out') args.out = argv[++i];
}
return args;
}
function priceNumber(value) {
const m = String(value || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/);
return m ? Number(m[0]) : null;
}
function riskFlags(item, task) {
const text = `${item.title || ''} ${item.description || ''}`.toLowerCase();
const flags = [];
const badWords = ['维修', '求购', '租', '回收', '展示机', '资源机', '监管机', '扩容', '改装', '仅拆封包装'];
for (const w of badWords) if (text.includes(w)) flags.push(`疑似${w}`);
if (/配件|零件|外壳|保护壳|贴膜/.test(text) && !/配件齐|原装配件|全套配件/.test(text)) flags.push('疑似配件/零件');
if (!item.description) flags.push('详情描述为空');
if (item.wantCount && /\d/.test(item.wantCount)) flags.push(`热度: ${item.wantCount}`);
const p = priceNumber(item.detailPrice || item.price);
if (task.config?.priceMax && p && p > task.config.priceMax) flags.push(`超预算(${p} > ${task.config.priceMax})`);
if (item.sellerRating) flags.push(item.sellerRating);
if (item.localCoarseReason) flags.push(`粗筛: ${item.localCoarseReason}`);
if (item.localFineReason) flags.push(`细筛: ${item.localFineReason}`);
return [...new Set(flags)].slice(0, 8);
}
function scoreItem(item, task) {
let score = 60;
const flags = riskFlags(item, task);
score -= flags.filter(f => /疑似|为空|超预算/.test(f)).length * 15;
if (item.description && item.description.length > 30) score += 8;
if (item.sellerRating && /好评/.test(item.sellerRating)) score += 5;
if (item.images?.length) score += Math.min(item.images.length, 5);
return Math.max(0, Math.min(100, score));
}
function renderTask(task, limit) {
const items = [...(task.products?.fineFiltered || [])]
.map(item => ({ item, score: scoreItem(item, task), flags: riskFlags(item, task) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
const lines = [];
lines.push(`# ${task.name || task.id} — Hermes 候选分析报告`);
lines.push('');
lines.push(`- 任务ID\`${task.id}\``);
lines.push(`- 阶段:${task.stage}`);
lines.push(`- 关键词:${(task.config?.queries || []).join('、') || '-'}`);
lines.push(`- 需求:${task.config?.customRequirements || '-'}`);
lines.push(`- 数量:采集 ${task.products?.raw?.length || 0} / 粗筛 ${task.products?.coarseFiltered?.length || 0} / 候选 ${task.products?.fineFiltered?.length || 0}`);
lines.push('');
if (items.length === 0) {
lines.push('暂无可分析候选。');
return lines.join('\n');
}
lines.push('## 优先查看');
lines.push('');
for (const { item, score, flags } of items) {
const price = item.detailPrice || item.price || '-';
const loc = item.sellerLocation || '-';
const href = item.href || item.url || '';
lines.push(`### ${score}分|${item.title || '(无标题)'}`);
lines.push(`- 价格:${price}`);
lines.push(`- 地区/卖家:${loc}${item.sellerName ? ` / ${item.sellerName}` : ''}`);
if (href) lines.push(`- 链接:${href}`);
if (item.description) lines.push(`- 描述:${String(item.description).replace(/\s+/g, ' ').slice(0, 220)}`);
if (flags.length) lines.push(`- 风险/备注:${flags.join('')}`);
lines.push('- 建议:人工打开链接核对成色、拆修、序列号/保修、配件与交易方式;当前工具不会自动联系卖家。');
lines.push('');
}
return lines.join('\n');
}
const args = parseArgs(process.argv.slice(2));
if (!fs.existsSync(TASKS_FILE)) {
console.error(`未找到 ${TASKS_FILE}`);
process.exit(1);
}
const tasks = JSON.parse(fs.readFileSync(TASKS_FILE, 'utf-8'));
const task = args.taskId ? tasks.find(t => t.id === args.taskId) : tasks[tasks.length - 1];
if (!task) {
console.error(`未找到任务: ${args.taskId || '(latest)'}`);
process.exit(1);
}
const report = renderTask(task, args.limit);
fs.mkdirSync(REPORT_DIR, { recursive: true });
const out = args.out || path.join(REPORT_DIR, `${task.id}-hermes-report.md`);
fs.writeFileSync(out, report, 'utf-8');
console.log(out);
@@ -0,0 +1,38 @@
import { chromium } from 'playwright';
const userDataDir = '/Users/chick/.Hermes/workspace/research/xianyu-hunter/browser-data';
const out = '/tmp/xianyu-goofish-login-entry.png';
const ctx = await chromium.launchPersistentContext(userDataDir, {
headless: false,
viewport: { width: 1280, height: 900 },
locale: 'zh-CN',
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
});
const page = ctx.pages()[0] || await ctx.newPage();
await page.goto('https://www.goofish.com/', { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(5000);
console.log('home', page.url(), await page.title().catch(()=>''));
const before = (await page.locator('body').innerText({timeout:5000}).catch(()=>''));
console.log('body-before', before.slice(0, 800).replace(/\s+/g,' '));
for (const selector of [
'text=立即登录',
'text=登录',
'[class*=login]',
'button:has-text("登录")',
'a:has-text("登录")'
]) {
const loc = page.locator(selector).first();
if (await loc.isVisible({timeout:1500}).catch(()=>false)) {
console.log('click', selector);
await loc.click({timeout:3000}).catch(e => console.log('click-error', e.message));
await page.waitForTimeout(5000);
break;
}
}
console.log('after', page.url(), await page.title().catch(()=>''));
const after = (await page.locator('body').innerText({timeout:5000}).catch(()=>''));
console.log('body-after', after.slice(0, 1200).replace(/\s+/g,' '));
await page.screenshot({ path: out, fullPage: false });
console.log(JSON.stringify({ok:true,out,url:page.url(),title:await page.title().catch(()=>''),pid:process.pid}));
await page.waitForTimeout(10 * 60 * 1000);
await ctx.close().catch(()=>{});
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import { checkLogin } from '../lib/login.mjs';
import { closeBrowser } from '../lib/browser.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const RUNTIME_DIR = path.join(ROOT, '.runtime');
const SERVER_STATE = path.join(RUNTIME_DIR, 'server.json');
const DEFAULT_PORT = Number(process.env.XIANHU_PORT || process.env.PORT || 3000);
function usage() {
console.log(`Usage:
node tools/xianyu_ops.mjs status [--port 3000]
node tools/xianyu_ops.mjs start [--port 3000]
node tools/xianyu_ops.mjs stop
node tools/xianyu_ops.mjs login
node tools/xianyu_ops.mjs create --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求>
node tools/xianyu_ops.mjs run --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> [--limit 30]
node tools/xianyu_ops.mjs report [--task <task_id>] [--limit 30]
Notes:
- Web UI is optional/backup: http://127.0.0.1:<port>
- login opens a persistent Chromium profile under ./browser-data.
- create/run do not require project AI API configuration and never contact sellers.
`);
}
function take(args, flag) {
const i = args.indexOf(flag);
if (i === -1) return undefined;
return args[i + 1];
}
function parseCommon(args) {
return {
port: Number(take(args, '--port') || DEFAULT_PORT),
limit: Number(take(args, '--limit') || 30),
};
}
function baseUrl(port) {
return `http://127.0.0.1:${port}`;
}
async function sleep(ms) {
await new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${typeof data === 'string' ? data : JSON.stringify(data)}`);
}
return data;
}
async function isServerReady(port) {
try {
await fetchJson(`${baseUrl(port)}/api/tasks`, { signal: AbortSignal.timeout(3000) });
return true;
} catch {
return false;
}
}
function readServerState() {
try { return JSON.parse(fs.readFileSync(SERVER_STATE, 'utf-8')); } catch { return null; }
}
function writeServerState(state) {
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
fs.writeFileSync(SERVER_STATE, JSON.stringify(state, null, 2), 'utf-8');
}
async function startServer(port) {
if (await isServerReady(port)) {
console.log(JSON.stringify({ ok: true, status: 'running', port, url: `${baseUrl(port)}/` }, null, 2));
return;
}
const out = fs.openSync(path.join(ROOT, 'xianyu-server.log'), 'a');
const child = spawn(process.execPath, ['server.mjs'], {
cwd: ROOT,
env: { ...process.env, PORT: String(port) },
detached: true,
stdio: ['ignore', out, out],
});
child.unref();
writeServerState({ pid: child.pid, port, startedAt: new Date().toISOString(), log: path.join(ROOT, 'xianyu-server.log') });
for (let i = 0; i < 30; i++) {
if (await isServerReady(port)) {
console.log(JSON.stringify({ ok: true, status: 'started', pid: child.pid, port, url: `${baseUrl(port)}/` }, null, 2));
return;
}
await sleep(1000);
}
throw new Error(`server did not become ready on port ${port}; see xianyu-server.log`);
}
async function stopServer() {
const state = readServerState();
if (!state?.pid) {
console.log(JSON.stringify({ ok: true, status: 'no-recorded-server' }, null, 2));
return;
}
try {
process.kill(state.pid, 'SIGTERM');
fs.unlinkSync(SERVER_STATE);
console.log(JSON.stringify({ ok: true, status: 'stopped', pid: state.pid }, null, 2));
} catch (err) {
console.log(JSON.stringify({ ok: false, status: 'stop-failed', pid: state.pid, error: err.message }, null, 2));
}
}
async function status(port) {
const ready = await isServerReady(port);
console.log(JSON.stringify({ ok: true, running: ready, port, url: `${baseUrl(port)}/`, state: readServerState() }, null, 2));
}
async function login() {
const emitLog = msg => console.log(msg);
const ok = await checkLogin(emitLog);
await closeBrowser();
console.log(JSON.stringify({ ok, loggedIn: ok }, null, 2));
if (!ok) process.exit(1);
}
function buildPayload(args) {
const name = take(args, '--name') || `闲鱼监测-${new Date().toISOString().slice(0, 10)}`;
const queriesRaw = take(args, '--queries');
const requirements = take(args, '--requirements') || take(args, '--req');
if (!queriesRaw || !requirements) {
usage();
process.exit(2);
}
return {
name,
queries: queriesRaw.split(/[,]/).map(s => s.trim()).filter(Boolean),
priceMin: take(args, '--price-min') ? Number(take(args, '--price-min')) : null,
priceMax: take(args, '--price-max') ? Number(take(args, '--price-max')) : null,
region: take(args, '--region') || '',
personalSeller: args.includes('--personal-seller'),
maxPages: take(args, '--pages') ? Number(take(args, '--pages')) : 3,
customRequirements: requirements,
coarsePrompt: '标题只做保守预筛:明显配件/维修/求购/租赁/广告才排除,拿不准保留。',
finePrompt: '详情只做采集后的保守预筛:明显冲突才排除,候选后续由 Hermes 深度分析。',
analysisMode: 'hermes',
};
}
async function createTask(args, port) {
await startServer(port);
const payload = buildPayload(args);
const task = await fetchJson(`${baseUrl(port)}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
console.log(JSON.stringify({ ok: true, task: { id: task.id, name: task.name, stage: task.stage }, url: `${baseUrl(port)}/` }, null, 2));
return task;
}
async function waitTask(taskId, port) {
for (;;) {
const task = await fetchJson(`${baseUrl(port)}/api/tasks/${taskId}`);
const counts = task.products || {};
console.log(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${task.name} ${task.stage} raw=${counts.rawCount || 0} coarse=${counts.coarseCount || 0} fine=${counts.fineCount || 0}`);
if (!task.running && ['completed', 'stopped'].includes(task.stage)) return task;
await sleep(15000);
}
}
async function report(args) {
const taskId = take(args, '--task') || take(args, '--task-id');
const limit = Number(take(args, '--limit') || 30);
const child = spawn(process.execPath, ['tools/hermes_report.mjs', ...(taskId ? ['--task', taskId] : []), '--limit', String(limit)], {
cwd: ROOT,
stdio: 'inherit',
});
await new Promise((resolve, reject) => {
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`report exited with ${code}`)));
child.on('error', reject);
});
}
async function run(args, port) {
const task = await createTask(args, port);
const finished = await waitTask(task.id, port);
await report(['--task', finished.id, '--limit', String(take(args, '--limit') || 30)]);
}
const [cmd, ...args] = process.argv.slice(2);
if (!cmd || args.includes('-h') || args.includes('--help')) {
usage();
process.exit(cmd ? 0 : 2);
}
const { port } = parseCommon(args);
try {
if (cmd === 'status') await status(port);
else if (cmd === 'start') await startServer(port);
else if (cmd === 'stop') await stopServer();
else if (cmd === 'login') await login();
else if (cmd === 'create') await createTask(args, port);
else if (cmd === 'run') await run(args, port);
else if (cmd === 'report') await report(args);
else {
usage();
process.exit(2);
}
} catch (err) {
console.error(`${err.message}`);
process.exit(1);
}
@@ -0,0 +1,36 @@
import { chromium } from 'playwright';
const userDataDir = '/Users/chick/.Hermes/workspace/research/xianyu-hunter/browser-data';
const out = '/tmp/xianyu-login-qr.png';
const ctx = await chromium.launchPersistentContext(userDataDir, {
headless: false,
viewport: { width: 1100, height: 900 },
locale: 'zh-CN',
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
});
const page = ctx.pages()[0] || await ctx.newPage();
const loginUrl = 'https://login.taobao.com/member/login.jhtml?redirectURL=' + encodeURIComponent('https://www.goofish.com/');
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(async () => {
await page.goto('https://www.goofish.com/', { waitUntil: 'domcontentloaded', timeout: 45000 });
});
await page.waitForTimeout(5000);
for (const selector of [
'text=扫码登录',
'text=二维码登录',
'text=使用二维码登录',
'.icon-qrcode',
'[class*=qrcode]',
]) {
try {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1200 }).catch(() => false)) {
await loc.click({ timeout: 2000 }).catch(() => {});
await page.waitForTimeout(2500);
break;
}
} catch {}
}
await page.screenshot({ path: out, fullPage: false });
console.log(JSON.stringify({ ok: true, out, url: page.url(), title: await page.title().catch(()=>''), pid: process.pid }));
await page.waitForTimeout(5 * 60 * 1000);
await ctx.close().catch(() => {});