- runtime-config.js: add baseUrl field to ocr type definition - default.cjs: add ocr.baseUrl default (empty string = subprocess mode) - runtime.js: add OCR_BASE_URL env var parsing - ocr.js: add HTTP mode (when OCR_BASE_URL set) alongside existing subprocess mode - resolveOcrMode() detects mode from runtimeConfig.ocr.baseUrl - callOcrViaHttp() calls FastAPI endpoints directly with payload - warmupOcrViaHttp() calls /health endpoint for HTTP mode warmup - callOcrRequest() routes between HTTP and subprocess based on mode - All 5 original exports preserved (recognizeTencentCaptcha, etc.) - Subprocess mode remains backward compatible (default when no OCR_BASE_URL)
281 lines
7.9 KiB
JavaScript
281 lines
7.9 KiB
JavaScript
// @ts-nocheck
|
||
|
||
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 recognizeImageCaptcha(payload) {
|
||
return callOcrRequest('recognize', payload)
|
||
}
|
||
|
||
export async function batchRecognizeTencentCaptcha(payload) {
|
||
return callOcrRequest('batch', payload, { timeoutMs: 180_000 })
|
||
}
|
||
|
||
export async function warmupLocalOcrWorker() {
|
||
const mode = resolveOcrMode()
|
||
if (mode === 'http') {
|
||
await warmupOcrViaHttp()
|
||
return
|
||
}
|
||
await runOcrCommand('healthcheck', { timeoutMs: 30_000 })
|
||
}
|
||
|
||
export async function closeLocalOcrWorker() {
|
||
// OCR 改为按次调用 Python 子进程或 HTTP 服务,这里不再维护常驻 worker。
|
||
}
|
||
|
||
// ── Mode Resolution ──────────────────────────────────────────────────────────
|
||
|
||
function resolveOcrMode() {
|
||
if (String(runtimeConfig.ocr.baseUrl || '').trim()) {
|
||
return 'http'
|
||
}
|
||
return 'subprocess'
|
||
}
|
||
|
||
// ── HTTP Mode ─────────────────────────────────────────────────────────────────
|
||
|
||
async function callOcrViaHttp(action, payload, { timeoutMs }) {
|
||
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
|
||
const url = `${baseUrl}/ocr/${action}`
|
||
|
||
let response
|
||
try {
|
||
response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
signal: AbortSignal.timeout(timeoutMs),
|
||
})
|
||
} catch (err) {
|
||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||
throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`)
|
||
}
|
||
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 ocr-worker 容器是否正常运行`)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`OCR 服务返回错误:HTTP ${response.status}`)
|
||
}
|
||
|
||
const result = await response.json()
|
||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||
throw new Error('OCR 服务返回了无效响应')
|
||
}
|
||
if (result.code !== 0) {
|
||
throw new Error(`OCR 服务返回错误:${result.msg || '未知错误'}`)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
async function warmupOcrViaHttp() {
|
||
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
|
||
let response
|
||
try {
|
||
response = await fetch(`${baseUrl}/health`, {
|
||
signal: AbortSignal.timeout(30_000),
|
||
})
|
||
} catch (err) {
|
||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||
throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`)
|
||
}
|
||
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 ocr-worker 容器是否正常运行`)
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`)
|
||
}
|
||
}
|
||
|
||
// ── Request Router ────────────────────────────────────────────────────────────
|
||
|
||
async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||
const mode = resolveOcrMode()
|
||
if (mode === 'http') {
|
||
return await callOcrViaHttp(action, payload, { timeoutMs })
|
||
}
|
||
// subprocess mode
|
||
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
|
||
}
|
||
|
||
// ── Subprocess Mode ───────────────────────────────────────────────────────────
|
||
|
||
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
|
||
}
|
||
}
|