@pls-compile-owo/fisheye 0.4.2 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bili.mjs CHANGED
@@ -48,6 +48,34 @@ export const keyOf = (u) => u.slice(u.lastIndexOf('/') + 1, u.lastIndexOf('.'))
48
48
 
49
49
  export const clean = (v) => String(v).replace(/[!'()*]/g, '')
50
50
 
51
+ /**
52
+ * 从 B站 subtitle.subtitles 列表里挑「主轨(原文)」和「翻译轨(中文)」。
53
+ * 外文视频(英文/日文…)的 AI 字幕除了原文轨还会带一条中文机器翻译轨(l2,lan 含 zh),
54
+ * 只取原文就成了「只转写不翻译」——所以这里把两条轨分开,交给 getOfficialSubs 分别下载。
55
+ *
56
+ * 语义:
57
+ * · 有外文轨 + 中文轨:main = 第一条外文轨(保留原话),translate = 中文轨
58
+ * · 只有中文轨:main = 中文轨,translate = null(原有行为,不产生「中文翻译中文」)
59
+ * · 只有外文轨:main = 外文轨,translate = null
60
+ * · 空 / 无有效 lan:null
61
+ * · preferZh=false:不做翻译,中文轨直接当主轨(只要中文文本的调用方)
62
+ * 中文轨优先级沿用旧逻辑:ai-zh → zh-CN → 其它 zh*(B站 AI 中文覆盖最全)。
63
+ * 纯函数,不发请求,便于单测。
64
+ */
65
+ export function pickSubtitleTracks(subs, preferZh = true) {
66
+ const list = (Array.isArray(subs) ? subs : []).filter((s) => s && typeof s.lan === 'string' && s.lan)
67
+ if (!list.length) return null
68
+ const isZh = (s) => /zh/i.test(s.lan)
69
+ const zh = list.find((s) => s.lan === 'ai-zh') ?? list.find((s) => s.lan === 'zh-CN')
70
+ ?? list.find(isZh) ?? null
71
+ const foreign = list.find((s) => !isZh(s)) ?? null
72
+ // 只要中文:中文轨当主轨,不再单列翻译
73
+ if (!preferZh) return { main: zh ?? foreign, translate: null }
74
+ // 没有外文轨(中文原声视频 / 只有单条中文字幕):保持旧行为
75
+ if (!foreign) return { main: zh, translate: null }
76
+ return { main: foreign, translate: zh }
77
+ }
78
+
51
79
  export async function getOfficialSubs(bvid, cid, range = null) {
52
80
  try {
53
81
  const nav = await (await fetchWithRetry('https://api.bilibili.com/x/web-interface/nav',
@@ -65,19 +93,25 @@ export async function getOfficialSubs(bvid, cid, range = null) {
65
93
  { headers: H }, { timeoutMs: 15000 })).json()
66
94
  const subs = p?.data?.subtitle?.subtitles ?? []
67
95
  if (!subs.length) return null
68
- const pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
69
- ?? subs.find((s) => String(s.lan).startsWith('zh')) ?? subs[0]
70
- if (!pick?.subtitle_url) return null
71
-
72
- const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
73
- const r = await fetchWithRetry(url, {
74
- headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
75
- }, { timeoutMs: 20000 })
76
- if (!r.ok) return null
77
- let body = (JSON.parse(await r.text()).body ?? [])
78
- if (!body.length) return null
79
- // 只保留所处理区间内的字幕行(--from/--to)
80
- if (range) body = body.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
96
+ const tracks = pickSubtitleTracks(subs)
97
+ if (!tracks?.main?.subtitle_url) return null
98
+
99
+ const subHeaders = { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` }
100
+ const subUrl = (u) => (String(u).startsWith('http') ? String(u) : 'https:' + String(u))
101
+ // 下载一条字幕轨并按 --from/--to 过滤。翻译轨失败只丢翻译,不能让整条字幕失败。
102
+ const fetchBody = async (u) => {
103
+ const r = await fetchWithRetry(subUrl(u), { headers: subHeaders }, { timeoutMs: 20000 })
104
+ if (!r.ok) return []
105
+ const raw = JSON.parse(await r.text()).body ?? []
106
+ if (!range) return raw
107
+ return raw.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
108
+ }
109
+ // 主轨 + 翻译轨并行下载:auth_key 分钟级过期,串行两次请求更容易踩到期
110
+ const translateTrack = tracks.translate?.subtitle_url ? tracks.translate : null
111
+ const [body, transBody] = await Promise.all([
112
+ fetchBody(tracks.main.subtitle_url),
113
+ translateTrack ? fetchBody(translateTrack.subtitle_url).catch(() => []) : Promise.resolve([]),
114
+ ])
81
115
  if (!body.length) return null
82
116
 
83
117
  fs.mkdirSync(OUT, { recursive: true })
@@ -86,6 +120,25 @@ export async function getOfficialSubs(bvid, cid, range = null) {
86
120
  fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
87
121
  fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
88
122
 
123
+ // 翻译轨:中文机器翻译(外文视频才有)。chars 用与原文相同的「去 ♪/空白」算法,
124
+ // 避免译文里的空格让字数虚高;全空的翻译轨按「没有翻译」处理。
125
+ let translated = null
126
+ if (transBody?.length) {
127
+ const tChars = transBody.reduce(
128
+ (a, l) => a + String(l.content ?? '').replace(/[♪\s]/g, '').length, 0)
129
+ if (tChars > 0) {
130
+ const file = path.join(OUT, `${bvid}-official-subs-translated.txt`)
131
+ fs.writeFileSync(file, transBody.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
132
+ translated = {
133
+ lan: translateTrack.lan,
134
+ lines: transBody.length,
135
+ chars: tChars,
136
+ txt: transBody.map((l) => String(l.content ?? '')).join('\n'),
137
+ file,
138
+ }
139
+ }
140
+ }
141
+
89
142
  // 质量判断:B 站 AI 字幕在纯音乐/音效为主的视频里会输出大量「 歌词」噪声或无意义碎片,
90
143
  // 这种字幕不能用(拿它当台词理解视频会全错),应该降级为看图
91
144
  const effChars = body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, '').length)
@@ -95,9 +148,11 @@ export async function getOfficialSubs(bvid, cid, range = null) {
95
148
  const quality = avgEff < 2 || uniqueRatio < 0.4 ? 'noisy' : 'ok'
96
149
 
97
150
  return {
98
- lan: pick.lan, lanDoc: pick.lan_doc, lines: body.length, txt, plain,
151
+ lan: tracks.main.lan, lanDoc: tracks.main.lan_doc, lines: body.length, txt, plain,
99
152
  chars, quality, avgEff: Number(avgEff.toFixed(1)), uniqueRatio: Number(uniqueRatio.toFixed(2)),
100
153
  head: body.slice(0, 5).map((l) => `[${fmt(l.from)}] ${l.content}`),
154
+ // 中文翻译轨(没有时为 null):原文 lines/txt 不变,翻译单独给,调用方自己决定怎么用
155
+ translated,
101
156
  }
102
157
  } catch (e) {
103
158
  console.warn(`[官方字幕] 获取失败(按无官方字幕处理):${String(e.message).split('\n')[0].slice(0, 60)}`)
package/lib/frame.mjs CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { execFileSync, spawnSync } from 'node:child_process'
6
- import { FFMPEG, PYTHON, WS, OUT } from './env.mjs'
6
+ import { FFMPEG, PYTHON, WS, OUT, PKG_DIR } from './env.mjs'
7
7
 
8
8
  export function detectScenes(file) {
9
9
  // 文件名带 pid + 时间戳:两个视频并发跑时不会互相覆盖
package/lib/i18n.mjs CHANGED
@@ -19,7 +19,7 @@ const MESSAGES = {
19
19
  zh: {
20
20
  // —— 用法帮助 ——
21
21
  usage: `用法:
22
- node fisheye.mjs auto <BV> [--budget 20000] [--density 6] ← 推荐:自动规划并铺网
22
+ node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] ← 推荐:自动规划并铺网(--audio 无字幕转写 / --resume 断点续跑)
23
23
  node fisheye.mjs refine <BV> --template ← 生成精读决策模板
24
24
  node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读
25
25
  node fisheye.mjs refine <BV> ← 几何兜底(零 token)
@@ -27,6 +27,11 @@ const MESSAGES = {
27
27
  node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
28
28
  node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点
29
29
  node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)
30
+ node fisheye.mjs transcribe <BV> [--lang zh] ← 音频转写(需 whisper.cpp)
31
+ node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng] ← OCR 识别图片文字(需 tesseract)
32
+ node fisheye.mjs url <视频链接> [--grid 3x3] ← 任意平台视频(需 yt-dlp)
33
+ node fisheye.mjs clean [--target cache|out|all] [--yes] ← 清理缓存/产物
34
+ node fisheye.mjs batch <清单文件|条目...> [--concurrency 2] ← 批量处理(每行一个 BV/路径/URL)
30
35
  node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)`,
31
36
 
32
37
  // —— 依赖缺失 ——
@@ -55,6 +60,9 @@ const MESSAGES = {
55
60
  'err.unknownStage': '未知阶段: {stage}',
56
61
  'err.bvFormat': '[错误] BV 号格式不对:{raw}(应为 BV + 10 位字母数字,如 BV1ziWP6qENy)',
57
62
  'err.local.usage': '用法: node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t]',
63
+ 'err.ocr.usage': '用法: node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng]',
64
+ 'err.url.usage': '用法: node fisheye.mjs url <视频链接> [--grid 3x3] [--from t] [--to t]',
65
+ 'err.batch.usage': '用法: node fisheye.mjs batch <清单文件|条目...> [--concurrency 2]',
58
66
  'err.fatal': '\n错误:{message}',
59
67
  'err.local.failed': '[错误] {message}',
60
68
 
@@ -71,7 +79,7 @@ const MESSAGES = {
71
79
 
72
80
  en: {
73
81
  usage: `Usage:
74
- node fisheye.mjs auto <BV> [--budget 20000] [--density 6] # recommended: auto-plan and build grids
82
+ node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] # recommended: auto-plan and build grids (--audio transcribe / --resume resume)
75
83
  node fisheye.mjs refine <BV> --template # generate a refine decision template
76
84
  node fisheye.mjs refine <BV> --decisions <file.json> # refine using model decisions
77
85
  node fisheye.mjs refine <BV> # geometric fallback (zero tokens)
@@ -79,6 +87,11 @@ const MESSAGES = {
79
87
  node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
80
88
  node fisheye.mjs grab <BV> <t1,t2,t3> # pick timestamps manually
81
89
  node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t] # analyze a local video (mp4/mkv/...)
90
+ node fisheye.mjs transcribe <BV> [--lang zh] # transcribe audio (requires whisper.cpp)
91
+ node fisheye.mjs ocr <image path> [--lang chi_sim+eng] # OCR text from an image (requires tesseract)
92
+ node fisheye.mjs url <video link> [--grid 3x3] # any platform video (requires yt-dlp)
93
+ node fisheye.mjs clean [--target cache|out|all] [--yes] # clean cache/out artifacts
94
+ node fisheye.mjs batch <list file|items...> [--concurrency 2] # batch process (one BV/path/URL per line)
82
95
  node fisheye.mjs selftest [--deep] # self-check (deps/credentials/API/cache/disk)`,
83
96
 
84
97
  'err.ffmpeg.missing': `ffmpeg not found.
@@ -105,6 +118,9 @@ then copy those three values. Or set BILI_CRED=<file path> to point at an existi
105
118
  'err.unknownStage': 'Unknown stage: {stage}',
106
119
  'err.bvFormat': '[error] Invalid BV id: {raw} (expected BV + 10 alphanumeric chars, e.g. BV1ziWP6qENy)',
107
120
  'err.local.usage': 'Usage: node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t]',
121
+ 'err.ocr.usage': 'Usage: node fisheye.mjs ocr <image path> [--lang chi_sim+eng]',
122
+ 'err.url.usage': 'Usage: node fisheye.mjs url <video link> [--grid 3x3] [--from t] [--to t]',
123
+ 'err.batch.usage': 'Usage: node fisheye.mjs batch <list file|items...> [--concurrency 2]',
108
124
  'err.fatal': '\nError: {message}',
109
125
  'err.local.failed': '[error] {message}',
110
126
 
package/lib/ocr.mjs ADDED
@@ -0,0 +1,386 @@
1
+ // OCR(可选):tesseract 命令行识别画面里的文字(截图标题、PPT、烧进画面的字幕条)
2
+ //
3
+ // 定位:fisheye 的信息源是「官方字幕 → 画面/字幕带 → 弹幕 → 音频」。画面里的文字
4
+ // (弹幕截图、PPT、标题条、烧进画面的字幕)模型只能"看图猜",OCR 能直接给出原文,
5
+ // 让模型对照文本而不是辨认像素。
6
+ // 它是**可选能力**:找不到 tesseract / 语言包一律返回 null,绝不让整个看片流程失败。
7
+ //
8
+ // 设计约束(和项目其他模块一致):
9
+ // · 零 npm 依赖,Node ≥18
10
+ // · 所有子进程用 spawnSync;stdout / stderr 分别重定向到临时文件(沙箱禁管道捕获),
11
+ // 分开落盘是为了不让 stderr 的 "Estimating resolution as ..." 混进正文
12
+ // · Windows 路径全部 path.join,可执行文件补 .exe
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+ import { spawnSync } from 'node:child_process'
18
+ import { PKG_DIR } from './env.mjs'
19
+
20
+ const IS_WIN = process.platform === 'win32'
21
+ const EXE = (name) => (IS_WIN ? `${name}.exe` : name)
22
+
23
+ // tesseract --version 每次探测都跑一遍太浪费,按 cmd 缓存
24
+ const versionCache = new Map()
25
+
26
+ const stripQuotes = (s) => String(s ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
27
+
28
+ function readText(file, limit = 65536) {
29
+ try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').slice(0, limit) } catch { return '' }
30
+ }
31
+
32
+ function tmpFile(tag) {
33
+ return path.join(os.tmpdir(), `fisheye-ocr-${tag}-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
34
+ }
35
+
36
+ /**
37
+ * 跑一个短命令,把 stdout / stderr 分别落到两个临时文件。
38
+ * 沙箱里不能捕获管道,所以不能用 spawnSync 的 'pipe' —— 只能 fd 重定向。
39
+ */
40
+ function capture(cmd, args, timeoutMs = 8000) {
41
+ const outFile = tmpFile('out')
42
+ const errFile = tmpFile('err')
43
+ let fdOut = null
44
+ let fdErr = null
45
+ try {
46
+ fdOut = fs.openSync(outFile, 'w')
47
+ fdErr = fs.openSync(errFile, 'w')
48
+ const r = spawnSync(cmd, args, {
49
+ stdio: ['ignore', fdOut, fdErr],
50
+ timeout: timeoutMs,
51
+ windowsHide: true,
52
+ })
53
+ return {
54
+ ok: r.status === 0,
55
+ status: r.status,
56
+ error: r.error,
57
+ out: readText(outFile),
58
+ err: readText(errFile),
59
+ }
60
+ } catch (e) {
61
+ return { ok: false, status: null, error: e, out: '', err: '' }
62
+ } finally {
63
+ if (fdOut !== null) try { fs.closeSync(fdOut) } catch {}
64
+ if (fdErr !== null) try { fs.closeSync(fdErr) } catch {}
65
+ try { fs.unlinkSync(outFile) } catch {}
66
+ try { fs.unlinkSync(errFile) } catch {}
67
+ }
68
+ }
69
+
70
+ // where.exe / which 的全部命中行(没有命中返回 [])
71
+ function whereAll(name) {
72
+ const r = capture(IS_WIN ? 'where.exe' : 'which', [name], 5000)
73
+ if (r.status !== 0) return []
74
+ return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
75
+ }
76
+
77
+ /**
78
+ * 判断一个 where 命中项是否值得 spawn。
79
+ * Windows 上 `where tesseract` 一般命中真 exe;但 PATHEXT 里还有 .cpl/.bat 之类,
80
+ * 直接 spawn 会 ENOENT 或弹控制面板,所以先按扩展名筛掉。
81
+ */
82
+ function spawnable(p) {
83
+ if (!p) return false
84
+ const ext = path.extname(p).toLowerCase()
85
+ if (IS_WIN && ['.cpl', '.dll', '.msi', '.sys', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.js'].includes(ext)) return false
86
+ const looksPath = p.includes('/') || p.includes('\\')
87
+ if (looksPath && !fs.existsSync(p)) return false
88
+ try {
89
+ if (fs.existsSync(p)) {
90
+ const st = fs.statSync(p)
91
+ if (!st.isFile() || st.size === 0) return false
92
+ }
93
+ } catch { return false }
94
+ return true
95
+ }
96
+
97
+ function isDir(p) {
98
+ try { return fs.statSync(p).isDirectory() } catch { return false }
99
+ }
100
+
101
+ /** tessdata 目录:TESSDATA_PREFIX → exe 同级 tessdata → 标准 share 布局 */
102
+ function tessdataDir(cmd, env = process.env) {
103
+ const cands = []
104
+ const explicit = stripQuotes(env.TESSDATA_PREFIX)
105
+ if (explicit) {
106
+ if (isDir(explicit)) cands.push(explicit)
107
+ // 少数分发把 TESSDATA_PREFIX 指到 "tessdata 的父目录"
108
+ if (isDir(path.join(explicit, 'tessdata'))) cands.push(path.join(explicit, 'tessdata'))
109
+ }
110
+ if (cmd && (cmd.includes('/') || cmd.includes('\\'))) {
111
+ const binDir = path.dirname(cmd)
112
+ cands.push(
113
+ path.join(binDir, 'tessdata'),
114
+ path.join(binDir, '..', 'share', 'tesseract-ocr', 'tessdata'),
115
+ path.join(binDir, '..', 'share', 'tessdata'),
116
+ path.join(binDir, '..', 'tessdata'),
117
+ )
118
+ }
119
+ for (const d of cands) if (isDir(d)) return path.resolve(d)
120
+ return null
121
+ }
122
+
123
+ /** tessdata 里的语言包(* .traineddata 的文件名,去掉扩展名) */
124
+ function listLangs(dir) {
125
+ if (!dir) return []
126
+ try {
127
+ return fs.readdirSync(dir, { withFileTypes: true })
128
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.traineddata'))
129
+ .map((e) => e.name.slice(0, -'.traineddata'.length))
130
+ .sort((a, b) => a.localeCompare(b))
131
+ } catch { return [] }
132
+ }
133
+
134
+ /** 常见安装路径(不含 PATH);顺序按需求固定:surfcore → LOCALAPPDATA → Program Files → 项目 bin */
135
+ function commonTesseractPaths(env = process.env) {
136
+ const home = os.homedir()
137
+ const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
138
+ return [
139
+ path.join(home, '.dsh', 'surfcore', 'tesseract', EXE('tesseract')),
140
+ path.join(home, '.dsh', 'surfcore', 'tesseract', 'bin', EXE('tesseract')),
141
+ path.join(localApp, 'Programs', 'Tesseract-OCR', EXE('tesseract')),
142
+ path.join(localApp, 'Tesseract-OCR', EXE('tesseract')),
143
+ 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe',
144
+ 'C:\\Program Files (x86)\\Tesseract-OCR\\tesseract.exe',
145
+ path.join(PKG_DIR, 'bin', EXE('tesseract')),
146
+ ]
147
+ }
148
+
149
+ // FISHEYE_TESSERACT 可能是 exe 路径 / 安装目录 / PATH 上的命令名
150
+ function resolveEnvCommand(raw, env, notes) {
151
+ const v = stripQuotes(raw)
152
+ if (!v) return null
153
+ if (isDir(v)) {
154
+ const p = path.join(v, EXE('tesseract'))
155
+ if (fs.existsSync(p) && spawnable(p)) return path.resolve(p)
156
+ notes.push(`FISHEYE_TESSERACT=${v} 是目录,但里面没有 ${EXE('tesseract')}`)
157
+ return null
158
+ }
159
+ if (fs.existsSync(v)) {
160
+ if (!spawnable(v)) { notes.push(`FISHEYE_TESSERACT=${v} 不是可执行文件`); return null }
161
+ return path.resolve(v)
162
+ }
163
+ if (path.isAbsolute(v) || v.includes('/') || v.includes('\\')) {
164
+ notes.push(`FISHEYE_TESSERACT 指向的文件不存在:${v}`)
165
+ return null
166
+ }
167
+ const hits = whereAll(v).filter(spawnable)
168
+ if (hits.length) return hits[0]
169
+ notes.push(`FISHEYE_TESSERACT=${v} 在 PATH 上找不到`)
170
+ return null
171
+ }
172
+
173
+ function describe(cmd, source, env) {
174
+ const abs = path.resolve(cmd)
175
+ return {
176
+ cmd: fs.existsSync(abs) ? abs : cmd,
177
+ binDir: path.dirname(fs.existsSync(abs) ? abs : cmd),
178
+ tessdata: tessdataDir(abs, env),
179
+ source,
180
+ }
181
+ }
182
+
183
+ function findTesseractDetailed({ env = process.env } = {}) {
184
+ const e = env ?? {}
185
+ const notes = []
186
+
187
+ // 1) 环境变量:显式指定优先级最高
188
+ if (e.FISHEYE_TESSERACT) {
189
+ const cmd = resolveEnvCommand(e.FISHEYE_TESSERACT, e, notes)
190
+ if (cmd) return { tesseract: describe(cmd, '环境变量 FISHEYE_TESSERACT', e), notes }
191
+ }
192
+
193
+ // 2) PATH 上所有 tesseract 命中项
194
+ for (const cmd of whereAll('tesseract').filter(spawnable)) {
195
+ return { tesseract: describe(cmd, 'PATH', e), notes }
196
+ }
197
+
198
+ // 3) 常见安装路径(用户手动解压时它通常不在 PATH 里)
199
+ for (const cmd of commonTesseractPaths(e)) {
200
+ if (!fs.existsSync(cmd) || !spawnable(cmd)) continue
201
+ return { tesseract: describe(cmd, '常见安装路径', e), notes }
202
+ }
203
+
204
+ return { tesseract: null, notes }
205
+ }
206
+
207
+ /**
208
+ * 探测可用的 tesseract。
209
+ * 探测顺序:FISHEYE_TESSERACT → PATH 上 tesseract → 常见安装路径(~/.dsh/surfcore/tesseract、
210
+ * %LOCALAPPDATA%\Programs\Tesseract-OCR、C:\Program Files\Tesseract-OCR、项目 bin/)。
211
+ * @param {{ env?: NodeJS.ProcessEnv }} [options]
212
+ * @returns {{ cmd: string, binDir: string, tessdata: string|null } | null}
213
+ */
214
+ export function findTesseract({ env } = {}) {
215
+ const r = findTesseractDetailed({ env: env ?? process.env })
216
+ if (!r.tesseract) return null
217
+ const { cmd, binDir, tessdata } = r.tesseract
218
+ return { cmd, binDir, tessdata }
219
+ }
220
+
221
+ // --version 输出形如 "tesseract v5.4.0.20240606" / "tesseract 4.1.1"
222
+ function tesseractVersion(cmd) {
223
+ const key = String(cmd).toLowerCase()
224
+ if (versionCache.has(key)) return versionCache.get(key)
225
+ const r = capture(cmd, ['--version'], 8000)
226
+ const m = `${r.out}\n${r.err}`.match(/tesseract\s+v?([0-9][\w.-]*)/i)
227
+ const v = m ? m[1] : null
228
+ versionCache.set(key, v)
229
+ return v
230
+ }
231
+
232
+ // 请求的语言里有的语言包没有时降级到交集;一个都没有返回 null
233
+ function resolveLang(requested, langs) {
234
+ // osd 只做方向检测,不是识别语言,混进 -l 会让 tesseract 输出空页
235
+ const want = String(requested ?? '').split('+').map((s) => s.trim()).filter(Boolean)
236
+ .filter((l) => l.toLowerCase() !== 'osd')
237
+ if (!want.length) return null
238
+ if (!langs.length) return want.join('+') // 语言包目录没探到,交给 tesseract 自己找
239
+ const have = new Set(langs.map((l) => l.toLowerCase()))
240
+ const kept = want.filter((l) => have.has(l.toLowerCase()))
241
+ return kept.length ? kept.join('+') : null
242
+ }
243
+
244
+ // chi_sim 会在每个汉字之间插一个空格("你 好 世 界"),合并回"你好世界";
245
+ // 只处理「汉字 + 空格 + 汉字」这一种相邻关系,不会动英文单词之间和中文与数字之间的空格
246
+ const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\u3005-\\u303f\\uff00-\\uffef'
247
+ const CJK_GAP = new RegExp(`([${CJK}])[ \\t]+(?=[${CJK}])`, 'g')
248
+
249
+ function normalizeText(raw) {
250
+ return String(raw ?? '')
251
+ .replace(/\u000c/g, '\n') // tesseract 每页结尾的 form feed
252
+ .replace(/^\uFEFF/, '')
253
+ .split(/\r?\n/)
254
+ .map((l) => l.trim())
255
+ .join('\n')
256
+ .replace(CJK_GAP, '$1')
257
+ .replace(/\n{3,}/g, '\n\n')
258
+ .trim()
259
+ }
260
+
261
+ /**
262
+ * 能力描述(给 CLI / selftest 打印用)。
263
+ * available 为 true 才表示「现在真的能做 OCR」:tesseract + 至少一个语言包。
264
+ * @param {{ env?: NodeJS.ProcessEnv, tesseract?: object }} [options]
265
+ * @returns {{ available: boolean, reason: string, summary: string, hint: string|null,
266
+ * cmd: string|null, binDir: string|null, langs: string[],
267
+ * tessdata: string|null, version: string|null, notes: string[] }}
268
+ */
269
+ export function ocrCaps(options = {}) {
270
+ const env = options.env ?? process.env
271
+ try {
272
+ const detail = options.tesseract
273
+ ? { tesseract: { ...options.tesseract, source: 'opts.tesseract' }, notes: [] }
274
+ : findTesseractDetailed({ env })
275
+ const t = detail.tesseract
276
+ const langs = t ? listLangs(t.tessdata) : []
277
+ const hasLang = langs.some((l) => l.toLowerCase() !== 'osd')
278
+ const version = t ? tesseractVersion(t.cmd) : null
279
+ const caps = {
280
+ available: false,
281
+ reason: '',
282
+ summary: '',
283
+ hint: null,
284
+ cmd: t?.cmd ?? null,
285
+ binDir: t?.binDir ?? null,
286
+ langs,
287
+ tessdata: t?.tessdata ?? null,
288
+ version,
289
+ notes: detail.notes ?? [],
290
+ }
291
+
292
+ if (!t) {
293
+ caps.reason = '未找到 tesseract 可执行文件(已试 FISHEYE_TESSERACT / PATH / 常见安装路径)'
294
+ caps.hint = '装 tesseract 到 ~/.dsh/surfcore/tesseract,或把 tesseract.exe 放进 PATH;'
295
+ + '中文识别还需要 tessdata/chi_sim.traineddata'
296
+ } else if (!hasLang) {
297
+ caps.reason = `找到 ${path.basename(t.cmd)},但 tessdata 里没有可用的语言包(只有 osd 不算)`
298
+ caps.hint = '把 eng.traineddata / chi_sim.traineddata 放进 tessdata 目录(或用 TESSDATA_PREFIX 指定)'
299
+ } else {
300
+ caps.available = true
301
+ const bits = [
302
+ `tesseract ${version ? `v${version}` : path.basename(t.cmd)}`,
303
+ `语言包 ${langs.join('/')}`,
304
+ ]
305
+ if (t.source) bits.push(t.source)
306
+ caps.reason = bits.join(',')
307
+ if (!langs.some((l) => l.toLowerCase() === 'chi_sim')) {
308
+ caps.hint = '没有 chi_sim 语言包,中文画面只能识别英文/数字;把 chi_sim.traineddata 放进 tessdata'
309
+ }
310
+ }
311
+
312
+ caps.summary = `OCR:${caps.available ? '可用' : '不可用'} —— ${caps.reason}`
313
+ return caps
314
+ } catch (e) {
315
+ return {
316
+ available: false,
317
+ reason: `能力探测异常:${String(e?.message ?? e).slice(0, 120)}`,
318
+ summary: 'OCR:不可用 —— 能力探测异常',
319
+ hint: null,
320
+ cmd: null,
321
+ binDir: null,
322
+ langs: [],
323
+ tessdata: null,
324
+ version: null,
325
+ notes: [],
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 识别一张图片里的文字(可选能力,失败不抛异常)。
332
+ *
333
+ * @param {string} imagePath 图片路径(png/jpg/bmp/tiff/webp 等,tesseract 支持的格式)
334
+ * @param {{ lang?: string, timeoutMs?: number, env?: NodeJS.ProcessEnv,
335
+ * tesseract?: object, quiet?: boolean }} [options]
336
+ * @returns {{ text: string, cmd: string, lang: string } | null}
337
+ */
338
+ export function ocrImage(imagePath, { lang = 'chi_sim+eng', timeoutMs = 30000, env = process.env, tesseract, quiet = false } = {}) {
339
+ const warn = (msg) => { if (!quiet) console.warn(`[OCR] ${msg}`) }
340
+
341
+ try {
342
+ const file = imagePath ? path.resolve(String(imagePath)) : ''
343
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
344
+ warn(`找不到图片:${imagePath ?? '(空)'}`)
345
+ return null
346
+ }
347
+
348
+ const t = tesseract ?? findTesseract({ env })
349
+ if (!t) {
350
+ warn('没有可用的 tesseract,跳过 OCR(装 tesseract 或设 FISHEYE_TESSERACT)')
351
+ return null
352
+ }
353
+
354
+ const langs = listLangs(t.tessdata)
355
+ const useLang = resolveLang(lang, langs)
356
+ if (!useLang) {
357
+ warn(`tessdata 里没有 ${lang} 对应的语言包(现有:${langs.join('/') || '无'})`)
358
+ return null
359
+ }
360
+ if (useLang !== lang) warn(`语言包不全:${lang} → 改用 ${useLang}`)
361
+
362
+ // tesseract <img> stdout [--tessdata-dir <dir>] -l <lang>
363
+ const args = [file, 'stdout']
364
+ const tessdata = t.tessdata ? path.resolve(t.tessdata) : null
365
+ if (tessdata) args.push('--tessdata-dir', tessdata)
366
+ args.push('-l', useLang)
367
+
368
+ const r = capture(t.cmd, args, timeoutMs)
369
+ if (!r.ok) {
370
+ const why = r.error?.code === 'ETIMEDOUT' ? '超时' : (r.error?.code ?? `退出码 ${r.status}`)
371
+ const detail = (r.err || r.out).trim().split(/\r?\n/)[0]?.slice(0, 160) ?? ''
372
+ warn(`tesseract 执行失败(${why})${detail ? `:${detail}` : ''}`)
373
+ return null
374
+ }
375
+
376
+ const text = normalizeText(r.out)
377
+ if (!text) {
378
+ warn('识别结果为空(画面里可能没有可读文字)')
379
+ return null
380
+ }
381
+ return { text, cmd: t.cmd, lang: useLang }
382
+ } catch (e) {
383
+ warn(`OCR 异常:${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
384
+ return null
385
+ }
386
+ }