198 lines
4.9 KiB
JavaScript
198 lines
4.9 KiB
JavaScript
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
|
||
}
|
||
}
|