#!/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 --queries [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> node tools/xianyu_ops.mjs run --name --queries [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> [--limit 30] node tools/xianyu_ops.mjs report [--task ] [--limit 30] Notes: - Web UI is optional/backup: http://127.0.0.1: - 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); }