223 lines
7.7 KiB
JavaScript
223 lines
7.7 KiB
JavaScript
#!/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);
|
||
}
|