@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/CHANGELOG.md CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范与 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.7.2] - 2026-11-14
6
+
7
+ ### Docs
8
+
9
+ 纯文档同步版本(功能与 0.7.1 完全一致,无需重新安装)。
10
+
11
+ - README 补全:批量处理、字幕翻译、MCP server、OCR、任意平台视频、清理、断点续跑等 0.5~0.7 新增功能的文档。
12
+ - 第三方依赖致谢补充 whisper.cpp / Tesseract / yt-dlp。
13
+ - 英文 README 补充 More commands 段。
14
+
15
+ ## [0.7.1] - 2026-11-14
16
+
17
+ ### Added
18
+
19
+ - `auto --resume`:断点续跑完整版——manifest 与计划一致且拼图齐全时**跳过抽帧**,直接复用已有拼图继续后续阶段(`--force` 强制重跑)。
20
+
21
+ ## [0.7.0] - 2026-11-14
22
+
23
+ ### Added
24
+
25
+ - `batch <清单文件|条目...> [--concurrency N]` 子命令:一次处理多个 BV/本地文件/URL(`lib/batch.mjs`)。
26
+ - 字幕翻译:外文视频的官方字幕会同时读取 B站 翻译轨(`translated` 字段,落盘 `<BV>-official-subs-translated.txt`)。
27
+ - 断点续跑基础(`lib/resume.mjs`):manifest 记录计划/阶段,重跑时提示可复用产物(`--force` 强重跑)。
28
+ - 进度辅助(`lib/progress.mjs`):分段抽帧进度、阶段计时。
29
+ - MCP server 入包(`mcp-video-server.js`,可选组件,需自装 @modelcontextprotocol/sdk / zod)+ 中英 README + CONFIG 文档。
30
+
31
+ ## [0.6.0] - 2026-11-14
32
+
33
+ ### Added
34
+
35
+ - OCR 画面文字识别:`ocr <图片路径>` 子命令(tesseract,可选);selftest 显示 OCR 可用性(`lib/ocr.mjs`)。
36
+ - 任意平台视频:`url <视频链接>` 子命令(yt-dlp,可选,下载后走本地视频流程)(`lib/ytdlp.mjs`)。
37
+ - `clean [--target cache|out|all] [--yes]` 子命令:清理缓存/产物(dry-run 默认,--yes 才删)。
38
+ - `auto --audio`:无官方字幕时自动用 whisper 转写台词,跳过烧录字幕带。
39
+
40
+ ### Changed
41
+
42
+ - selftest 增加 OCR / yt-dlp 检查项。
43
+
44
+ ## [0.5.0] - 2026-11-14
45
+
46
+ ### Added
47
+
48
+ - `transcribe <BV> [--lang zh]` 子命令:用 whisper.cpp 转写音频,文本落 `out/<BV>-audio-transcript.txt`。
49
+ - `lib/audio.mjs` 自动探测 whisper.cpp 官方 Windows 包解压结构(`whisper/Release/`)。
50
+ - 单元测试覆盖扩展到全部模块:retry / budget / i18n / frame / env / audio / bili(共 46 个用例)。
51
+ - 外部自检脚本增加「模块导入冒烟」(10 个 lib 模块全部可加载)。
52
+
5
53
  ## [0.4.2] - 2026-11-14
6
54
 
7
55
  ### Fixed
package/README.en.md CHANGED
@@ -65,6 +65,20 @@ node fisheye.mjs grab BV1xxx 69.6,75.3
65
65
  The CLI prints `SHEET: <path>` — feed that image to your multimodal model via
66
66
  `read_image`. (Optional: wrap the CLI in an MCP server if your agent environment supports it.)
67
67
 
68
+ ## More commands
69
+
70
+ ```bash
71
+ node fisheye.mjs local ./video.mp4 [--grid 4x4] # local video
72
+ node fisheye.mjs transcribe BV1xxx [--lang zh] # audio transcription (whisper.cpp)
73
+ node fisheye.mjs ocr image.png [--lang chi_sim+eng] # OCR text (tesseract)
74
+ node fisheye.mjs url "https://..." # any platform (yt-dlp)
75
+ node fisheye.mjs batch list.txt --concurrency 2 # process many videos
76
+ node fisheye.mjs clean [--target out] [--yes] # clean artifacts
77
+ ```
78
+
79
+ `auto --resume` reuses existing grids and skips frame extraction; `--force` re-runs everything.
80
+ Optional integrations: whisper.cpp (audio), tesseract (OCR), yt-dlp (any URL), and `mcp-video-server.js` (MCP tools, needs `npm i @modelcontextprotocol/sdk zod`).
81
+
68
82
  ## Docs
69
83
 
70
84
  - `docs/DESIGN.md` — design rationale
@@ -88,6 +102,9 @@ Third-party dependencies:
88
102
  |---|---|---|
89
103
  | [ffmpeg](https://ffmpeg.org/) | frame extraction, tiling, subtitle-band slicing | LGPL/GPL |
90
104
  | [numpy](https://numpy.org/) · [Pillow](https://python-pillow.org/) | subtitle-band detection (optional) | BSD / MIT-CMU |
105
+ | [whisper.cpp](https://github.com/ggml-org/whisper.cpp) | audio transcription (optional) | MIT |
106
+ | [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) | OCR (optional) | Apache-2.0 |
107
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | any-platform video download (optional) | Unlicense |
91
108
 
92
109
  ## License
93
110
 
package/README.md CHANGED
@@ -159,6 +159,15 @@ node fisheye.mjs grab BV1ziWP6qENy 82.5,145.0
159
159
  00:27 弹幕热区 —— 弹幕热区 19 条
160
160
  ```
161
161
 
162
+ ## 断点续跑
163
+
164
+ 长视频处理中断后,重新跑同样的命令会提示可复用产物:
165
+
166
+ ```bash
167
+ node fisheye.mjs auto BV1xxx --budget 20000 --resume # 跳过已抽帧阶段,复用拼图
168
+ node fisheye.mjs auto BV1xxx --budget 20000 --force # 强制全部重跑
169
+ ```
170
+
162
171
  ## 命令
163
172
 
164
173
  | 命令 | 作用 |
@@ -168,7 +177,13 @@ node fisheye.mjs grab BV1ziWP6qENy 82.5,145.0
168
177
  | `subs <BV>` | 只生成字幕带(`--count` 行数、`--band auto\|0.22`) |
169
178
  | `refine <BV>` | 挑出值得精读的时间点,**并直接截好原图**(三种模式:模板 / 决策文件 / 几何兜底) |
170
179
  | `grab <BV> <t1,t2>` | 按时间点截原分辨率单帧(手动指定) |
171
- | `selftest [--deep]` | 自检:依赖 / 凭证 / API / 缓存 / 磁盘 |
180
+ | `local <视频文件>` | 本地视频:ffmpeg 抽帧拼图(`--grid`、`--from`/`--to`) |
181
+ | `transcribe <BV>` | 音频转写(whisper.cpp,`--lang zh`) |
182
+ | `ocr <图片>` | OCR 识别图片文字(tesseract,`--lang chi_sim+eng`) |
183
+ | `url <链接>` | 任意平台视频(yt-dlp 下载后走本地流程) |
184
+ | `batch <清单文件\|条目...>` | 批量处理(`--concurrency N` 并发) |
185
+ | `clean [--target cache\|out\|all] [--yes]` | 清理缓存/产物(默认 dry-run) |
186
+ | `selftest [--deep]` | 自检:依赖 / 凭证 / API / OCR / 转写 / yt-dlp / 缓存 / 磁盘 |
172
187
 
173
188
  > `grid` / `subs` / `refine` / `grab` 都要求 `cache/<BV>.mp4` 已存在——第一次用请先跑 `auto`(它会下载)。
174
189
 
@@ -205,6 +220,123 @@ node fisheye.mjs local <视频文件> [--grid 3x3] [--from 30] [--to 90]
205
220
  不依赖 B站 登录与字幕:ffmpeg 解析时长 → 按 grid 等间隔抽帧拼图 → 输出 `SHEET: <拼图路径>`。
206
221
  把拼图路径交给模型 `read_image` 即可"看"本地视频。适合 mp4/mkv/avi/mov/webm。
207
222
 
223
+ ## 音频转写
224
+
225
+ 装了 whisper.cpp 后,可以给无字幕视频做语音转写:
226
+
227
+ ```bash
228
+ node fisheye.mjs transcribe BV1xxx [--lang zh]
229
+ ```
230
+
231
+ 自动探测顺序:`FISHEYE_WHISPER` 环境变量 → PATH → 常见安装路径(含 `~/.dsh/surfcore/whisper/Release/`)。
232
+ 模型放 `~/.dsh/surfcore/whisper/`(`ggml-*.bin`)或用 `FISHEYE_WHISPER_MODEL` 指定。
233
+ 转写文本落 `out/<BV>-audio-transcript.txt`。
234
+
235
+ ### 字幕翻译
236
+
237
+ 外文视频(英文/日文等)的官方字幕会同时读取 B站 的**中文翻译轨**(l2 机器翻译),
238
+ 转写结果旁会打印翻译行数,翻译文本落盘 `out/<BV>-official-subs-translated.txt`。
239
+ 没有翻译轨的视频不会凭空翻译(不接外部翻译 API)。
240
+
241
+ ## OCR(图片文字识别)
242
+
243
+ 装了 tesseract 后可以识别图片/画面里的文字:
244
+
245
+ ```bash
246
+ node fisheye.mjs ocr screenshot.png [--lang chi_sim+eng]
247
+ ```
248
+
249
+ 自动探测 `FISHEYE_TESSERACT` → PATH → 常见安装路径;语言包缺 `chi_sim` 时自动降级到 `eng`。
250
+ 识别结果落 `out/<图片名>.ocr.txt`。
251
+
252
+ ## 任意平台视频(yt-dlp)
253
+
254
+ 装了 yt-dlp 后可以直接给任意平台(YouTube/抖音/Twitter…)的链接:
255
+
256
+ ```bash
257
+ node fisheye.mjs url "https://www.youtube.com/watch?v=xxx"
258
+ ```
259
+
260
+ 下载(yt-dlp)→ 自动走本地视频抽帧流程。探测顺序:`FISHEYE_YTDLP` → PATH → 常见安装路径。
261
+
262
+ ## 批量处理
263
+
264
+ 一次处理多个视频(每行一个 BV / 本地路径 / URL,支持 `#` 注释):
265
+
266
+ ```bash
267
+ # 从清单文件
268
+ node fisheye.mjs batch list.txt --concurrency 2
269
+
270
+ # 直接传条目
271
+ node fisheye.mjs batch BV1xxx BV2xxx "C:\videos\a.mp4"
272
+
273
+ # 并发处理(默认 1 串行;失败不中断,最后汇总)
274
+ node fisheye.mjs batch list.txt --concurrency 3
275
+ ```
276
+
277
+ 每一项按类型自动分派:BV → auto、路径 → local、链接 → url。
278
+
279
+ ## 清理
280
+
281
+ ```bash
282
+ node fisheye.mjs clean # 预览将清理的缓存/产物(dry-run)
283
+ node fisheye.mjs clean --target out --yes # 实际删除 out/
284
+ ```
285
+
286
+ ## MCP server(可选)
287
+
288
+ `mcp-video-server.js` 把 fisheye 包装成 [MCP](https://modelcontextprotocol.io/)(Model Context Protocol)工具,让支持 MCP 的 agent 环境(DSH / Claude Code / Cursor 等)**不必自己拼命令行**就能看视频。
289
+
290
+ 这是**可选组件**:fisheye 核心保持零依赖,不装下面两个 npm 包也能正常用 `auto` / `local` 等命令。
291
+
292
+ ### 安装
293
+
294
+ ```bash
295
+ # 在 fisheye 目录(含 fisheye.mjs 的那层)里执行,会生成 node_modules/
296
+ npm i @modelcontextprotocol/sdk zod
297
+ ```
298
+
299
+ 除此之外还需要同目录的 `fisheye.mjs` + `lib/`、ffmpeg、B站 登录态(见上文「配置 B 站登录态」)。
300
+ `vwatch_deep` 的「独立会话复述」额外需要本机安装 **DeepSeek Harness 桌面版**;没有时会自动降级,直接返回抽帧规划 + 官方字幕原文。
301
+
302
+ ### 配置
303
+
304
+ 把 server 注册到 MCP 客户端(Claude Code 写进项目根的 `.mcp.json`;DSH 用同样的 `mcpServers` 结构):
305
+
306
+ ```json
307
+ {
308
+ "mcpServers": {
309
+ "fisheye": {
310
+ "command": "node",
311
+ "args": ["C:/path/to/fisheye/mcp-video-server.js"]
312
+ }
313
+ }
314
+ }
315
+ ```
316
+
317
+ Windows 路径用正斜杠或双反斜杠。若用 `FISHEYE_HOME` 挪过数据目录,在 `env` 里一并传入:
318
+
319
+ ```json
320
+ {
321
+ "mcpServers": {
322
+ "fisheye": {
323
+ "command": "node",
324
+ "args": ["C:/path/to/fisheye/mcp-video-server.js"],
325
+ "env": { "FISHEYE_HOME": "D:/video-cache" }
326
+ }
327
+ }
328
+ }
329
+ ```
330
+
331
+ ### 工具
332
+
333
+ | 工具 | 作用 | 参数 |
334
+ |---|---|---|
335
+ | `vwatch_scan` | 快速扫一眼:对开头一段抽帧拼成九宫格,**直接返回画面**(不含文字总结) | `bvid`、`grid`(`3x3` / `4x4` / `5x5`) |
336
+ | `vwatch_deep` | 深入看懂:按 token 预算抽帧 + 优先官方字幕 → 交给独立 headless 会话读完 → **只返回完整复述**(不占当前对话上下文) | `bvid`、`from`、`to`、`page`、`budget` |
337
+
338
+ > 工具名沿用旧名 `vwatch_*`,以免破坏已有的 MCP 配置。参数与 CLI 一一对应:`bvid` → BV 号,`from`/`to` → `--from`/`--to`,`page` → `--p`,`budget` → `--budget`。
339
+
208
340
  ## 它怎么工作
209
341
 
210
342
  ```
@@ -252,7 +384,7 @@ B 站的弹幕密度本身就是内容信号。实测一条 40 分钟视频:
252
384
  ## 开发
253
385
 
254
386
  ```bash
255
- node --test test/ # 单元测试(参数解析 / 弹幕分析)
387
+ node --test test/ # 单元测试(全部模块:46 个用例)
256
388
  node test-danmaku-filter.js # 弹幕过滤规则测试(51 条用例)
257
389
  node fisheye.mjs selftest --deep # 含抽帧/字幕带实测的自检
258
390
  ```
@@ -303,6 +435,9 @@ Copyright (c) 2026 Derpyu520 and qq-bridge / QSH contributors)。
303
435
  |---|---|---|
304
436
  | [ffmpeg](https://ffmpeg.org/) | 抽帧、拼图、字幕带切片 | LGPL/GPL |
305
437
  | [numpy](https://numpy.org/) · [Pillow](https://python-pillow.org/) | 字幕带位置检测(可选) | BSD / MIT-CMU |
438
+ | [whisper.cpp](https://github.com/ggml-org/whisper.cpp) | 音频转写(可选,`transcribe` / `--audio`) | MIT |
439
+ | [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) | 图片文字识别(可选,`ocr`) | Apache-2.0 |
440
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 任意平台视频下载(可选,`url`) | Unlicense |
306
441
 
307
442
  ### 思想来源
308
443
 
package/fisheye.mjs CHANGED
@@ -15,23 +15,29 @@
15
15
  // · 拼图每格顺序固定,manifest 按「从左到右、从上到下」记录时间,供 agent 对照
16
16
  // · 拼图与精读用同一种 seek,保证同一时间戳取到同一帧
17
17
  // · 所有子进程用 stdio:'ignore'(沙箱禁管道捕获),数据走临时文件
18
+ import fs from 'node:fs'
18
19
  import path from 'node:path'
19
20
  import { PKG_DIR, WS, CFG_FILE, FFMPEG, CRED } from './lib/env.mjs'
20
21
  import { parseTime, num, pageNum } from './lib/util.mjs'
21
22
  import { t } from './lib/i18n.mjs'
22
- import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal } from './lib/stages.mjs'
23
+ import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal, stageTranscribe, stageClean, stageOcr, stageUrl, stageBatch } from './lib/stages.mjs'
23
24
 
24
25
  const IS_SELFTEST = process.argv[2] === 'selftest'
25
26
 
26
27
  // local 处理本地文件,不需要 B站 登录态(但仍然需要 ffmpeg)
27
28
  const IS_LOCAL = process.argv[2] === 'local'
29
+ // clean / ocr 只动本地文件,不需要 ffmpeg 与 B站 凭证
30
+ const IS_CLEAN = process.argv[2] === 'clean'
31
+ const IS_OCR = process.argv[2] === 'ocr'
32
+ // url 需要 yt-dlp + ffmpeg,但不需要 B站 凭证
33
+ const IS_URL = process.argv[2] === 'url'
28
34
 
29
- if (!IS_SELFTEST) {
35
+ if (!IS_SELFTEST && !IS_CLEAN && !IS_OCR) {
30
36
  if (!FFMPEG) {
31
37
  console.error(t('err.ffmpeg.missing', { binDir: path.join(PKG_DIR, 'bin'), cfgFile: CFG_FILE }))
32
38
  process.exit(2)
33
39
  }
34
- if (!IS_LOCAL && !CRED) {
40
+ if (!IS_LOCAL && !IS_CLEAN && !IS_OCR && !IS_URL && !CRED) {
35
41
  console.error(t('err.cred.missing', { credPath: path.join(WS, 'credentials.json') }))
36
42
  process.exit(2)
37
43
  }
@@ -70,7 +76,68 @@ if (stage === 'local') {
70
76
  }
71
77
  process.exit(0)
72
78
  }
73
- if (!stage || (stage !== 'selftest' && !bvid)) {
79
+
80
+ if (stage === 'ocr') {
81
+ const img = argv[1]
82
+ if (!img || img.startsWith('--')) {
83
+ console.error(t('err.ocr.usage'))
84
+ process.exit(2)
85
+ }
86
+ try {
87
+ await stageOcr(img, { lang: opt('lang', null) })
88
+ } catch (error) {
89
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
90
+ process.exit(1)
91
+ }
92
+ process.exit(0)
93
+ }
94
+ if (stage === 'url') {
95
+ const link = argv[1]
96
+ if (!link || link.startsWith('--')) {
97
+ console.error(t('err.url.usage'))
98
+ process.exit(2)
99
+ }
100
+ try {
101
+ await stageUrl(link, {
102
+ grid: opt('grid', '3x3'),
103
+ from: parseTime(opt('from', null), 'from'),
104
+ to: parseTime(opt('to', null), 'to')
105
+ })
106
+ } catch (error) {
107
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
108
+ process.exit(1)
109
+ }
110
+ process.exit(0)
111
+ }
112
+
113
+
114
+ if (stage === 'batch') {
115
+ const arg = argv[1]
116
+ const listText = (arg && !arg.startsWith('--') && fs.existsSync(arg))
117
+ ? fs.readFileSync(arg, 'utf8')
118
+ : argv.slice(1).filter((a) => !a.startsWith('--')).join('\n')
119
+ if (!listText.trim()) {
120
+ console.error(t('err.batch.usage'))
121
+ process.exit(2)
122
+ }
123
+ try {
124
+ await stageBatch(listText, {
125
+ concurrency: num(opt('concurrency', 1), 'concurrency', { min: 1, max: 8 }),
126
+ budget: opt('budget', null) ? num(opt('budget'), 'budget', { min: 2000, max: 200000 }) : 20000,
127
+ grid: opt('grid', '3x3'),
128
+ audio: argv.includes('--audio'),
129
+ lang: opt('lang', null),
130
+ from: parseTime(opt('from', null), 'from'),
131
+ to: parseTime(opt('to', null), 'to')
132
+ })
133
+ } catch (error) {
134
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
135
+ process.exit(1)
136
+ }
137
+ process.exit(0)
138
+ }
139
+
140
+ if (!stage || (stage !== 'selftest' && stage !== 'clean' && stage !== 'ocr' && stage !== 'url' && stage !== 'batch' && !bvid)) {
74
141
  if (rawBvid && stage !== 'selftest') {
75
142
  console.error(`${t('err.bvFormat', { raw: rawBvid })}\n`)
76
143
  }
@@ -94,7 +161,9 @@ try {
94
161
  await stageAuto(bvid,
95
162
  num(opt('budget', 20000), 'budget', { min: 2000, max: 200000 }),
96
163
  { density: d === null ? null : num(d, 'density', { min: 0.5, max: 60 }), page, from, to,
97
- useDanmakuFrames: !argv.includes('--no-danmaku-frames') })
164
+ useDanmakuFrames: !argv.includes('--no-danmaku-frames'),
165
+ audio: argv.includes('--audio'), audioLang: opt('lang', null),
166
+ resume: argv.includes('--resume'), force: argv.includes('--force') })
98
167
  } else if (stage === 'refine') {
99
168
  await stageRefine(bvid, { template: argv.includes('--template'), decisions: opt('decisions', null) })
100
169
  } else if (stage === 'grid') {
@@ -108,6 +177,11 @@ try {
108
177
  opt('band', 'auto'),
109
178
  { page, range })
110
179
  } else if (stage === 'grab') await stageGrab(bvid, argv[2] ?? '', { page })
180
+ else if (stage === 'clean') {
181
+ // 清理缓存/产物:本地操作,不需要 BV 号
182
+ await stageClean({ target: opt('target', 'all'), yes: argv.includes('--yes') })
183
+ }
184
+ else if (stage === 'transcribe') await stageTranscribe(bvid, { page, lang: opt('lang', null) })
111
185
  else { console.error(t('err.unknownStage', { stage })); process.exit(2) }
112
186
  } catch (e) {
113
187
  console.error(t('err.fatal', { message: e.message }))
package/lib/audio.mjs CHANGED
@@ -145,6 +145,7 @@ function commonWhisperPaths(env = process.env) {
145
145
  path.join(home, '.dsh', 'surfcore', 'whisper', 'bin'),
146
146
  path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin'),
147
147
  path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin', 'Release'),
148
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'Release'),
148
149
  // 本机实际布局:ffmpeg 和 B站下载器放在 bili/bin,whisper 也可能被丢在同一处
149
150
  path.join(home, '.dsh', 'surfcore', 'bili', 'bin'),
150
151
  path.join(home, '.dsh', 'surfcore', 'bin'),
package/lib/batch.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // 批量处理核心:把多条 BV / 本地文件 / URL 交给同一个 runner 跑。
2
+ // 纯逻辑:不读文件系统、不起子进程、不碰网络、零第三方依赖。
3
+ // 文件是否存在、视频能不能解析,统一交给调用方传入的 runner 判断——
4
+ // 这样 parseBatchList 是可测的纯函数,批量流程也能在单测里跑完。
5
+ //
6
+ // 典型接法(后续 batch 子命令):
7
+ // const { items, errors } = parseBatchList(text)
8
+ // // errors 先报给用户,items 再进 runBatch
9
+ // const summary = await runBatch(items, (item) => runOne(item, opts), {
10
+ // concurrency: 2,
11
+ // onItem: (item, i) => console.log(`[${i + 1}/${items.length}] ${item.value}`)
12
+ // })
13
+ // console.log(formatBatchReport(summary))
14
+
15
+ // BV 号:BV + 10 位字母数字(与 fisheye.mjs 入口校验保持一致)
16
+ const BV_RE = /^BV[0-9A-Za-z]{10}$/
17
+ const URL_RE = /^https?:\/\//i
18
+ // Windows 盘符:C:\ / C:/ / 裸 C:
19
+ const DRIVE_RE = /^[A-Za-z]:(?:[\\/]|$)/
20
+ // 常见视频扩展名(URL 已在前面单独判定,这里只看裸文件名)
21
+ const VIDEO_EXT_RE = /\.(mp4|mkv|avi|mov|webm)$/i
22
+
23
+ // 路径形态判断(纯字符串,不看磁盘)
24
+ function looksLikeFile(s) {
25
+ if (DRIVE_RE.test(s)) return true
26
+ if (s.startsWith('\\\\') || s.startsWith('//')) return true // UNC \\server\share
27
+ if (s.startsWith('./') || s.startsWith('.\\') || s.startsWith('../') || s.startsWith('..\\')) return true
28
+ if (s.startsWith('/')) return true
29
+ if (VIDEO_EXT_RE.test(s)) return true // 裸文件名:video.mp4
30
+ if (/[\\/]/.test(s)) return true // 含路径分隔符
31
+ return false
32
+ }
33
+
34
+ function classify(value) {
35
+ if (BV_RE.test(value)) return 'bv'
36
+ if (URL_RE.test(value)) return 'url'
37
+ if (looksLikeFile(value)) return 'file'
38
+ return null
39
+ }
40
+
41
+ // 统一成「物理行」数组:字符串直接按行拆;数组每项再按行拆(一项多行也认)
42
+ function toLines(input) {
43
+ if (typeof input === 'string') return input.split(/\r?\n/)
44
+ if (!Array.isArray(input)) return null
45
+ const out = []
46
+ for (const entry of input) {
47
+ if (entry === null || entry === undefined) continue
48
+ out.push(...String(entry).split(/\r?\n/))
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * 解析批量输入。
55
+ * 每行 trim 后:空行 / `#` 开头的注释行跳过;BV 号 → bv;http(s):// → url;
56
+ * 像文件路径(盘符/UNC/相对路径/含分隔符/视频扩展名)→ file;其余进 errors。
57
+ * @param {string|string[]} input 多行文本,或每项一行的数组
58
+ * @returns {{ items: Array<{kind:'bv'|'file'|'url', value:string}>,
59
+ * errors: Array<{line:number, raw:string, reason:string}> }}
60
+ */
61
+ export function parseBatchList(input) {
62
+ const lines = toLines(input)
63
+ // 类型不对时也走 errors,不要抛异常——CLI 层还要把错误列给用户看
64
+ if (lines === null) {
65
+ return {
66
+ items: [],
67
+ errors: [{ line: 0, raw: String(input ?? ''), reason: '批量清单必须是字符串或数组' }]
68
+ }
69
+ }
70
+ const items = []
71
+ const errors = []
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const raw = lines[i]
74
+ const value = raw.trim()
75
+ if (value === '' || value.startsWith('#')) continue
76
+ const kind = classify(value)
77
+ if (kind) items.push({ kind, value })
78
+ else errors.push({ line: i + 1, raw: value, reason: '无法识别:既不是 BV 号、URL,也不是视频文件路径' })
79
+ }
80
+ return { items, errors }
81
+ }
82
+
83
+ const nowMs = () => performance.now()
84
+
85
+ function errorText(error) {
86
+ if (error instanceof Error) return error.message || error.name || '未知错误'
87
+ if (typeof error === 'string') return error
88
+ if (error === null || error === undefined) return '未知错误'
89
+ try {
90
+ const s = JSON.stringify(error)
91
+ return s === undefined ? String(error) : s
92
+ } catch {
93
+ return String(error) // 循环引用、BigInt 之类
94
+ }
95
+ }
96
+
97
+ async function runOne(item, runner) {
98
+ const started = nowMs()
99
+ try {
100
+ const result = await runner(item)
101
+ return { item, ok: true, result, ms: Math.round(nowMs() - started) }
102
+ } catch (error) {
103
+ return { item, ok: false, error: errorText(error), ms: Math.round(nowMs() - started) }
104
+ }
105
+ }
106
+
107
+ function normalizeConcurrency(value, total) {
108
+ const n = Math.floor(Number(value))
109
+ if (!Number.isFinite(n) || n < 1) return 1
110
+ return Math.min(n, total)
111
+ }
112
+
113
+ /**
114
+ * 批量执行:串行或简单并发池;单项失败只记录,不中断整体。
115
+ * 结果顺序始终与 items 一致(并发完成顺序不回写结果顺序)。
116
+ * onItem 在每项 runner 之前调用,抛错会中断整批(进度回调不该抛错)。
117
+ * @param {Array} items parseBatchList().items 或任意可迭代项
118
+ * @param {(item:any, index:number)=>Promise<any>} runner 单条处理逻辑
119
+ * @param {{concurrency?:number, onItem?:null|((item:any, index:number)=>any)}} [options]
120
+ * @returns {Promise<{results: Array<{item:any, ok:boolean, result?:any, error?:string, ms:number}>,
121
+ * total:number, ok:number, failed:number}>}
122
+ */
123
+ export async function runBatch(items, runner, { concurrency = 1, onItem = null } = {}) {
124
+ if (!Array.isArray(items)) throw new TypeError('runBatch: items 必须是数组')
125
+ if (typeof runner !== 'function') throw new TypeError('runBatch: runner 必须是函数')
126
+ if (onItem !== null && typeof onItem !== 'function') throw new TypeError('runBatch: onItem 必须是函数或 null')
127
+
128
+ const total = items.length
129
+ if (total === 0) return { results: [], total: 0, ok: 0, failed: 0 }
130
+
131
+ const limit = normalizeConcurrency(concurrency, total)
132
+ const results = new Array(total)
133
+ let next = 0
134
+
135
+ // 简单并发池:limit 个 worker 抢同一个游标,谁空谁取下一个
136
+ async function worker() {
137
+ for (;;) {
138
+ const index = next++
139
+ if (index >= total) return
140
+ const item = items[index]
141
+ if (onItem) await onItem(item, index)
142
+ results[index] = await runOne(item, runner)
143
+ }
144
+ }
145
+ await Promise.all(Array.from({ length: limit }, () => worker()))
146
+
147
+ const ok = results.reduce((n, r) => n + (r.ok ? 1 : 0), 0)
148
+ return { results, total, ok, failed: total - ok }
149
+ }
150
+
151
+ // 报告里的耗时:按 ms 展示,超过 1 秒给一位小数,超过 1 分钟给 XmYs
152
+ function formatDuration(ms) {
153
+ if (!Number.isFinite(ms) || ms <= 0) return '0ms'
154
+ if (ms < 1000) return `${Math.round(ms)}ms`
155
+ if (ms < 60000) {
156
+ const s = Math.round(ms / 100) / 10
157
+ return s >= 60 ? '1m0s' : `${s.toFixed(1)}s`
158
+ }
159
+ const m = Math.floor(ms / 60000)
160
+ const s = Math.round((ms % 60000) / 1000)
161
+ return s === 60 ? `${m + 1}m0s` : `${m}m${s}s`
162
+ }
163
+
164
+ function describeItem(item) {
165
+ if (!item || typeof item !== 'object') return String(item)
166
+ const value = item.value ?? ''
167
+ return item.kind ? `[${item.kind}] ${value}` : String(value)
168
+ }
169
+
170
+ /**
171
+ * 生成人类可读的汇总文本(不含尾随换行,方便 console.log 直接用)。
172
+ * 耗时取 results 里各条 ms 之和(并发时是累计值,不是墙钟时间)。
173
+ * @param {object} summary runBatch() 的返回值
174
+ * @returns {string}
175
+ */
176
+ export function formatBatchReport(summary) {
177
+ const results = Array.isArray(summary?.results) ? summary.results : []
178
+ const total = Number.isFinite(summary?.total) ? summary.total : results.length
179
+ const ok = Number.isFinite(summary?.ok) ? summary.ok : results.filter((r) => r?.ok).length
180
+ const failed = Number.isFinite(summary?.failed) ? summary.failed : Math.max(0, total - ok)
181
+ const spent = results.reduce((sum, r) => sum + (Number.isFinite(r?.ms) ? r.ms : 0), 0)
182
+
183
+ const lines = [`批量处理:共 ${total} 条,成功 ${ok} 条,失败 ${failed} 条,耗时合计 ${formatDuration(spent)}`]
184
+ if (failed > 0) {
185
+ lines.push('失败明细:')
186
+ const failures = results.filter((r) => r && !r.ok)
187
+ if (failures.length === 0) lines.push(' (没有明细)')
188
+ else failures.forEach((r, i) => lines.push(` ${i + 1}. ${describeItem(r.item)} —— ${r.error ?? '未知错误'}`))
189
+ }
190
+ return lines.join('\n')
191
+ }