dsh-bili-watch 0.2.23 → 0.2.24

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/README.md CHANGED
@@ -54,7 +54,7 @@ dsh plugin --profile web remove dsh-bili-watch
54
54
  - **删除**:面板按实际路径判断——装在插件目录里的(yt-dlp、转写模型、登录凭证)可直接删;装在插件目录外的(比如复用系统 ffmpeg 或已有的 Python 环境)按钮置灰,旁边标出它实际在哪。
55
55
  - **批量**:标题行的「批量下载 / 批量清理」打开勾选清单,默认勾上所有可操作的项,删除前会再确认一次。
56
56
  - **抽帧缓存**:显示当前占用与文件数,「立即清除」一键清空;不点则会按规则自动清理——保留天数(默认 7 天)与总量上限(默认 2048 MB),超过则从最旧的删起。规则存在 `deps/cache-config.json`,插件启动和打开面板时各检查一次。
57
- - **缓存位置**:默认在系统临时目录(`tmpdir()/bili-watch`)。要换位置就设环境变量 `BILI_WATCH_CACHE`,或在 `deps/deps-config.json` 里写 `"cache": "D:\\你想要的目录"`;改完重启 DSH 生效,面板「抽帧缓存 → 位置」会显示当前实际路径。
57
+ - **缓存位置**:默认在 DSH 家目录(`$DSH_HOME/bili-watch`;`$DSH_HOME` 就是 DSH 的家目录,Windows 下通常是用户目录里的 `.dsh`)。要换位置就设环境变量 `BILI_WATCH_CACHE`,或在 `deps/deps-config.json` 里写 `"cache": "D:\你想要的目录"`;改完重启 DSH 生效,面板「抽帧缓存 → 位置」会显示当前实际路径。
58
58
  - **最近一次调用**:显示命令、时间、成败,用来判断上一次看视频是否卡住。
59
59
 
60
60
  装完第一次重启 DSH,面板才会出现。
@@ -137,7 +137,7 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
137
137
  | `search <关键词> [--days 3]` | 搜视频 |
138
138
  | `hot [--limit 10]` | 综合热门榜 |
139
139
  | `login [--check] [--poll]` | 扫码登录 |
140
- | `clean` | 清空系统临时目录下的 `bili-watch`(Windows 是 `%TEMP%\bili-watch`) |
140
+ | `clean` | 清空缓存根(默认 `$DSH_HOME/bili-watch`) |
141
141
 
142
142
  工具侧的等价入口:`bili_watch` 工具(参数 `command` + `bv`),或 `/bili` 命令。
143
143
 
@@ -157,7 +157,7 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
157
157
  `view` / `text` / `asr` / `frames` 接受非 B 站 URL(YouTube、X、直链 mp4 等),走 yt-dlp:
158
158
 
159
159
  - `yt-dlp.exe` 放进 `deps/`,或设 `BILI_WATCH_YTDLP`,或让它出现在 PATH 里。
160
- - 下载按 URL 哈希隔离在 `%TEMP%\bili-watch\`,不会串目录。
160
+ - 下载按 URL 哈希隔离在缓存根下,不会串目录。
161
161
  - `peek` / `sub` / `comments` / `danmaku` / `sheet` / `substrip` / `ocr` 只支持 B 站。
162
162
 
163
163
  ## 转写可信度
@@ -183,6 +183,24 @@ AI 字幕(需登录)
183
183
 
184
184
  降级时会说明原因,不会静默跳过。
185
185
 
186
+ ## 字幕可能是诱饵
187
+
188
+ 有些视频的标题、简介、字幕跟画面是故意错开的:字幕讲一套数据,画面却在放歌词。只看字幕就复述,会写出一段不存在的内容。
189
+
190
+ `text` 会做一次便宜的判断,命中任一条就打一行提示:
191
+
192
+ - 字幕语速不在 2–8.5 字/秒(真人口播的常见区间)
193
+ - 关键词命中不到 30%(字幕和标题/UP 名对不上)
194
+ - 字幕里自称「实验 / 钓鱼 / 测试」
195
+
196
+ 想进一步确认就加 `--verify`:它会抽 5 帧做 OCR,拿画面文字跟字幕比一遍,报「首帧写着某实验说明」或「画面与字幕几乎不重合」。
197
+
198
+ ```
199
+ node tools/look.mjs text BV1xxxxxxxxx --verify
200
+ ```
201
+
202
+ 默认不抽帧:语速偏慢在 MV、纯音乐、风景片里很常见,为了少数钓鱼视频让每条都多花十几秒下载和 OCR 不划算。提示归提示,**结论还得你自己看画面再下**。
203
+
186
204
  ## 环境变量
187
205
 
188
206
  | 变量 | 作用 |
@@ -221,3 +239,13 @@ AI 字幕(需登录)
221
239
  ## 许可
222
240
 
223
241
  MIT
242
+
243
+ ## 长文本保护(2026-10-26)
244
+
245
+ `text` / `sub` / `asr` 的正文超过 **800 字**时,终端只给开头 400 字摘要 + 一行落盘路径,全文写进缓存目录下按视频分的文件夹:`<缓存目录>/<BV>/<命令>.txt`。
246
+
247
+ - `--head N`:自定义摘要长度
248
+ - `--full`:打全文
249
+ - 工具层(`bili_watch`)与 `/bili` 命令同理,阈值 2000 字(摘要 800),超了同样落盘并回摘要
250
+
251
+ 这么做的原因:一段 14 分钟视频的字幕 4,449 字,整段塞进上下文会把会话挤爆(2026-10-26 实测两次)。
package/index.js CHANGED
@@ -11,11 +11,52 @@
11
11
  */
12
12
  import { spawn } from 'node:child_process'
13
13
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
14
- import { tmpdir } from 'node:os'
14
+ import { tmpdir, homedir } from 'node:os'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
17
  // @deepseek-ai/dsh-tools 是可选 peer:桌面版 autoInstallPeers:false,市场安装时不会自动装上。
18
18
  // 缺了只少一个 bili_watch 工具,/bili 命令、插件面板与路由照常工作,不能因此整块加载失败。
19
+ // 工具输出上限:超了就把全文落文件、只回摘要。
20
+ // 由来:2026-10-26 一段 14 分钟视频的字幕 4,449 字整段塞进上下文,把会话挤爆两次。
21
+ const TOOL_TEXT_CAP = 2000
22
+ const TOOL_TEXT_HEAD = 800
23
+
24
+ function cleanKey(s) {
25
+ // 只留字母数字与 _ . -;挡掉 .. / . / Windows 保留设备名,避免输入直接决定磁盘路径
26
+ const k = String(s || '').replace(/[^\w.-]/g, '').replace(/^\.+$/, '').slice(0, 40)
27
+ if (!k || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(k)) return '_misc'
28
+ return k
29
+ }
30
+
31
+ /** 按视频分目录:CACHE/<BV>/<cmd>.txt —— 同一个视频各命令的输出收在一处 */
32
+ function videoDir(key) {
33
+ const base = cleanKey(key)
34
+ mkdirSync(CACHE, { recursive: true })
35
+ // 同一个 BV 已经有目录(可能被 CLI 改成「BV_标题」)就用它,别建第二个兄弟目录(2026-10-26 审查 T01/T02)
36
+ try {
37
+ for (const d of readdirSync(CACHE)) {
38
+ if (d === base || d.startsWith(base + '_')) return join(CACHE, d)
39
+ }
40
+ } catch {}
41
+ const d = join(CACHE, base)
42
+ mkdirSync(d, { recursive: true })
43
+ return d
44
+ }
45
+
46
+ /** 长文本 → 落盘 + 摘要;短文本原样返回(不论长短都先落一份到缓存) */
47
+ function trimForModel(text, tag = 'out', bv = '') {
48
+ const s = String(text ?? '')
49
+ let f = ''
50
+ try {
51
+ const name = String(tag || 'out').replace(/[^\w.-]/g, '') || 'out'
52
+ f = join(videoDir(bv), `${name}.tool.txt`) // .tool 后缀:别覆盖 CLI 落的同名全文
53
+ writeFileSync(f, s, 'utf8')
54
+ } catch { f = '' }
55
+ if (!f) return s // 落盘失败:原样返回,不截断(保护是 best-effort,正确性优先)
56
+ if (s.length <= TOOL_TEXT_CAP) return s
57
+ return `${s.slice(0, TOOL_TEXT_HEAD)}\n\n…(全文 ${s.length} 字,只回了前 ${TOOL_TEXT_HEAD} 字${f ? ';全文已存:' + f : ''})\n要看完整版:用 read 工具打开上面的文件(别再跑一遍命令);或换更细的 command 分段取。`
58
+ }
59
+
19
60
  let defineTool = null
20
61
  try {
21
62
  ({ defineTool } = await import('@deepseek-ai/dsh-tools'))
@@ -60,7 +101,7 @@ const LAST_RUN = join(DEPS, 'last-run.json')
60
101
  // 缓存位置:env BILI_WATCH_CACHE > deps\deps-config.json 的 cache > 系统临时目录。发布版不含本机路径。
61
102
  // 自定义目录不可写时不让功能哑掉:回退到临时目录,并把原因挂进 /status.warnings。
62
103
  function pickCacheDir() {
63
- const want = process.env.BILI_WATCH_CACHE || depsCfg().cache || join(tmpdir(), 'bili-watch')
104
+ const want = process.env.BILI_WATCH_CACHE || depsCfg().cache || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-watch')
64
105
  try {
65
106
  mkdirSync(want, { recursive: true })
66
107
  const probe = join(want, `.write-test-${process.pid}`)
@@ -68,7 +109,7 @@ function pickCacheDir() {
68
109
  rmSync(probe, { force: true })
69
110
  return { dir: want, fallback: null }
70
111
  } catch (e) {
71
- const fb = join(tmpdir(), 'bili-watch')
112
+ const fb = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-watch')
72
113
  try { mkdirSync(fb, { recursive: true }) } catch {}
73
114
  return { dir: fb, fallback: String(e?.message || e) }
74
115
  }
@@ -665,6 +706,10 @@ function parseCommandLine(raw) {
665
706
  else extra.push('--out', out)
666
707
  } else if (t === '--ffmpeg' && tokens.length) {
667
708
  extra.push('--ffmpeg', tokens.shift())
709
+ } else if (t === '--head' && tokens.length) {
710
+ extra.push('--head', tokens.shift())
711
+ } else if (t === '--full' || t === '--verify') {
712
+ extra.push(t)
668
713
  } else if (t === '--format' && tokens.length) {
669
714
  extra.push('--format', tokens.shift())
670
715
  } else if (t === '--fresh' || t === '--ocr' || t === '--poll' || t === '--check' || t === '--fast' || t === '--scene' || t === '--mark-seen') {
@@ -696,7 +741,8 @@ async function lookup({ command, bv, extra = [], badOut = false }) {
696
741
  const r = await runLook(args)
697
742
  // login 只生成二维码,真正的登录成功由面板 poll 写;命令层不记 lastRun,避免「生成成功」被读成「已登录」
698
743
  if (command !== 'login') writeLastRun(command, r.code)
699
- const text = (r.out || r.err || '').trim() || '(没有输出)'
744
+ const body = (r.out || r.err || '').trim()
745
+ const text = body || `(${command} 没有输出,退出码 ${r.code ?? '?'};完整日志看 bili-watch 的 stderr)`
700
746
  return { ok: r.code === 0, text }
701
747
  }
702
748
 
@@ -718,7 +764,7 @@ function apply(ctx) {
718
764
  handler: async (invocation) => {
719
765
  const { command, bv, extra, badOut } = parseCommandLine(invocation?.rawInput)
720
766
  const r = await lookup({ command, bv, extra, badOut })
721
- return { kind: r.ok ? 'success' : 'error', text: r.text }
767
+ return { kind: r.ok ? 'success' : 'error', text: trimForModel(r.text, command, bv) }
722
768
  },
723
769
  })
724
770
 
@@ -841,7 +887,7 @@ function apply(ctx) {
841
887
  text: { type: 'string', description: '输出文本' },
842
888
  },
843
889
  },
844
- render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '').slice(0, 24000) }],
890
+ render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '').slice(0, 2500) }],
845
891
  },
846
892
  execute: async (args) => {
847
893
  const extra = []
@@ -864,13 +910,13 @@ function apply(ctx) {
864
910
  }
865
911
  if (args.command === 'hot') {
866
912
  const r = await lookup({ command: 'hot', bv: '', extra })
867
- return { ok: r.ok, text: r.text }
913
+ return { ok: r.ok, text: trimForModel(r.text, 'hot', '_hot') }
868
914
  }
869
915
  const target = args.command === 'search' ? String(args.keyword ?? '').trim() : String(args.bv ?? '').trim()
870
916
  if (args.command === 'search' && !target) return { ok: false, text: 'search 需要 keyword 参数(要搜什么词)' }
871
917
  if (!target) return { ok: false, text: '缺少 BV 号或分享链接' }
872
918
  const r = await lookup({ command: args.command, bv: target, extra })
873
- return { ok: r.ok, text: r.text }
919
+ return { ok: r.ok, text: trimForModel(r.text, args.command, target) }
874
920
  },
875
921
  }))
876
922
  } else {
package/package.json CHANGED
@@ -1,78 +1,78 @@
1
1
  {
2
- "name": "dsh-bili-watch",
3
- "version": "0.2.23",
4
- "private": false,
5
- "description": "把 B 站视频读成文本给 DSH agent 用:元数据、评论、弹幕、字幕、本地转写、抽帧 OCR;支持 b23.tv 短链与 av 号,抽帧缓存目录可换。只读命令零依赖。",
6
- "type": "module",
7
- "main": "index.js",
8
- "bin": {
9
- "bili-watch": "tools/look.mjs"
10
- },
11
- "exports": {
12
- ".": "./index.js",
13
- "./client": "./client.js",
14
- "./cordis.patch.yml": "./cordis.patch.yml",
15
- "./package.json": "./package.json"
16
- },
17
- "dsh": {
18
- "bundle": {
19
- "patch": "./cordis.patch.yml"
20
- },
21
- "client": {
22
- "platform": "web",
23
- "inject": [
24
- "@deepseek-ai/dsh-client-runtime",
25
- "@deepseek-ai/dsh-client-ui-settings"
26
- ]
27
- }
28
- },
29
- "files": [
30
- "index.js",
31
- "client.js",
32
- "cordis.patch.yml",
33
- "tools/",
34
- "scripts/",
35
- "deps/.gitkeep",
36
- "deps/_bili-asr.py",
37
- "auto-trigger.js",
38
- "LICENSE"
39
- ],
40
- "keywords": [
41
- "bilibili",
42
- "video",
43
- "asr",
44
- "ocr",
45
- "danmaku",
46
- "dsh",
47
- "deepseek",
48
- "subtitles"
49
- ],
50
- "author": "Sacredbluedragon",
51
- "license": "MIT",
52
- "repository": {
53
- "type": "git",
54
- "url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
55
- },
56
- "bugs": {
57
- "url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
58
- },
59
- "homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
60
- "engines": {
61
- "node": ">=22"
62
- },
63
- "dependencies": {
64
- "qrcode": "^1.5.4"
65
- },
66
- "peerDependencies": {
67
- "@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.3.0-0",
68
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.3.0-0"
69
- },
70
- "peerDependenciesMeta": {
71
- "@deepseek-ai/dsh": {
72
- "optional": true
73
- },
74
- "@deepseek-ai/dsh-tools": {
75
- "optional": true
76
- }
77
- }
2
+ "name": "dsh-bili-watch",
3
+ "version": "0.2.24",
4
+ "private": false,
5
+ "description": "把 B 站视频读成文本给 DSH agent 用:元数据、评论、弹幕、字幕、本地转写、抽帧 OCR;支持 b23.tv 短链与 av 号,抽帧缓存目录可换。只读命令零依赖。",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": {
9
+ "bili-watch": "tools/look.mjs"
10
+ },
11
+ "exports": {
12
+ ".": "./index.js",
13
+ "./client": "./client.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "platform": "web",
23
+ "inject": [
24
+ "@deepseek-ai/dsh-client-runtime",
25
+ "@deepseek-ai/dsh-client-ui-settings"
26
+ ]
27
+ }
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "client.js",
32
+ "cordis.patch.yml",
33
+ "tools/",
34
+ "scripts/",
35
+ "deps/.gitkeep",
36
+ "deps/_bili-asr.py",
37
+ "auto-trigger.js",
38
+ "LICENSE"
39
+ ],
40
+ "keywords": [
41
+ "bilibili",
42
+ "video",
43
+ "asr",
44
+ "ocr",
45
+ "danmaku",
46
+ "dsh",
47
+ "deepseek",
48
+ "subtitles"
49
+ ],
50
+ "author": "Sacredbluedragon",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
58
+ },
59
+ "homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
60
+ "engines": {
61
+ "node": "\u003e=22"
62
+ },
63
+ "dependencies": {
64
+ "qrcode": "^1.5.4"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/dsh": "\u003e=0.2.0-rc.1 \u003c0.3.0-0",
68
+ "@deepseek-ai/dsh-tools": "\u003e=0.2.0-rc.1 \u003c0.3.0-0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@deepseek-ai/dsh": {
72
+ "optional": true
73
+ },
74
+ "@deepseek-ai/dsh-tools": {
75
+ "optional": true
76
+ }
77
+ }
78
78
  }
package/tools/look.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
  * 下载 360p 抽帧;加 --ocr 顺带识别画面文字
20
20
  * node look.mjs ocr <BV> [--step 2] 抽帧 + RapidOCR 出文字(不读图)
21
21
  * node look.mjs text <BV> [--cred 凭证] 四级链:字幕 → ASR → OCR,输出最可信的一份文本
22
+ * 超过 800 字只给开头 400 字摘要,全文落 <缓存目录>/<BV>/<命令>.txt
23
+ * (--head N 自定义摘要长度,--full 打全文)
22
24
  * node look.mjs peek <BV> 先看标题/简介/热评 + 5 帧 OCR
23
25
  * node look.mjs clean 清空 %TEMP%\bili-watch
24
26
  *
@@ -32,7 +34,7 @@ import { createHash, randomBytes } from 'node:crypto'
32
34
  import { createRequire } from 'node:module'
33
35
  import { join, dirname, basename } from 'node:path'
34
36
  import * as zlib from 'node:zlib'
35
- import { tmpdir } from 'node:os'
37
+ import { tmpdir, homedir } from 'node:os'
36
38
  import { fileURLToPath } from 'node:url'
37
39
 
38
40
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -60,7 +62,9 @@ const FFMPEG_CAND = process.env.BILI_WATCH_FFMPEG || depsCfg.ffmpeg || ''
60
62
  const FFMPEG_HINT =
61
63
  '跑 scripts/fetch-deps.ps1 -Target ffmpeg(macOS/Linux:bash scripts/fetch-deps.sh 或系统包管理器装),或设 BILI_WATCH_FFMPEG,或用 --ffmpeg 传路径'
62
64
  // 缓存位置:env BILI_WATCH_CACHE > deps\deps-config.json 的 cache > 系统临时目录(与 index.js 同一口径)。
63
- const CACHE = process.env.BILI_WATCH_CACHE || depsCfg.cache || join(tmpdir(), 'bili-watch')
65
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
66
+ // 缓存根:环境变量 / 插件配置优先,兜底落 DSH_HOME。曾用 tmpdir,会跟着临时清理一起没(2026-10-26 改)
67
+ const CACHE = process.env.BILI_WATCH_CACHE || depsCfg.cache || join(DSH_HOME, 'bili-watch')
64
68
  const require = createRequire(import.meta.url)
65
69
  const CRED_FILE = join(DEPS, 'credential.json')
66
70
  const LOGIN_KEY_FILE = join(DEPS, 'login-key.json')
@@ -291,6 +295,63 @@ function speedScore(subText, durSec) {
291
295
  const ok = rate >= 2.0 && rate <= 8.5
292
296
  return { ok, rate: Math.round(rate * 10) / 10, why: ok ? '' : `${rate.toFixed(1)} 字/秒,超出 2.0–8.5 正常口播区间` }
293
297
  }
298
+ // ── 反钓鱼自查(2026-10-26 加,来源 BV19bna6NE7i 那类实验)──────────────
299
+ // 有些视频的标题/简介/字幕是故意错配的诱饵(画面放歌词、字幕讲假的日报),
300
+ // 只读字幕就会复述一套不存在的内容。默认不抽帧,只有字幕自己露出马脚才付抽帧成本。
301
+ const BAIT_WORD = /实验说明|实验\s*\d+\s*[//]\s*\d+|这是实验|钓鱼视频|钓鱼视频|测试视频|诱饵| bait\b/i
302
+
303
+ function bigramSet(s) {
304
+ const t = String(s || '').replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '')
305
+ const set = new Set()
306
+ for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
307
+ return set
308
+ }
309
+
310
+ /** 可疑判据:语速不在口播区间、字幕自称实验/钓鱼,任一命中;关键词命中率低只在语速也异常时作为补充理由。 */
311
+ function baitSignals(text, v, c) {
312
+ const reasons = []
313
+ let speedBad = false
314
+ if (c?.sp && typeof c.sp.rate === 'number' && Number.isFinite(c.sp.rate)) {
315
+ if (c.sp.rate < 2.0) { reasons.push(`语速 ${c.sp.rate} 字/秒(低于口播常见的 2–8.5)`); speedBad = true }
316
+ else if (c.sp.rate > 8.5) { reasons.push(`语速 ${c.sp.rate} 字/秒(高于口播常见的 2–8.5)`); speedBad = true }
317
+ }
318
+ if (BAIT_WORD.test(String(text || ''))) reasons.push('字幕自称「实验/钓鱼」')
319
+ // 命中率不能单独触发:ASR 常把英文读成中文谐音(DeepSeek→deep sick),
320
+ // 正常视频也会低命中(2026-10-26 实测 BV19dJQ6sE9F 命中 2/13、语速 5 字/秒)。
321
+ if (speedBad && c?.ks && c.ks.total >= 8 && c.ks.hit / c.ks.total < 0.3) {
322
+ reasons.push(`关键词只命中 ${c.ks.hit}/${c.ks.total}(字幕和标题/UP 对不上)`)
323
+ }
324
+ return { suspect: reasons.length > 0, reasons }
325
+ }
326
+
327
+ /** 可疑时抽 5 帧(复用 peek 的缓存)+ OCR,拿画面文字跟字幕对一遍。 */
328
+ async function checkBaitFrames(bv, v, subtitleText, opts = {}) {
329
+ if (!ocrReady()) return { ok: false, why: 'OCR 环境不可用,核实不了画面' }
330
+ try {
331
+ const r = await peekFrames(bv, v, { ffmpeg: opts.ffmpeg })
332
+ const lines = await peekOcrLines(r.dir)
333
+ if (!lines || !lines.length) return { ok: false, why: '画面里没 OCR 出字' }
334
+ const frameText = lines.map((x) => x.text || '').join(' ')
335
+ const first = String(lines.map((x) => String(x?.text || '').trim()).find(Boolean) || '')
336
+ const a = bigramSet(subtitleText)
337
+ const b = bigramSet(frameText)
338
+ const small = Math.min(a.size, b.size)
339
+ const shared = small ? [...a].filter((g) => b.has(g)).length : 0
340
+ const overlap = small ? shared / small : 0
341
+ return {
342
+ ok: true,
343
+ overlap,
344
+ first,
345
+ flagFirst: BAIT_WORD.test(first),
346
+ frameText: frameText.slice(0, 400),
347
+ dir: r.dir,
348
+ cached: !!r.cached,
349
+ }
350
+ } catch (e) {
351
+ return { ok: false, why: `抽帧/OCR 失败:${String(e?.message ?? e).slice(0, 160)}` }
352
+ }
353
+ }
354
+
294
355
  function credibility(v, text) {
295
356
  const sp = speedScore(text, v.dur)
296
357
  const ks = keywordScore(text, extractKeywords(v.title, v.owner))
@@ -472,6 +533,8 @@ async function cmdPeek(bv, opts = {}) {
472
533
  console.log(`视频: ${v.title}`)
473
534
  console.log(`UP: ${v.owner} 时长 ${fmtSec(v.dur)} 播放 ${v.view ?? '?'} 评论 ${v.reply ?? '?'}${pTag}`)
474
535
  if (v.desc) console.log(`简介: ${v.desc.replace(/\s+/g, ' ').slice(0, 260)}`)
536
+ // 目录名带标题(view 通常最先跑,正好把目录定下来)
537
+ try { writeMeta(bv, { title: v.title, up: v.owner, dur: v.dur, aid: v.aid }, nameVideoDir(bv, v.title)) } catch (e) { console.error("[meta-debug]", e.message) }
475
538
  const c = await topComments(v.aid, 5)
476
539
  if (c.ok && c.list.length) {
477
540
  console.log('\n热评:')
@@ -508,8 +571,8 @@ async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page
508
571
  const useStep = step > 0 ? step : autoStep(v.dur)
509
572
  const dirTag = scene ? `scene-${useStep}s` : `${useStep}s`
510
573
  const dir = outDir || join(CACHE, 'frames', `${bv}-p${v.p}-${dirTag}`)
511
- // --out 指向用户目录时绝不整目录递归删:只覆盖本工具产物(下面 :505/:506 会重下 mp4、清 jpg)
512
- if (fresh && !outDir && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
574
+ // --out 指向用户目录时绝不整目录递归删:只覆盖本工具产物(下面会重下 mp4、清 jpg)
575
+ // --fresh 的清理**必须等拿到锁之后**:锁外删会把别人刚写的 .lock 一起删掉,两个进程都以为持锁(2026-10-26 审查 T24)
513
576
  mkdirSync(dir, { recursive: true })
514
577
  const mp4 = join(dir, `${bv}.mp4`)
515
578
  const jpgs = () => readdirSync(dir).filter((f) => f.endsWith('.jpg'))
@@ -526,6 +589,12 @@ async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page
526
589
  throw new Error('同目录有另一个实例在抽帧,等不到结果')
527
590
  }
528
591
  try {
592
+ if (fresh && !outDir) {
593
+ for (const e of readdirSync(dir)) {
594
+ if (e === '.lock') continue // 锁留着,只清产物
595
+ try { rmSync(join(dir, e), { recursive: true, force: true }) } catch {}
596
+ }
597
+ }
529
598
  const p = await jget(
530
599
  `https://api.bilibili.com/x/player/playurl?avid=${v.aid}&cid=${v.cid}&qn=16&fnval=1&platform=html5&fourk=0`,
531
600
  )
@@ -745,6 +814,8 @@ async function cmdView(bv, p = 1) {
745
814
  console.log(`UP: ${v.owner} (mid ${v.ownerMid}) 时长 ${fmtSec(v.dur)} 播放 ${v.view ?? '?'} 评论 ${v.reply ?? '?'}${pTag}`)
746
815
  console.log(`aid=${v.aid} cid=${v.cid}`)
747
816
  if (v.desc) console.log(`简介: ${v.desc.replace(/\s+/g, ' ').slice(0, 300)}`)
817
+ // 目录名带标题(view 常是第一个跑的,正好把目录定下来)
818
+ try { writeMeta(bv, { title: v.title, up: v.owner, dur: v.dur, aid: v.aid }, nameVideoDir(bv, v.title)) } catch (e) { console.error('[meta-debug]', e.message) }
748
819
  const c = await topComments(v.aid, 5)
749
820
  if (c.ok && c.list.length) {
750
821
  console.log('\n热评:')
@@ -913,7 +984,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
913
984
  if (out) {
914
985
  writeFileSync(out, text, 'utf8')
915
986
  say(`✓ 已写入 ${out}(${usable} 条,官方时间戳)`)
916
- } else console.log(text)
987
+ } else printText(text, { ...opts, dataOnly: true }, bv, 'sub-srt')
917
988
  return
918
989
  }
919
990
  if (format === 'txt') {
@@ -921,7 +992,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
921
992
  if (out) {
922
993
  writeFileSync(out, r.text, 'utf8')
923
994
  say(`✓ 已写入 ${out}(${r.count} 行,纯文本)`)
924
- } else console.log(r.text)
995
+ } else printText(r.text, { ...opts, dataOnly: true }, bv, 'sub-txt')
925
996
  return
926
997
  }
927
998
  if (format === 'md') {
@@ -929,7 +1000,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
929
1000
  if (out) {
930
1001
  writeFileSync(out, r.text, 'utf8')
931
1002
  say(`✓ 已写入 ${out}(${r.count} 条,时间戳可跳回原片)`)
932
- } else console.log(r.text)
1003
+ } else printText(r.text, { ...opts, dataOnly: true }, bv, 'sub-md')
933
1004
  return
934
1005
  }
935
1006
  const payload = {
@@ -942,7 +1013,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
942
1013
  if (out) {
943
1014
  writeFileSync(out, text, 'utf8')
944
1015
  say(`✓ 已写入 ${out}(${payload.cues.length} 条)`)
945
- } else console.log(text)
1016
+ } else printText(text, { ...opts, dataOnly: true }, bv, 'sub-json')
946
1017
  return
947
1018
  }
948
1019
  const c = credibility(v, s.text)
@@ -954,7 +1025,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
954
1025
  say(
955
1026
  ` 提示 · 关键词重合 ${c.ks.hit}/${c.ks.total}${c.ks.hits.length ? `(${c.ks.hits.join('、')})` : ''}${c.ks.hit === 0 ? '(零命中,可能标题与主题词不同)' : ''}`,
956
1027
  )
957
- console.log(`\n字幕全文:\n${s.text}`)
1028
+ printText(s.text, opts, bv, 'sub')
958
1029
  }
959
1030
 
960
1031
  async function cmdComments(bv, ps = 10, opts = {}) {
@@ -1204,7 +1275,7 @@ async function cmdAsr(bv, p = 1, ffmpeg = '') {
1204
1275
  }
1205
1276
  const vf = r.verify
1206
1277
  const tag = vf ? (vf.suspect ? '⚠ 疑似幻觉' : '✓ 可信') : ''
1207
- console.log(`\n转写 ${r.chars} 字${tag ? `(${tag})` : ''}:\n${r.text}`)
1278
+ printText(r.text, { head: opt('head'), full: flag('full') }, bv, 'asr')
1208
1279
  if (vf?.suspect) {
1209
1280
  console.log(`\n⚠ 转写可信度存疑:${vf.reasons.join(';')}`)
1210
1281
  console.log(' 可能是 ASR 在没人声时填的模板句,别当台词用;要确认先抽帧看画面或跑 OCR。')
@@ -1233,7 +1304,7 @@ async function cmdFrames(bv, opts = {}) {
1233
1304
  }
1234
1305
  console.log('\nOCR 识别画面文字…')
1235
1306
  const text = await runOcr(r.dir)
1236
- console.log(text || '(画面里没识别到文字)')
1307
+ printText(text || '(画面里没识别到文字)', opts, bv, 'ocr-frames')
1237
1308
  }
1238
1309
  }
1239
1310
 
@@ -1247,7 +1318,7 @@ async function cmdOcr(bv, opts = {}) {
1247
1318
  console.log(`抽帧目录: ${r.dir}${r.cached ? '(复用)' : ''}`)
1248
1319
  console.log('OCR 识别画面文字…\n')
1249
1320
  const text = await runOcr(r.dir)
1250
- console.log(text || '(画面里没识别到文字)')
1321
+ printText(text || '(画面里没识别到文字)', opts, bv, 'ocr')
1251
1322
  }
1252
1323
 
1253
1324
  // ── 拼图:N 帧拼一张,比逐帧读省好几倍 ──
@@ -1456,6 +1527,111 @@ function markSeen(bv, title) {
1456
1527
  }
1457
1528
 
1458
1529
  /** 四级链:字幕 → ASR → 抽帧 OCR。输出最可信的一份文本。 */
1530
+ const TEXT_LIMIT = 800 // 超过这个字数就不往终端打,落盘 + 给路径(上下文保护)
1531
+
1532
+ /**
1533
+ * 打印或落盘:
1534
+ * ≤800 字直接打全文;超过就只给摘要(开头 400 字)+总字数+落盘路径。
1535
+ * --head N 指定摘要长度;--full 强制打全文。
1536
+ * 全文总是落盘到 CACHE/<BV>/<kind>.txt,深读直接 read 那个文件。
1537
+ */
1538
+ function cleanKey(s) {
1539
+ // 只留字母数字与 _ . -;挡掉 .. / . / Windows 保留设备名,避免输入直接决定磁盘路径
1540
+ const k = String(s || '').replace(/[^\w.-]/g, '').replace(/^\.+$/, '').slice(0, 40)
1541
+ if (!k || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(k)) return '_misc'
1542
+ return k
1543
+ }
1544
+
1545
+ /** 按视频分目录:CACHE/<BV>/<kind>.txt —— 同一个视频识别到的各层都收在同一个文件夹里 */
1546
+ function videoDir(bv) {
1547
+ const base = cleanKey(bv)
1548
+ mkdirSync(CACHE, { recursive: true })
1549
+ // 同一个 BV 已经有目录(可能带标题后缀)就用它,别建第二个
1550
+ try {
1551
+ for (const d of readdirSync(CACHE)) {
1552
+ if (d === base || d.startsWith(base + '_')) return join(CACHE, d)
1553
+ }
1554
+ } catch {}
1555
+ const d = join(CACHE, base)
1556
+ mkdirSync(d, { recursive: true })
1557
+ return d
1558
+ }
1559
+
1560
+ /** 拿到标题后把目录改成「BV_标题」,人一眼能认;改名失败就用原目录 */
1561
+ /** 目录名用的标题片段:保留中文与字母数字,只去掉路径非法字符(cleanKey 会把中文删光,不能复用) */
1562
+ function slugTitle(s) {
1563
+ return String(s || '')
1564
+ .replace(/[\\/:*?"<>|]/g, '')
1565
+ .replace(/[\u0000-\u001f]/g, '')
1566
+ .replace(/\s+/g, ' ')
1567
+ .trim()
1568
+ .slice(0, 20)
1569
+ }
1570
+
1571
+ function nameVideoDir(bv, title) {
1572
+ const dir = videoDir(bv)
1573
+ const base = cleanKey(bv)
1574
+ if (!title || basename(dir) !== base) return dir
1575
+ const slug = slugTitle(title)
1576
+ if (!slug) return dir
1577
+ const nice = join(dirname(dir), `${base}_${slug}`)
1578
+ try { renameSync(dir, nice); return nice } catch { return dir }
1579
+ }
1580
+
1581
+ /** 每个视频目录一份 meta.json:是哪条视频、什么时候抓的、抓了哪些文件各多少字节 */
1582
+ function writeMeta(bv, extra = {}, dirHint = '') {
1583
+ const dir = dirHint || videoDir(bv)
1584
+ const p = join(dir, 'meta.json')
1585
+ let meta = {}
1586
+ try { meta = JSON.parse(readFileSync(p, 'utf8')) } catch {}
1587
+ Object.assign(meta, { bv, updatedAt: new Date().toISOString() }, extra)
1588
+ if (!meta.createdAt) meta.createdAt = meta.updatedAt
1589
+ try {
1590
+ meta.files = readdirSync(dir)
1591
+ .filter((f) => f !== 'meta.json')
1592
+ .map((f) => ({ name: f, bytes: statSync(join(dir, f)).size }))
1593
+ writeFileSync(p, JSON.stringify(meta, null, 2) + '\n', 'utf8')
1594
+ } catch {}
1595
+ return dir
1596
+ }
1597
+
1598
+ function printText(text, opts, bv, kind = 'text') {
1599
+ const full = String(text || '').trim()
1600
+ if (!full) return
1601
+ // 数据模式(--format json/srt/md/txt 且没给 --out):stdout 只许有数据,不截断、不落盘、不打提示
1602
+ if (opts && opts.dataOnly) { process.stdout.write(full + (full.endsWith('\n') ? '' : '\n')); return }
1603
+ let file = ''
1604
+ try {
1605
+ file = join(videoDir(bv), `${kind}.txt`)
1606
+ writeFileSync(file, full, 'utf8')
1607
+ } catch { file = '' }
1608
+ if (file) { try { writeMeta(bv, { [`${kind}Chars`]: full.length }) } catch {} }
1609
+ if (!file) {
1610
+ // 缓存目录不可写:宁可变长也不丢内容
1611
+ console.log(`\n${full}`)
1612
+ console.error('⚠ 全文落盘失败(缓存目录不可写),已直接打印全文;检查 BILI_WATCH_CACHE 或 deps 里的 cache')
1613
+ return
1614
+ }
1615
+ const head = Number(opts.head) > 0 ? Number(opts.head) : 0
1616
+ if (opts.full) {
1617
+ console.log(`\n${full}`)
1618
+ if (file) console.log(`[text] 全文 ${full.length} 字(--full)`)
1619
+ return
1620
+ }
1621
+ if (head) {
1622
+ console.log(`\n${full.slice(0, head)}`)
1623
+ console.log(`\n[text] 全文 ${full.length} 字,这里给前 ${head} 字;全文:${file || '(落盘失败)'}`)
1624
+ return
1625
+ }
1626
+ if (full.length <= TEXT_LIMIT) { console.log(`\n${full}`); return }
1627
+ const brief = 400
1628
+ console.log(`\n${full.slice(0, brief)}`)
1629
+ console.log(`\n…(全文 ${full.length} 字,这里只给开头 ${brief} 字)`)
1630
+ if (file) console.log(`全文已存:${file}`)
1631
+ console.log('要看更多:--head 1200 给前 1200 字;--full 打全文')
1632
+ if (file) console.log(`模型/agent:直接 read ${file} 拿全文,别再跑一遍命令`)
1633
+ }
1634
+
1459
1635
  async function cmdText(bv, credPath, opts = {}) {
1460
1636
  QUIET = opts.format === 'json' || opts.format === 'md'
1461
1637
  const t0 = Date.now()
@@ -1481,6 +1657,26 @@ async function cmdText(bv, credPath, opts = {}) {
1481
1657
  }
1482
1658
  if (s.ok) {
1483
1659
  const c = credibility(v, s.text)
1660
+ const bait = baitSignals(s.text, v, c)
1661
+ let check = null
1662
+ if (bait.suspect) {
1663
+ // 默认只提示、不抽帧:语速异常在 MV / 音乐 / 风景片里很常见,
1664
+ // 自动下载整片核对等于给常态任务加十几秒成本。要看画面加 --verify。
1665
+ say(`[track] ⚠ 字幕可疑:${bait.reasons.join(';')}${opts.verify ? '' : '(要看画面加 --verify)'}`)
1666
+ if (opts.verify) {
1667
+ say('[track] 抽帧核对画面…')
1668
+ check = await checkBaitFrames(bv, v, s.text, opts)
1669
+ if (!check.ok) {
1670
+ say(`[track] ⚠ 画面核对做不了:${check.why}(字幕仍给,但请自己看画面)`)
1671
+ } else if (check.flagFirst) {
1672
+ say(`[track] ⚠⚠ 首帧写着「${check.first.slice(0, 60)}」——这是实验/钓鱼片,字幕和简介都可能是饵`)
1673
+ } else if (check.overlap < 0.05) {
1674
+ say(`[track] ⚠⚠ 画面文字跟字幕几乎不重合(重合度 ${(check.overlap * 100).toFixed(1)}%,首帧「${check.first.slice(0, 50)}」)`)
1675
+ } else {
1676
+ say(`[track] 画面核对:重合度 ${(check.overlap * 100).toFixed(1)}%,未见诱饵迹象`)
1677
+ }
1678
+ }
1679
+ }
1484
1680
  // 信道优先:官方字幕能拿到就是可信内容,语速/关键词只作提示,不拦
1485
1681
  // 口径差异(有意):自动化选片链路会把「语速/关键词不合格」当硬拦,
1486
1682
  // 这里是人看的排查通道,取到就用、质量交给调用方判断。同一判据两种策略,别当 bug 报。
@@ -1491,12 +1687,13 @@ async function cmdText(bv, credPath, opts = {}) {
1491
1687
  timeline: { exact: true, source: 'official-subtitle' },
1492
1688
  cues: (s.cues || []).map((c) => ({ from: Number(c?.from ?? 0), to: Number(c?.to ?? 0), content: String(c?.content ?? '') })),
1493
1689
  text: s.text,
1690
+ warnings: check ? { suspect: bait.suspect, reasons: bait.reasons, ...check } : undefined,
1494
1691
  })) {
1495
1692
  done()
1496
1693
  return
1497
1694
  }
1498
1695
  say(`[track] ${via}: ok ${s.text.length} 字(语速 ${c.sp.rate ?? '?'} 字/秒,关键词 ${c.ks.hit}/${c.ks.total})`)
1499
- console.log(`\n${s.text}`)
1696
+ printText(s.text, opts, bv)
1500
1697
  done()
1501
1698
  return
1502
1699
  }
@@ -1524,7 +1721,7 @@ async function cmdText(bv, credPath, opts = {}) {
1524
1721
  return
1525
1722
  }
1526
1723
  say(`[track] 转写: ok ${a.chars} 字(${rate.toFixed(1)} 字/秒${a.cached ? ',缓存命中' : ''};机器转写,专有名词可能有错)`)
1527
- console.log(`\n${a.text}`)
1724
+ printText(a.text, opts, bv)
1528
1725
  done()
1529
1726
  return
1530
1727
  }
@@ -1554,7 +1751,7 @@ async function cmdText(bv, credPath, opts = {}) {
1554
1751
  return
1555
1752
  }
1556
1753
  say(`[track] 画面 OCR: ok(${r.dir};这是画面上的字,不是口播内容)`)
1557
- console.log(`\n${text}`)
1754
+ printText(text, opts, bv)
1558
1755
  done()
1559
1756
  return
1560
1757
  }
@@ -1579,7 +1776,7 @@ async function cmdText(bv, credPath, opts = {}) {
1579
1776
  const argv = process.argv.slice(2)
1580
1777
  const cmd = argv[0]
1581
1778
  // 带值的选项:解析位置参数时必须跳过它们的值,否则 `hot --limit 2` 会把 "2" 当成 BV
1582
- const VALUE_OPTS = new Set(['cred', 'days', 'ffmpeg', 'grid', 'key', 'limit', 'order', 'out', 'p', 'step', 'format'])
1779
+ const VALUE_OPTS = new Set(['cred', 'days', 'ffmpeg', 'grid', 'key', 'limit', 'order', 'out', 'p', 'step', 'format', 'head'])
1583
1780
  function positional(list) {
1584
1781
  const out = []
1585
1782
  for (let i = 0; i < list.length; i++) {
@@ -1941,8 +2138,7 @@ async function runGeneric(cmd, src, opts = {}) {
1941
2138
  const r = await transcribeLocal(dl.path, tag, { timeoutMs: cmd === 'text' ? 180_000 : 900_000 })
1942
2139
  if (!r.ok) { console.error(`✗ ${r.why}`); process.exit(1) }
1943
2140
  console.log(`\n转写 ${r.chars} 字:${r.cached ? '(缓存命中)' : ''}`)
1944
- if (cmd === 'text') console.log(`\n${r.text.slice(0, 6000)}`)
1945
- else console.log(`\n${r.text}`)
2141
+ printText(r.text, {}, tag, cmd === 'text' ? 'text' : 'asr')
1946
2142
  return
1947
2143
  }
1948
2144
  }
@@ -1973,11 +2169,13 @@ const HELP = `用法:
1973
2169
  node look.mjs sheet <BV或链接> [--grid 3x3] [--step 2] N 帧拼一张九宫格(仅 B 站)
1974
2170
  node look.mjs substrip <BV或链接> [--step 7] 底部字幕带竖拼一张图(仅 B 站)
1975
2171
  node look.mjs ocr <BV或链接> [--step 2] 抽帧 + OCR 出文字(仅 B 站)
1976
- node look.mjs text <BV或链接或URL> [--cred 凭证路径] [--step 2] [--fast] [--mark-seen] [--format json] [--out 文件]
2172
+ node look.mjs text <BV或链接或URL> [--cred 凭证路径] [--step 2] [--fast] [--mark-seen] [--verify] [--head N] [--full] [--format json] [--out 文件]
1977
2173
  node look.mjs login [--poll] [--check] 扫码登录,拿 AI 字幕用的本地凭证
1978
2174
  node look.mjs clean
1979
2175
 
1980
2176
  多 P 视频加 --p N 指定第几个分P(默认 P1)。
2177
+ --verify:text 拿到字幕后,若字幕可疑(语速不在 2–8.5 字/秒、关键词命中过低、字幕自称实验/钓鱼)
2178
+ 再加抽帧 OCR 核对画面,报「字幕与画面不重合」;不加只打一行提示,不花抽帧时间。
1981
2179
  --format:sub 支持 text|srt|json|md|txt(srt/md 带真时间戳,md 的时间戳可跳回原片,txt 是一行一句);
1982
2180
  text 支持 text|json|md(json 带时间轴口径:字幕 exact / 转写与 OCR 是估算);
1983
2181
  comments 与 danmaku 支持 json(stdout 只有 JSON,可接 jq)。
@@ -2004,7 +2202,7 @@ try {
2004
2202
  } else if (cmd === 'view') {
2005
2203
  await cmdView(BV, P)
2006
2204
  } else if (cmd === 'sub') {
2007
- await cmdSub(BV, CRED, P, { format: opt('format') || 'text', out: opt('out') || '' })
2205
+ await cmdSub(BV, CRED, P, { format: opt('format') || 'text', out: opt('out') || '', head: opt('head') || '', full: flag('full') })
2008
2206
  } else if (cmd === 'comments') {
2009
2207
  await cmdComments(BV, Number(pos[2] || 10), { format: opt('format') || 'text', out: opt('out') || '' })
2010
2208
  } else if (cmd === 'danmaku') {
@@ -2020,7 +2218,7 @@ try {
2020
2218
  } else if (cmd === 'ocr') {
2021
2219
  await cmdOcr(BV, { step: STEP, p: P })
2022
2220
  } else if (cmd === 'text') {
2023
- await cmdText(BV, CRED, { step: STEP, p: P, fast: flag('fast'), mark: flag('mark-seen'), format: opt('format') || 'text', out: opt('out') || '' })
2221
+ await cmdText(BV, CRED, { step: STEP, p: P, fast: flag('fast'), mark: flag('mark-seen'), verify: flag('verify'), format: opt('format') || 'text', out: opt('out') || '', head: opt('head') || '', full: flag('full') })
2024
2222
  } else if (cmd === 'clean') {
2025
2223
  if (existsSync(CACHE)) {
2026
2224
  rmSync(CACHE, { recursive: true, force: true })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 发布前守卫:把「已经踩过一次的坑」变成可执行断言。
3
+ *
4
+ * 由来(2026-10-26 审查 T12):0.2.23 的两个修复(.tool.txt 后缀隔离、--format json 的
5
+ * 数据模式短路)没有任何自动化守卫,改一下就可能悄悄退回去。这个文件只做一件事:
6
+ * 每条坑一条断言,跑完打印 PASS/FAIL,有 FAIL 就 exit 1。
7
+ *
8
+ * 用法:node tools/verify.mjs
9
+ * 不进 npm 包(files 里只留 tools/look.mjs,见 package.json)。
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { execFileSync } from 'node:child_process'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+ const ROOT = join(HERE, '..')
18
+ const read = (rel) => readFileSync(join(ROOT, rel), 'utf8')
19
+
20
+ const results = []
21
+ const check = (name, fn) => {
22
+ try {
23
+ const detail = fn()
24
+ results.push({ name, ok: true, detail: detail ?? '' })
25
+ } catch (e) {
26
+ results.push({ name, ok: false, detail: e?.message ?? String(e) })
27
+ }
28
+ }
29
+ const must = (cond, msg) => { if (!cond) throw new Error(msg) }
30
+ // 依赖网络的断言默认跳过:网络抖动不该让守卫变红(静态断言永远可跑)。VERIFY_ONLINE=1 才联网验。
31
+ const skip = (name, why) => results.push({ name, ok: true, skip: true, detail: why })
32
+ const online = process.env.VERIFY_ONLINE === "1"
33
+
34
+ // —— 静态断言 ——
35
+ check('版本号不是 0.2.22(P0:sync-release 会把新版覆盖成旧版)', () => {
36
+ const v = JSON.parse(read('package.json')).version
37
+ must(v !== '0.2.22', `package.json version=${v}`)
38
+ return v
39
+ })
40
+
41
+ check('--fresh 的删除在 tryLock 之后(P0:锁外删会删掉别人的 .lock)', () => {
42
+ const t = read('tools/look.mjs')
43
+ const iLock = t.indexOf('const lock = tryLock(dir)')
44
+ const iFresh = t.indexOf("if (fresh && !outDir) {\n for (const e of readdirSync(dir))")
45
+ must(iLock > 0, '找不到 tryLock')
46
+ must(iFresh > 0, '找不到锁内的 fresh 清理块')
47
+ must(iFresh > iLock, 'fresh 清理出现在 tryLock 之前')
48
+ must(!t.includes('if (fresh && !outDir && existsSync(dir)) rmSync'), '锁外那份 rmSync 还在')
49
+ return `lock@${iLock} < fresh@${iFresh}`
50
+ })
51
+
52
+ check('index.js 的 videoDir 复用「BV_标题」目录(P0:同一视频两个兄弟目录)', () => {
53
+ const t = read('index.js')
54
+ must(t.includes("d.startsWith(base + '_')"), 'videoDir 没有复用逻辑')
55
+ return 'ok'
56
+ })
57
+
58
+ check('index.js 的失败兜底带退出码(P0:别把错误抹成「没有输出」)', () => {
59
+ const t = read('index.js')
60
+ must(!t.includes("|| '(没有输出)'"), '旧的「(没有输出)」还在')
61
+ must(t.includes('退出码'), '兜底文案没带退出码')
62
+ return 'ok'
63
+ })
64
+
65
+ check('工具层落盘用 .tool.txt 后缀(P0:别覆盖 CLI 落的全文)', () => {
66
+ const t = read('index.js')
67
+ must(t.includes('`${name}.tool.txt`'), '找不到 .tool.txt')
68
+ return 'ok'
69
+ })
70
+
71
+ check('数据模式短路还在(P0:--format json 不许被提示污染)', () => {
72
+ const t = read('tools/look.mjs')
73
+ must(t.includes('opts.dataOnly'), 'printText 里没有 dataOnly 分支')
74
+ return 'ok'
75
+ })
76
+
77
+ // —— 行为断言(真跑,但都很快)——
78
+ if (online) check('text 摘要里给出落盘路径(P0:摘要必须指路,别让人再跑一遍)', () => {
79
+ const out = execFileSync(process.execPath, [join(HERE, 'look.mjs'), 'text', 'BV19dJQ6sE9F', '--head', '40'], {
80
+ encoding: 'utf8', timeout: 120000,
81
+ })
82
+ must(/text\.txt/.test(out), '输出里没有 text.txt 路径')
83
+ must(/全文/.test(out), '输出里没有全文长度说明')
84
+ return 'ok'
85
+ })
86
+
87
+ if (online) check('sub --format json 的 stdout 是纯 JSON(P0:机器可读格式不许有中文提示)', () => {
88
+ const out = execFileSync(process.execPath, [join(HERE, 'look.mjs'), 'sub', 'BV19dJQ6sE9F', '--format', 'json'], {
89
+ encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024,
90
+ })
91
+ must(out.trimStart().startsWith('{'), `首字符是 ${JSON.stringify(out.slice(0, 12))}`)
92
+ JSON.parse(out)
93
+ return `${out.length} B`
94
+ })
95
+
96
+ if (!online) {
97
+ skip('text 摘要里给出落盘路径', '联网断言,设 VERIFY_ONLINE=1 才跑')
98
+ skip('sub --format json 纯 JSON', '联网断言,设 VERIFY_ONLINE=1 才跑')
99
+ }
100
+
101
+ // —— 汇总 ——
102
+ let bad = 0
103
+ for (const r of results) {
104
+ if (!r.ok) bad++
105
+ const tag = r.skip ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'
106
+ console.log(`${tag} ${r.name}${r.detail ? ' — ' + r.detail : ''}`)
107
+ }
108
+ console.log(`\n${results.length - bad}/${results.length} 通过`)
109
+ process.exit(bad ? 1 : 0)