import { spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { runtimeConfig } from '../../config/runtime.js' const DEFAULT_TIMEOUT_MS = 60_000 export async function recognizeTencentCaptcha(payload) { return callOcrRequest('recognize', payload) } export async function batchRecognizeTencentCaptcha(payload) { return callOcrRequest('batch', payload, { timeoutMs: 180_000 }) } export async function warmupLocalOcrWorker() { await runOcrCommand('healthcheck', { timeoutMs: 30_000 }) } export async function closeLocalOcrWorker() { // OCR 改为按次调用 Python 子进程,这里不再维护常驻 worker。 } async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { const response = await runOcrCommand('request', { timeoutMs, input: JSON.stringify({ action, payload: payload || {}, }), }) if (!response || typeof response !== 'object' || Array.isArray(response)) { throw new Error('本地 OCR 返回了无效响应') } return response } async function runOcrCommand(command, { timeoutMs = DEFAULT_TIMEOUT_MS, input = '' } = {}) { const projectRoot = resolveOcrProjectRoot() const workerCommand = resolveWorkerCommand(projectRoot) const env = { ...process.env, PYTHONUNBUFFERED: '1', PYTHONPATH: buildPythonPath(path.join(projectRoot, 'src')), } return await new Promise((resolve, reject) => { const child = spawn(workerCommand.command, [...workerCommand.args, command], { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], env, }) let stdout = '' let stderr = '' let settled = false let timedOut = false const timer = setTimeout(() => { timedOut = true child.kill() }, timeoutMs) child.stdout.on('data', (chunk) => { stdout += String(chunk || '') }) child.stderr.on('data', (chunk) => { stderr += String(chunk || '') }) child.on('error', (error) => { if (settled) { return } settled = true clearTimeout(timer) reject(buildWorkerError(error)) }) child.on('close', (code, signal) => { if (settled) { return } settled = true clearTimeout(timer) if (timedOut) { reject(new Error(`本地 OCR 执行超时,请检查 ${projectRoot} 的 Python 依赖是否已经准备完成`)) return } if (signal || code !== 0) { reject( new Error( buildWorkerExitMessage({ code, signal, projectRoot, stderrText: stderr.trim(), }), ), ) return } if (command === 'healthcheck') { resolve(null) return } const parsed = tryParseJson(stdout.trim()) if (!parsed || typeof parsed !== 'object') { reject(new Error(`本地 OCR 返回无法解析的结果${stderr.trim() ? `\n${stderr.trim()}` : ''}`)) return } resolve(parsed) }) if (input) { child.stdin.end(`${input}\n`, 'utf8') return } child.stdin.end() }) } function resolveOcrProjectRoot() { return String(runtimeConfig.ocr.projectRoot || '').trim() } function resolveWorkerCommand(projectRoot) { const venvPython = path.join(projectRoot, '.venv', 'bin', 'python') if (fs.existsSync(venvPython)) { return { command: venvPython, args: ['-u', '-m', 'ocr_worker.cli'], } } return { command: 'python3', args: ['-u', '-m', 'ocr_worker.cli'], } } function buildPythonPath(srcPath) { const current = String(process.env.PYTHONPATH || '').trim() return current ? `${srcPath}${path.delimiter}${current}` : srcPath } function buildWorkerError(error) { if ( error instanceof Error && (error.message.includes('spawn python3 ENOENT') || error.message.includes('spawn ') && error.message.includes('python')) ) { const projectRoot = resolveOcrProjectRoot() return new Error( `无法启动本地 OCR。请检查 Python 是否可用,并确认 OCR_PROJECT_ROOT 指向的目录存在:${projectRoot}`, ) } return new Error( `本地 OCR 启动失败: ${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 = `请检查 ${projectRoot} 的 Python 依赖是否已安装;本地源码运行请先执行 uv sync,Docker 请重建 backend 镜像` const stderrHint = stderrText ? `\n${stderrText}` : '' return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}` } function tryParseJson(text) { try { return JSON.parse(text) } catch { return null } }