Files
catbus/scripts/fetch-ocr-models.mjs
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

155 lines
6.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 把 ddddocr 的检测 / 识别模型和字符集放到 packages/assets-ocr/models/:从 PyPI 上固定版本的 ddddocr wheel 里取出,
// 校验 wheel 与每个文件的 sha256。本机已经装过同版本的 ddddocr(例如对拍用的 .golden/<p> 虚拟环境)时直接复制,不下载。
// 也可以用 CATBUS_DDDDOCR_WHEEL=<本地 wheel 路径> 指定离线的 wheel。
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { inflateRawSync } from 'node:zlib'
const root = fileURLToPath(new URL('../', import.meta.url))
const outDir = join(root, 'packages/assets-ocr/models')
const VERSION = '1.6.1'
const WHEEL = {
url: 'https://files.pythonhosted.org/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl',
sha256: 'c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935',
}
// 输出文件 → wheel 里的来源。字符集是 ddddocr/charsets.py 的 CHARSET_OLD(默认识别模型 common_old.onnx 用的那份),转成紧凑 JSON
const FILES = {
'common_det.onnx': { from: 'ddddocr/common_det.onnx', sha256: '6faa8ea85a8c1a634e5050c4a138fca10f30194e0d7abbe9ade1fcd423af6ed6' },
'common_old.onnx': { from: 'ddddocr/common_old.onnx', sha256: 'b8f2ad9cbc1f2e3922a6cb9459e30824e7e2467f3fb4fd61420640e34ea0bf68' },
'charset_old.json': { from: 'ddddocr/charsets.py', sha256: '2c097552127cb5476189e653c0d2ccfd6b6c87425c57f81a01b5770207c33360', convert: charsetJson },
}
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
const log = (msg) => console.error(`[fetch-ocr-models] ${msg}`)
/** charsets.py 里 `CHARSET_OLD = [...]` 这个 Python 列表字面量 → JSON。 */
function charsetJson(source) {
const text = source.toString('utf8')
let i = text.indexOf('[', text.indexOf('CHARSET_OLD'))
const list = []
for (i++; i < text.length; ) {
const ch = text[i]
if (ch === ']') break
if (ch !== "'" && ch !== '"') {
i++
continue
}
let s = ''
for (i++; text[i] !== ch; i++) {
if (text[i] !== '\\') {
s += text[i]
continue
}
const e = text[++i]
if (e === 'u' || e === 'x' || e === 'U') {
const n = { u: 4, x: 2, U: 8 }[e]
s += String.fromCodePoint(parseInt(text.slice(i + 1, i + 1 + n), 16))
i += n
} else s += { n: '\n', t: '\t', r: '\r', 0: '\0' }[e] ?? e
}
list.push(s)
i++
}
return Buffer.from(JSON.stringify(list))
}
/** 从 zip(wheel)里取出指定文件。wheel 不超过 4 GB,不需要 zip64。 */
function unzip(buf, names) {
let eocd = -1
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
if (buf.readUInt32LE(i) === 0x06054b50) {
eocd = i
break
}
}
if (eocd < 0) throw new Error('wheel 不是有效的 zip')
const out = new Map()
let off = buf.readUInt32LE(eocd + 16)
for (let n = buf.readUInt16LE(eocd + 10); n > 0; n--) {
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('zip 中央目录损坏')
const method = buf.readUInt16LE(off + 10)
const size = buf.readUInt32LE(off + 20)
const nameLen = buf.readUInt16LE(off + 28)
const local = buf.readUInt32LE(off + 42)
const name = buf.toString('utf8', off + 46, off + 46 + nameLen)
off += 46 + nameLen + buf.readUInt16LE(off + 30) + buf.readUInt16LE(off + 32)
if (!names.includes(name)) continue
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
const data = buf.subarray(start, start + size)
if (method === 0) out.set(name, Buffer.from(data))
else if (method === 8) out.set(name, inflateRawSync(data))
else throw new Error(`${name}:不支持的压缩方式 ${method}`)
}
return out
}
/** 本机已装的 ddddocr 包目录(.golden/<p> 虚拟环境,macOS / Linux 与 Windows 两种布局)。 */
function localPackages() {
const dirs = []
const golden = join(root, '.golden')
if (!existsSync(golden)) return dirs
for (const venv of readdirSync(golden)) {
const lib = join(golden, venv, 'lib')
if (existsSync(lib)) for (const py of readdirSync(lib)) dirs.push(join(lib, py, 'site-packages'))
dirs.push(join(golden, venv, 'Lib', 'site-packages'))
}
return dirs.filter((d) => existsSync(join(d, 'ddddocr', '__init__.py')))
}
function convert(file, raw) {
const data = FILES[file].convert ? FILES[file].convert(raw) : raw
return sha256(data) === FILES[file].sha256 ? data : null
}
mkdirSync(outDir, { recursive: true })
const missing = Object.keys(FILES).filter((file) => {
const dest = join(outDir, file)
if (existsSync(dest) && sha256(readFileSync(dest)) === FILES[file].sha256) {
log(`${file} 已就位`)
return false
}
return true
})
// 先找本机已装的同版本 ddddocr
for (const dir of localPackages()) {
for (const file of [...missing]) {
const src = join(dir, FILES[file].from)
const data = existsSync(src) ? convert(file, readFileSync(src)) : null
if (!data) continue
log(`从 ${dir} 复制 ${file}`)
writeFileSync(join(outDir, file), data)
missing.splice(missing.indexOf(file), 1)
}
}
if (missing.length) {
let wheel
if (process.env.CATBUS_DDDDOCR_WHEEL) {
log(`读取 ${process.env.CATBUS_DDDDOCR_WHEEL}`)
wheel = readFileSync(process.env.CATBUS_DDDDOCR_WHEEL)
} else {
log(`下载 ddddocr ${VERSION}:${WHEEL.url}`)
const res = await fetch(WHEEL.url)
if (!res.ok) throw new Error(`下载失败:HTTP ${res.status} ${WHEEL.url}`)
wheel = Buffer.from(await res.arrayBuffer())
}
if (sha256(wheel) !== WHEEL.sha256) throw new Error(`ddddocr-${VERSION} wheel 的 sha256 不匹配`)
const entries = unzip(
wheel,
missing.map((f) => FILES[f].from),
)
for (const file of missing) {
const raw = entries.get(FILES[file].from)
if (!raw) throw new Error(`wheel 里没有 ${FILES[file].from}`)
const data = convert(file, raw)
if (!data) throw new Error(`${file} 的 sha256 不匹配`)
log(`取出 ${file}`)
writeFileSync(join(outDir, file), data)
}
}