import { spawn } from 'node:child_process' import readline from 'node:readline' import process from 'node:process' import { runtimeConfig } from '../config/runtime.js' const DEFAULT_TIMEOUT_MS = 60_000 let workerPromise = null let requestSequence = 0 const pendingRequests = new Map() let stderrBuffer = [] export async function recognizeTencentCaptcha(payload) { return callOcrWorker('recognize', payload) } export async function batchRecognizeTencentCaptcha(payload) { return callOcrWorker('batch', payload, { timeoutMs: 180_000 }) } export async function warmupLocalOcrWorker() { await ensureOcrWorker() } export async function closeLocalOcrWorker() { const worker = await workerPromise?.catch(() => null) if (!worker) { workerPromise = null return } worker.child.kill() workerPromise = null } async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { const worker = await ensureOcrWorker() const requestId = `ocr-${Date.now()}-${++requestSequence}` return await new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingRequests.delete(requestId) worker.child.kill() reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常')) }, timeoutMs) pendingRequests.set(requestId, { resolve: (value) => { clearTimeout(timer) resolve(value) }, reject: (error) => { clearTimeout(timer) reject(error) }, }) worker.child.stdin.write( `${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`, 'utf8', ) }) } async function ensureOcrWorker() { if (!workerPromise) { workerPromise = createOcrWorker().catch((error) => { workerPromise = null throw error }) } return workerPromise } function createOcrWorker() { return new Promise((resolve, reject) => { const projectRoot = resolveOcrProjectRoot() stderrBuffer = [] const child = spawn('uv', ['run', 'ocr-worker', 'worker'], { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], env: process.env, }) child.stdin.setDefaultEncoding('utf8') const stdoutReader = readline.createInterface({ input: child.stdout }) const worker = { child, stdoutReader } const readyTimer = setTimeout(() => { reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`)) child.kill() }, 15_000) let ready = false stdoutReader.on('line', (line) => { const message = tryParseJson(line) if (!message || typeof message !== 'object') { return } if (message.type === 'ready') { if (!ready) { ready = true clearTimeout(readyTimer) resolve(worker) } return } if (message.type !== 'response') { return } const requestId = String(message.id || '') const pending = pendingRequests.get(requestId) if (!pending) { return } pendingRequests.delete(requestId) pending.resolve(message.payload) }) child.stderr.on('data', (chunk) => { const text = String(chunk || '').trim() if (!text) { return } stderrBuffer.push(text) if (stderrBuffer.length > 20) { stderrBuffer = stderrBuffer.slice(-20) } }) child.on('error', (error) => { clearTimeout(readyTimer) const workerError = buildWorkerError(error) failAllPendingRequests(workerError) if (!ready) { reject(workerError) } workerPromise = null }) child.on('exit', (code, signal) => { clearTimeout(readyTimer) const workerError = new Error( buildWorkerExitMessage({ code, signal, projectRoot, stderrText: stderrBuffer.join('\n'), }), ) failAllPendingRequests(workerError) if (!ready) { reject(workerError) } workerPromise = null }) }) } function failAllPendingRequests(error) { for (const [requestId, pending] of pendingRequests.entries()) { pendingRequests.delete(requestId) pending.reject(error) } } function resolveOcrProjectRoot() { return String(runtimeConfig.ocr.projectRoot || '').trim() } function buildWorkerError(error) { if (error instanceof Error && error.message.includes('spawn uv ENOENT')) { return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里') } return new Error( `本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`, ) } function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) { const reason = signal ? `signal ${signal}` : typeof code === 'number' ? `exit code ${code}` : 'unknown reason' const installHint = `请先执行: cd ${projectRoot} && uv sync` const stderrHint = stderrText ? `\n${stderrText}` : '' return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}` } function tryParseJson(text) { try { return JSON.parse(text) } catch { return null } }