Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/tools/xianyu_ops.mjs
T

223 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}