214 lines
5.1 KiB
JavaScript
214 lines
5.1 KiB
JavaScript
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
|
|
}
|
|
}
|