dsh-bili-watch 0.2.9 → 0.2.11
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 +22 -11
- package/auto-trigger.js +4 -1
- package/client.js +1 -16
- package/index.js +51 -13
- package/package.json +1 -1
- package/scripts/fetch-deps.ps1 +1 -1
- package/scripts/fetch-deps.sh +2 -2
- package/tools/look.mjs +153 -26
package/README.md
CHANGED
|
@@ -17,15 +17,15 @@
|
|
|
17
17
|
dsh plugin --profile web add dsh-bili-watch
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
装完重启 DSH 即可。DSH 0.2.0-rc.2 起,`dsh plugin add` 会把包自动写进 profile 的 `dsh.profile.bundles`;若用更早的内核,需要自己打开 profile 的 `package.json`,在 `dsh.profile.bundles` 数组里补一行 `"dsh-bili-watch"`。
|
|
21
21
|
|
|
22
22
|
装完可以验证:`dsh --profile web --dump-config` 里能搜到 `dsh-bili-watch` 才算进了装配。
|
|
23
23
|
|
|
24
24
|
也可以把本仓库直接放进 `profiles/<名字>/node_modules/dsh-bili-watch`。
|
|
25
25
|
|
|
26
|
-
插件依赖 DSH 自带的 `@deepseek-ai/dsh-tools`(peer,optional)。profile
|
|
26
|
+
插件依赖 DSH 自带的 `@deepseek-ai/dsh-tools`(peer,optional)。profile 里缺这个包时不会报错:`bili_watch` 工具不注册,`/bili` 命令与插件面板照常可用,日志里会打一行提示。
|
|
27
27
|
|
|
28
|
-
插件市场已收录(2026-10-21):在市场里搜 `dsh-bili-watch`
|
|
28
|
+
插件市场已收录(2026-10-21):在市场里搜 `dsh-bili-watch` 安装。新装插件默认不自动启用,装完到「插件」页把它的开关打开,再重启 DSH;市场里暂时搜不到时,按上面的命令装。
|
|
29
29
|
|
|
30
30
|
### 只当命令行工具
|
|
31
31
|
|
|
@@ -43,14 +43,14 @@ dsh plugin --profile web add dsh-bili-watch@latest
|
|
|
43
43
|
dsh plugin --profile web remove dsh-bili-watch
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
升级后重启 DSH
|
|
46
|
+
升级后重启 DSH。`deps/` 在包目录内,卸载或重装会连模型、环境、凭证一起删掉;要长期保留就把 `BILI_WATCH_PROJ` / `BILI_WATCH_SEEN` 指到包外。日常清理在面板里逐项删即可。
|
|
47
47
|
|
|
48
48
|
## 插件面板
|
|
49
49
|
|
|
50
50
|
装进 DSH 后,打开插件列表,点 `dsh-bili-watch` 卡片进详情页,里面有一块面板(不用先进设置),常规操作无需去对话里敲命令:
|
|
51
51
|
|
|
52
|
-
- **依赖状态**:ffmpeg、yt-dlp、ASR 环境、OCR
|
|
53
|
-
-
|
|
52
|
+
- **依赖状态**:ffmpeg、yt-dlp、ASR 环境、OCR 环境、转写模型、登录凭证逐项列出是否就绪、实际路径、占用体积。缺的点击行末「一键下载」,后台跑 `scripts/fetch-deps`(体积是估算值,实际以跑完为准)。
|
|
53
|
+
- **扫码登录**:凭证未登录时点「去扫码登录」(已登录是「重新扫码」),面板里弹出二维码,手机 B 站扫完自动刷新。凭证保存在本机 `deps/credential.json`。
|
|
54
54
|
- **删除**:面板按实际路径判断——装在插件目录里的(yt-dlp、转写模型、登录凭证)可直接删;装在插件目录外的(比如复用系统 ffmpeg 或已有的 Python 环境)按钮置灰,旁边标出它实际在哪。
|
|
55
55
|
- **批量**:标题行的「批量下载 / 批量清理」打开勾选清单,默认勾上所有可操作的项,删除前会再确认一次。
|
|
56
56
|
- **抽帧缓存**:显示当前占用与文件数,「立即清除」一键清空;不点则会按规则自动清理——保留天数(默认 7 天)与总量上限(默认 2048 MB),超过则从最旧的删起。规则存在 `deps/cache-config.json`,插件启动和打开面板时各检查一次。
|
|
@@ -123,14 +123,14 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
123
123
|
| 命令 | 做什么 |
|
|
124
124
|
|---|---|
|
|
125
125
|
| `view <BV或链接或URL>` | 元数据 + 简介 + 热评 |
|
|
126
|
-
| `peek <BV或链接>` | 元数据 + 热评 + 5
|
|
127
|
-
| `sub <BV
|
|
126
|
+
| `peek <BV或链接>` | 元数据 + 热评 + 5 张均匀帧(最便宜;装了 OCR 环境时会顺带识别这 5 帧,成本略高;仅 B 站) |
|
|
127
|
+
| `sub <BV或链接> [--format srt|json] [--out 文件]` | AI 字幕 + 三判据(字/秒、关键词命中、时长);`--format` 输出 SRT 或 JSON(带官方时间戳) |
|
|
128
128
|
| `comments <BV> [N]` | 评论区,默认按热度排序 |
|
|
129
129
|
| `asr <BV或URL>` | 本地转写音频 |
|
|
130
130
|
| `frames <BV或URL> [--step 秒] [--ocr]` | 抽帧;`--ocr` 顺带识别画面文字 |
|
|
131
131
|
| `ocr <BV>` | 抽帧并 OCR |
|
|
132
|
-
| `text <BV或URL
|
|
133
|
-
| `sheet <BV> [--grid 3x3]` |
|
|
132
|
+
| `text <BV或URL> [--format srt|json]` | 四级链:字幕 → 转写 → 抽帧 OCR;`--format srt|json` 带上时间轴口径(字幕 exact,转写与 OCR 是估算) |
|
|
133
|
+
| `sheet <BV> [--grid 3x3]` | 均匀挑帧拼成一张图(`NxM` 如 `3x3`、`4x4`,默认 9 帧) |
|
|
134
134
|
| `substrip <BV> [--step 秒]` | 底部字幕竖拼成一张图 |
|
|
135
135
|
| `danmaku <BV>` | 弹幕热区(按时间桶统计峰值) |
|
|
136
136
|
| `search <关键词> [--days 3]` | 搜视频 |
|
|
@@ -142,6 +142,14 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
142
142
|
|
|
143
143
|
表里只列常用参数。完整参数(`--out`、`--fresh`、`--scene`、`--ffmpeg`、`--cred`、`--fast`、`--mark-seen`、`--p` 等)跑 `bili-watch help` 看。
|
|
144
144
|
|
|
145
|
+
## 单条链接软提示
|
|
146
|
+
|
|
147
|
+
用户的消息里只有一条 B 站视频链接(BV 号或 `b23.tv` 短链)时,插件往消息流里补一句提示,告诉 agent 有这条链接可读、`peek` 最便宜。它**不会**替用户调用工具,也不会自动跑起来——只是让「贴链接不说话」这种场景不容易被漏掉。
|
|
148
|
+
|
|
149
|
+
- `b23.tv` 短链只提示不展开(插件不解析短链,会请用户换成浏览器地址栏里的完整链接)。
|
|
150
|
+
- 关掉:`deps/ui-config.json` 里 `"autoHint": false`。
|
|
151
|
+
- 消息里除了链接还有别的文字(问题、指令)时不提示,避免打断正常对话。
|
|
152
|
+
|
|
145
153
|
## 通用视频 URL
|
|
146
154
|
|
|
147
155
|
`view` / `text` / `asr` / `frames` 接受非 B 站 URL(YouTube、X、直链 mp4 等),走 yt-dlp:
|
|
@@ -172,11 +180,14 @@ AI 字幕(需登录)
|
|
|
172
180
|
| `BILI_WATCH_FFMPEG` | ffmpeg 可执行文件路径 |
|
|
173
181
|
| `BILI_WATCH_YTDLP` | yt-dlp 可执行文件路径 |
|
|
174
182
|
| `BILI_WATCH_SEEN` | `seen.md` 去重文件路径(默认 `deps/seen.md`)。文件不存在时 `--mark-seen` 记不了,先建一个空文件即可 |
|
|
183
|
+
| `BILI_WATCH_MIRROR` | `cn` 时 `scripts/fetch-deps.*` 走镜像下载(gh-proxy / 清华 PyPI / hf-mirror) |
|
|
184
|
+
| `HF_ENDPOINT` | 转写模型的 HuggingFace 端,默认 `https://hf-mirror.com`;海外可设官方站 |
|
|
185
|
+
| `BILI_ASR_MODEL` | 本地转写用的 faster-whisper 模型名,默认 `small` |
|
|
175
186
|
|
|
176
187
|
## 成本
|
|
177
188
|
|
|
178
189
|
- 字幕接口带一次重试;`text` 最多次试两次(先匿名、有凭证再试一次)。
|
|
179
|
-
- 抽帧会下载视频(360p)并跑 ffmpeg。`--step`
|
|
190
|
+
- 抽帧会下载视频(360p)并跑 ffmpeg。`--step` 的秒数调大能减少抽帧数,脚本另外限了 `-threads 2`。
|
|
180
191
|
- 拼图和字幕带把多帧压成一张图,省 token。
|
|
181
192
|
|
|
182
193
|
## 限制与风险
|
package/auto-trigger.js
CHANGED
|
@@ -10,7 +10,7 @@ const RE_FULL = /https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/(BV[0-9A-Za-z]{1
|
|
|
10
10
|
// 短链:能识别,但 look.mjs 不能展开(tools/look.mjs:1249-1251),调用方要单独处理
|
|
11
11
|
const RE_SHORT = /https?:\/\/b23\.tv\/[0-9A-Za-z]{5,12}(?:[/?#][^\s<>"'))]*)?/gi
|
|
12
12
|
// ?bvid=BV… 形态(tools/look.mjs:1254 支持)
|
|
13
|
-
const RE_BVID_Q = /[?&]bvid=(BV[0-9A-Za-z]{10})
|
|
13
|
+
const RE_BVID_Q = /https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/[^\s<>"'))]*?[?&]bvid=(BV[0-9A-Za-z]{10})[^\s<>"'))]*/gi
|
|
14
14
|
// 裸 BV 号:前后不许粘字母数字
|
|
15
15
|
const RE_BARE = /(?<![0-9A-Za-z])BV[0-9A-Za-z]{10}(?![0-9A-Za-z])/g
|
|
16
16
|
|
|
@@ -33,6 +33,9 @@ function stripWrappers(raw) {
|
|
|
33
33
|
return ' '
|
|
34
34
|
})
|
|
35
35
|
}
|
|
36
|
+
// `<https://…>` 是常见自动链接写法;标签块已整段删掉之后,再剥掉包着单个
|
|
37
|
+
// 无空格 token 的尖括号,避免把 <system-reminder> 这类壳提前破坏。
|
|
38
|
+
text = text.replace(/<([^<>\s]{8,})>/g, '$1')
|
|
36
39
|
const kept = []
|
|
37
40
|
for (const ln of text.split('\n')) {
|
|
38
41
|
if (/^\s*>/.test(ln)) {
|
package/client.js
CHANGED
|
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
const React = require('react')
|
|
16
16
|
const h = React.createElement
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// 包名 = 插件详情页挂点的 key:生产与发布树同名 dsh-bili-watch
|
|
19
19
|
const PKG = 'dsh-bili-watch'
|
|
20
20
|
const API = '/api/bili-watch'
|
|
21
21
|
const jget = async (p) => (await fetch(API + p, { credentials: 'same-origin' })).json()
|
|
@@ -96,21 +96,6 @@ window.__ModuleLoader__.load({
|
|
|
96
96
|
)
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// 设置页入口:先在插件详情页关掉「在设置页显示」时,这里自动隐身
|
|
100
|
-
function SettingsPanel() {
|
|
101
|
-
const [show, setShow] = React.useState(null)
|
|
102
|
-
React.useEffect(() => {
|
|
103
|
-
jget('/status')
|
|
104
|
-
.then((j) => setShow(j.ok && j.data.ui?.showInSettings !== false))
|
|
105
|
-
.catch(() => setShow(false))
|
|
106
|
-
const onUi = (e) => setShow(!!e?.detail?.showInSettings)
|
|
107
|
-
window.addEventListener('bili-watch-ui', onUi)
|
|
108
|
-
return () => window.removeEventListener('bili-watch-ui', onUi)
|
|
109
|
-
}, [])
|
|
110
|
-
if (!show) return null
|
|
111
|
-
return h(Panel, null)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
99
|
function Panel() {
|
|
115
100
|
const [st, setSt] = React.useState(null)
|
|
116
101
|
const [err, setErr] = React.useState('')
|
package/index.js
CHANGED
|
@@ -92,7 +92,7 @@ const QR_POLL = (key) =>
|
|
|
92
92
|
const SPI = 'https://api.bilibili.com/x/frontend/finger/spi'
|
|
93
93
|
|
|
94
94
|
const name = 'dsh-bili-watch'
|
|
95
|
-
const inject = ['commands', '
|
|
95
|
+
const inject = ['commands', 'webServer', 'agents']
|
|
96
96
|
|
|
97
97
|
function runLook(args, { timeoutMs = 1_200_000 } = {}) {
|
|
98
98
|
return new Promise((resolve) => {
|
|
@@ -445,11 +445,23 @@ function runDownload(target) {
|
|
|
445
445
|
try {
|
|
446
446
|
appendFileSync(DOWNLOAD_LOG, `[${new Date().toISOString()}] 开始下载 ${target}\n`, 'utf8')
|
|
447
447
|
} catch {}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
448
|
+
const win = process.platform === 'win32'
|
|
449
|
+
const cmd = win ? 'powershell.exe' : 'bash'
|
|
450
|
+
const args = win
|
|
451
|
+
? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Target', target]
|
|
452
|
+
: [script.replace(/\.ps1$/i, '.sh'), target]
|
|
453
|
+
let child = null
|
|
454
|
+
try {
|
|
455
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore', detached: true })
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return { started: false, error: String(e?.message ?? e) }
|
|
458
|
+
}
|
|
459
|
+
// detached 子进程不会把 spawn 失败同步抛给我们,必须自己接 error,否则 ENOENT 会带崩宿主
|
|
460
|
+
child.on('error', (e) => {
|
|
461
|
+
try {
|
|
462
|
+
appendFileSync(DOWNLOAD_LOG, `[${new Date().toISOString()}] 启动失败 ${target}:${e?.message ?? e}\n`, 'utf8')
|
|
463
|
+
} catch {}
|
|
464
|
+
})
|
|
453
465
|
child.unref()
|
|
454
466
|
return { started: true, log: DOWNLOAD_LOG }
|
|
455
467
|
}
|
|
@@ -517,7 +529,7 @@ function parseCommandLine(raw) {
|
|
|
517
529
|
.split(/\s+/)
|
|
518
530
|
.filter(Boolean)
|
|
519
531
|
const command = tokens.shift()
|
|
520
|
-
const
|
|
532
|
+
const positionals = []
|
|
521
533
|
const extra = []
|
|
522
534
|
let step = ''
|
|
523
535
|
let cred = ''
|
|
@@ -543,12 +555,16 @@ function parseCommandLine(raw) {
|
|
|
543
555
|
extra.push('--out', tokens.shift())
|
|
544
556
|
} else if (t === '--ffmpeg' && tokens.length) {
|
|
545
557
|
extra.push('--ffmpeg', tokens.shift())
|
|
558
|
+
} else if (t === '--format' && tokens.length) {
|
|
559
|
+
extra.push('--format', tokens.shift())
|
|
546
560
|
} else if (t === '--fresh' || t === '--ocr' || t === '--poll' || t === '--check' || t === '--fast' || t === '--scene' || t === '--mark-seen') {
|
|
547
561
|
extra.push(t)
|
|
562
|
+
} else if (!t.startsWith('--')) {
|
|
563
|
+
// 非选项 token 都当位置参数收着:BV / 搜索词 / 评论条数都可能出现在选项前后
|
|
564
|
+
positionals.push(t)
|
|
548
565
|
}
|
|
549
|
-
// 其余 token 直接丢弃(命令本身不接受别的参数)
|
|
550
566
|
}
|
|
551
|
-
return { command, bv, extra, step, cred }
|
|
567
|
+
return { command, bv: positionals[0] || '', extra, step, cred }
|
|
552
568
|
}
|
|
553
569
|
|
|
554
570
|
async function lookup({ command, bv, extra = [] }) {
|
|
@@ -586,7 +602,7 @@ function apply(ctx) {
|
|
|
586
602
|
name: 'bili',
|
|
587
603
|
description: '看 B 站视频内容:字幕 → 本地转写 → 抽帧 OCR,输出最可信的一份文本',
|
|
588
604
|
input: {
|
|
589
|
-
hint: '<search 关键词|hot|peek|view|sub|comments|danmaku|asr|frames|sheet|substrip|ocr|text|login|clean> [BV] [--p N] [--step 2] [--days 3] [--order pubdate|click] [--cred 凭证路径] [--fast] [--scene] [--grid 3x3]',
|
|
605
|
+
hint: '<search 关键词|hot|peek|view|sub|comments|danmaku|asr|frames|sheet|substrip|ocr|text|login|clean> [BV] [--p N] [--step 2] [--days 3] [--order pubdate|click] [--cred 凭证路径] [--fast] [--scene] [--grid 3x3] [--format srt|json] [--out 文件]',
|
|
590
606
|
},
|
|
591
607
|
handler: async (invocation) => {
|
|
592
608
|
const { command, bv, extra } = parseCommandLine(invocation?.rawInput)
|
|
@@ -644,7 +660,7 @@ function apply(ctx) {
|
|
|
644
660
|
// ② 同名工具:模型可调,需要看视频时自己来,不依赖用户敲命令
|
|
645
661
|
// 必须用 defineTool 包一层:ctx.tools.register 收的是编译好的 tool 对象,
|
|
646
662
|
// 直接传 options 会让发往模型的 parameters 变成 null(报 INVALID_REQUEST: schema must be type "object")。
|
|
647
|
-
if (defineTool) {
|
|
663
|
+
if (defineTool && ctx.tools) {
|
|
648
664
|
ctx.tools.register(defineTool({
|
|
649
665
|
name: 'bili_watch',
|
|
650
666
|
description:
|
|
@@ -689,6 +705,18 @@ function apply(ctx) {
|
|
|
689
705
|
type: 'string',
|
|
690
706
|
description: 'command=sheet 时的格子,如 3x3 或 4x4(默认 3x3)。',
|
|
691
707
|
},
|
|
708
|
+
format: {
|
|
709
|
+
type: 'string',
|
|
710
|
+
description: 'command=sub 时可选 srt 或 json(带官方时间戳);command=text 时可选 json(含时间轴口径:字幕 exact,转写与 OCR 是估算)。不传默认纯文本。',
|
|
711
|
+
},
|
|
712
|
+
out: {
|
|
713
|
+
type: 'string',
|
|
714
|
+
description: 'command=sub/text 且传了 format 时,把结果写到这个文件路径;不传打印到标准输出。',
|
|
715
|
+
},
|
|
716
|
+
limit: {
|
|
717
|
+
type: 'number',
|
|
718
|
+
description: 'command=hot/search 的结果条数(hot 默认 10,search 默认 10)。',
|
|
719
|
+
},
|
|
692
720
|
},
|
|
693
721
|
output: {
|
|
694
722
|
// 注意:DSH 的 output.schema 是「author-facing spec」,编译时 allowRequired=false,
|
|
@@ -712,8 +740,18 @@ function apply(ctx) {
|
|
|
712
740
|
if (args.scene) extra.push('--scene')
|
|
713
741
|
if (typeof args.grid === 'string' && args.grid.trim()) extra.push('--grid', args.grid.trim())
|
|
714
742
|
if (typeof args.days === 'number' && args.days > 0) extra.push('--days', String(Math.floor(args.days)))
|
|
743
|
+
if (typeof args.limit === 'number' && args.limit > 0) extra.push('--limit', String(Math.floor(args.limit)))
|
|
744
|
+
if (typeof args.format === 'string' && args.format.trim()) extra.push('--format', args.format.trim())
|
|
745
|
+
if (typeof args.out === 'string' && args.out.trim()) {
|
|
746
|
+
const out = args.out.trim()
|
|
747
|
+
// 工具层是模型可调的:只允许写工作区内的相对路径,挡住绝对路径 / 盘符 / .. 与家目录
|
|
748
|
+
if (/^([/\\]|[A-Za-z]:|~)|\.\./.test(out)) {
|
|
749
|
+
return { ok: false, text: '--out 只接受工作区内的相对路径(不能用绝对路径、盘符、~ 或 ..)' }
|
|
750
|
+
}
|
|
751
|
+
extra.push('--out', out)
|
|
752
|
+
}
|
|
715
753
|
if (args.command === 'hot') {
|
|
716
|
-
const r = await lookup({ command: 'hot', bv: '' })
|
|
754
|
+
const r = await lookup({ command: 'hot', bv: '', extra })
|
|
717
755
|
return { ok: r.ok, text: r.text }
|
|
718
756
|
}
|
|
719
757
|
const target = args.command === 'search' ? String(args.keyword ?? '').trim() : String(args.bv ?? '').trim()
|
|
@@ -812,4 +850,4 @@ function apply(ctx) {
|
|
|
812
850
|
}
|
|
813
851
|
}
|
|
814
852
|
|
|
815
|
-
export { apply, inject, name }
|
|
853
|
+
export { apply, inject, name, parseCommandLine }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-watch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Reads Bilibili (and generic URLs via yt-dlp) into text for DSH agents: search, hot, metadata, comments, danmaku, AI subtitles, local ASR, frame OCR.",
|
|
6
6
|
"type": "module",
|
package/scripts/fetch-deps.ps1
CHANGED
|
@@ -181,7 +181,7 @@ function Ensure-Model {
|
|
|
181
181
|
return
|
|
182
182
|
}
|
|
183
183
|
$env:HF_HOME = $hfHome
|
|
184
|
-
$env:HF_ENDPOINT = $HfEndpoint
|
|
184
|
+
if (-not $env:HF_ENDPOINT) { $env:HF_ENDPOINT = $HfEndpoint }
|
|
185
185
|
$env:HF_HUB_DISABLE_XET = '1'
|
|
186
186
|
& $asrPy -c "from huggingface_hub import snapshot_download; print(snapshot_download('Systran/faster-whisper-small'))"
|
|
187
187
|
if ($LASTEXITCODE -ne 0) { throw '模型下载失败' }
|
package/scripts/fetch-deps.sh
CHANGED
|
@@ -24,11 +24,11 @@ MIRROR="${BILI_WATCH_MIRROR:-}"
|
|
|
24
24
|
if [ "$MIRROR" = "cn" ]; then
|
|
25
25
|
GH="https://gh-proxy.com/https://github.com"
|
|
26
26
|
PY_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
27
|
-
HF_ENDPOINT="https://hf-mirror.com"
|
|
27
|
+
HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}"
|
|
28
28
|
else
|
|
29
29
|
GH="https://github.com"
|
|
30
30
|
PY_INDEX="https://pypi.org/simple"
|
|
31
|
-
HF_ENDPOINT="https://huggingface.co"
|
|
31
|
+
HF_ENDPOINT="${HF_ENDPOINT:-https://huggingface.co}"
|
|
32
32
|
fi
|
|
33
33
|
|
|
34
34
|
fetch_ytdlp() {
|
package/tools/look.mjs
CHANGED
|
@@ -234,7 +234,7 @@ async function fetchSubtitle(aid, cid, headers = H) {
|
|
|
234
234
|
notes.push(`第 ${i + 1} 次:内容为空`)
|
|
235
235
|
continue
|
|
236
236
|
}
|
|
237
|
-
return { ok: true, text, lan: track.lan, tries: i + 1, notes }
|
|
237
|
+
return { ok: true, text, cues: sj.body ?? [], lan: track.lan, tries: i + 1, notes }
|
|
238
238
|
}
|
|
239
239
|
return { ok: false, text: '', notes, why: `试了 2 次都没拿到(${notes.join(';')})` }
|
|
240
240
|
}
|
|
@@ -664,16 +664,101 @@ async function cmdView(bv, p = 1) {
|
|
|
664
664
|
return v
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
-
|
|
667
|
+
// SRT 时间戳:HH:MM:SS,mmm(from/to 是秒,可能带小数)
|
|
668
|
+
function fmtSrtTime(sec) {
|
|
669
|
+
// 整体按毫秒取整再拆,避免 Math.round 把小数位凑成 1000(非法 SRT 时间戳)
|
|
670
|
+
const totalMs = Math.max(0, Math.round((Number(sec) || 0) * 1000))
|
|
671
|
+
const h = Math.floor(totalMs / 3600000)
|
|
672
|
+
const m = Math.floor(totalMs / 60000) % 60
|
|
673
|
+
const s = Math.floor(totalMs / 1000) % 60
|
|
674
|
+
const ms = totalMs % 1000
|
|
675
|
+
const p = (n, w = 2) => String(n).padStart(w, '0')
|
|
676
|
+
return `${p(h)}:${p(m)}:${p(s)},${p(ms, 3)}`
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 只给有时间的档用(字幕档是官方时间戳;ASR 档是估算,调用方要自己标 .estimated)
|
|
680
|
+
function cuesToSrt(cues) {
|
|
681
|
+
const rows = []
|
|
682
|
+
let n = 0
|
|
683
|
+
for (const c of cues || []) {
|
|
684
|
+
const text = String(c?.content ?? '').trim()
|
|
685
|
+
if (!text) continue
|
|
686
|
+
const a = Number(c?.from ?? 0)
|
|
687
|
+
const b = Number(c?.to ?? 0)
|
|
688
|
+
n++
|
|
689
|
+
rows.push(String(n), `${fmtSrtTime(a)} --> ${fmtSrtTime(Math.max(b, a))}`, text, '')
|
|
690
|
+
}
|
|
691
|
+
return rows.join('\n')
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// text 命令的 --format json:把「这一档拿到的东西 + 时间轴口径」一起吐出去
|
|
695
|
+
function emitTextJson(opts, payload) {
|
|
696
|
+
if (String(opts?.format || '').toLowerCase() !== 'json') return false
|
|
697
|
+
const text = JSON.stringify(payload, null, 2)
|
|
698
|
+
if (opts.out) {
|
|
699
|
+
writeFileSync(opts.out, text, 'utf8')
|
|
700
|
+
console.log(`✓ 已写入 ${opts.out}(source=${payload.source})`)
|
|
701
|
+
} else console.log(text)
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// --format 输出结构化数据时,过程说明一律走 stderr,保证 stdout 能被 jq / 管道直接消费
|
|
706
|
+
let QUIET = false
|
|
707
|
+
const say = (...a) => (QUIET ? console.error : console.log)(...a)
|
|
708
|
+
|
|
709
|
+
async function cmdSub(bv, credPath, p = 1, opts = {}) {
|
|
710
|
+
QUIET = opts.format === 'srt' || opts.format === 'json'
|
|
711
|
+
const format = String(opts.format || 'text').toLowerCase()
|
|
712
|
+
const out = String(opts.out || '')
|
|
713
|
+
if (out && format !== 'json' && format !== 'srt') {
|
|
714
|
+
console.log('✗ --out 要配合 --format srt|json;纯文本会直接打印到标准输出')
|
|
715
|
+
process.exitCode = 1
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
if (!['text', 'json', 'srt'].includes(format)) {
|
|
719
|
+
console.log(`✗ 不认识的 --format:${format}(可选 text|srt|json)`)
|
|
720
|
+
process.exitCode = 1
|
|
721
|
+
return
|
|
722
|
+
}
|
|
668
723
|
const cred = credFor(credPath)
|
|
669
724
|
const v = await viewOf(bv, p)
|
|
670
725
|
const pTag = v.pages > 1 ? `(P${v.p}/${v.pages})` : ''
|
|
671
|
-
|
|
726
|
+
say(`视频: ${v.title} UP: ${v.owner} 时长 ${fmtSec(v.dur)}${pTag}${cred ? '(带凭证)' : '(匿名)'}`)
|
|
672
727
|
const s = await fetchSubtitle(v.aid, v.cid, headersFor(cred))
|
|
673
728
|
if (!s.ok) {
|
|
674
729
|
console.log(`\n✗ 字幕取不到:${s.why}`)
|
|
675
730
|
console.log(` ${cred ? '换匿名再试:' : '有凭证可加 --cred:'}node look.mjs sub ${bv}${cred ? '' : ' --cred <凭证>'}`)
|
|
676
731
|
console.log(` 无字幕轨时走:node look.mjs text ${bv}(ASR → OCR)`)
|
|
732
|
+
process.exitCode = 1
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (format === 'json' || format === 'srt') {
|
|
736
|
+
const cues = Array.isArray(s.cues) ? s.cues : []
|
|
737
|
+
const usable = cues.filter((c) => String(c?.content ?? '').trim()).length
|
|
738
|
+
if (usable === 0) {
|
|
739
|
+
console.log('\n✗ 字幕轨里没有可用文案(0 条),不写空文件')
|
|
740
|
+
process.exitCode = 1
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
if (format === 'srt') {
|
|
744
|
+
const text = cuesToSrt(cues)
|
|
745
|
+
if (out) {
|
|
746
|
+
writeFileSync(out, text, 'utf8')
|
|
747
|
+
console.log(`✓ 已写入 ${out}(${usable} 条,官方时间戳)`)
|
|
748
|
+
} else console.log(text)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
const payload = {
|
|
752
|
+
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
753
|
+
lan: s.lan ?? null,
|
|
754
|
+
timeline: { exact: true, source: 'official-subtitle' },
|
|
755
|
+
cues: cues.map((c) => ({ from: Number(c?.from ?? 0), to: Number(c?.to ?? 0), content: String(c?.content ?? '') })),
|
|
756
|
+
}
|
|
757
|
+
const text = JSON.stringify(payload, null, 2)
|
|
758
|
+
if (out) {
|
|
759
|
+
writeFileSync(out, text, 'utf8')
|
|
760
|
+
console.log(`✓ 已写入 ${out}(${payload.cues.length} 条)`)
|
|
761
|
+
} else console.log(text)
|
|
677
762
|
return
|
|
678
763
|
}
|
|
679
764
|
const c = credibility(v, s.text)
|
|
@@ -885,6 +970,7 @@ async function cmdAsr(bv, p = 1, ffmpeg = '') {
|
|
|
885
970
|
const r = await transcribe(bv, { p, ffmpeg })
|
|
886
971
|
if (!r.ok) {
|
|
887
972
|
console.log(`\n✗ ${r.why}`)
|
|
973
|
+
process.exitCode = 1
|
|
888
974
|
return
|
|
889
975
|
}
|
|
890
976
|
console.log(`\n转写 ${r.chars} 字:\n${r.text}`)
|
|
@@ -1134,6 +1220,7 @@ function markSeen(bv, title) {
|
|
|
1134
1220
|
|
|
1135
1221
|
/** 四级链:字幕 → ASR → 抽帧 OCR。输出最可信的一份文本。 */
|
|
1136
1222
|
async function cmdText(bv, credPath, opts = {}) {
|
|
1223
|
+
QUIET = opts.format === 'json'
|
|
1137
1224
|
const t0 = Date.now()
|
|
1138
1225
|
const cred = credFor(credPath)
|
|
1139
1226
|
const v = await viewOf(bv, opts.p)
|
|
@@ -1141,12 +1228,12 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1141
1228
|
const done = () => {
|
|
1142
1229
|
if (!opts.mark) return
|
|
1143
1230
|
const ok = markSeen(bv, v.title)
|
|
1144
|
-
|
|
1231
|
+
say(`[track] seen: ${ok ? '已记一笔' : '记不了(seen.md 不存在)'}`)
|
|
1145
1232
|
}
|
|
1146
1233
|
const pTag = v.pages > 1 ? `(P${v.p}/${v.pages})` : ''
|
|
1147
|
-
|
|
1234
|
+
say(`视频: ${v.title} UP: ${v.owner} 时长 ${fmtSec(v.dur)}${pTag}`)
|
|
1148
1235
|
const budget = v.dur > 900 ? '约 2–4 分钟' : v.dur > 300 ? '约 1–3 分钟' : '约 30–90 秒'
|
|
1149
|
-
|
|
1236
|
+
say(`[track] 预算:${v.dur <= 1800 ? budget : '视频超过 30 分钟,字幕取不到会跳过转写直接走画面'}`)
|
|
1150
1237
|
|
|
1151
1238
|
// ① 字幕(匿名先试;失败且有凭证再试登录态——AI 字幕轨要登录)
|
|
1152
1239
|
let s = await fetchSubtitle(v.aid, v.cid, H)
|
|
@@ -1160,58 +1247,94 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1160
1247
|
// 信道优先:官方字幕能拿到就是可信内容,语速/关键词只作提示,不拦
|
|
1161
1248
|
// 口径差异(有意):自动化选片链路会把「语速/关键词不合格」当硬拦,
|
|
1162
1249
|
// 这里是人看的排查通道,取到就用、质量交给调用方判断。同一判据两种策略,别当 bug 报。
|
|
1163
|
-
|
|
1250
|
+
if (emitTextJson(opts, {
|
|
1251
|
+
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1252
|
+
source: 'subtitle',
|
|
1253
|
+
lan: s.lan ?? null,
|
|
1254
|
+
timeline: { exact: true, source: 'official-subtitle' },
|
|
1255
|
+
cues: (s.cues || []).map((c) => ({ from: Number(c?.from ?? 0), to: Number(c?.to ?? 0), content: String(c?.content ?? '') })),
|
|
1256
|
+
text: s.text,
|
|
1257
|
+
})) {
|
|
1258
|
+
done()
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
say(`[track] ${via}: ok ${s.text.length} 字(语速 ${c.sp.rate ?? '?'} 字/秒,关键词 ${c.ks.hit}/${c.ks.total})`)
|
|
1164
1262
|
console.log(`\n${s.text}`)
|
|
1165
1263
|
done()
|
|
1166
1264
|
return
|
|
1167
1265
|
}
|
|
1168
|
-
|
|
1266
|
+
say(`[track] 字幕: miss(${s.why})`)
|
|
1169
1267
|
|
|
1170
1268
|
// ② 本地 ASR 转写(无字幕时最靠谱,零 token;--fast 跳过,直接走画面)
|
|
1171
1269
|
if (!opts.fast && asrReady() && v.dur > 1800) {
|
|
1172
|
-
|
|
1270
|
+
say(`[track] 转写: skip(时长 ${fmtSec(v.dur)} 超过 30 分钟,转写会超时)`)
|
|
1173
1271
|
}
|
|
1174
1272
|
if (!opts.fast && asrReady() && v.dur <= 1800) {
|
|
1175
|
-
|
|
1273
|
+
say(`[track] 转写: 开始(faster-whisper,最多等 3 分钟)…`)
|
|
1176
1274
|
const a = await transcribe(bv, { timeoutMs: 180_000, p: v.p })
|
|
1177
1275
|
if (a.ok) {
|
|
1178
1276
|
// 口播类正常 4–6 字/秒;低于 2 字/秒多半是背景音乐、外语歌或纯画面,
|
|
1179
1277
|
// 这时候转写出来的「字」只是把杂音听成了字,继续往下走 OCR 更靠谱。
|
|
1180
1278
|
const rate = v.dur > 0 ? a.chars / v.dur : 0
|
|
1181
1279
|
if (rate >= 2) {
|
|
1182
|
-
|
|
1280
|
+
if (emitTextJson(opts, {
|
|
1281
|
+
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1282
|
+
source: 'asr',
|
|
1283
|
+
timeline: { exact: false, source: 'asr-estimate', note: '无官方时间戳;按标点与字数估算' },
|
|
1284
|
+
text: a.text,
|
|
1285
|
+
})) {
|
|
1286
|
+
done()
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
say(`[track] 转写: ok ${a.chars} 字(${rate.toFixed(1)} 字/秒${a.cached ? ',缓存命中' : ''};机器转写,专有名词可能有错)`)
|
|
1183
1290
|
console.log(`\n${a.text}`)
|
|
1184
1291
|
done()
|
|
1185
1292
|
return
|
|
1186
1293
|
}
|
|
1187
|
-
|
|
1294
|
+
say(`[track] 转写: 质量低(${a.chars} 字,${rate.toFixed(1)} 字/秒),继续往下试`)
|
|
1188
1295
|
} else {
|
|
1189
|
-
|
|
1296
|
+
say(`[track] 转写: miss(${a.why})`)
|
|
1190
1297
|
}
|
|
1191
1298
|
} else if (opts.fast) {
|
|
1192
|
-
|
|
1299
|
+
say(`[track] 转写: skip(--fast)`)
|
|
1193
1300
|
} else if (!asrReady()) {
|
|
1194
|
-
|
|
1301
|
+
say(`[track] 转写: skip(环境不可用)`)
|
|
1195
1302
|
}
|
|
1196
1303
|
|
|
1197
1304
|
// ③ 抽帧 OCR(画面文字转文本)
|
|
1198
1305
|
if (ocrReady()) {
|
|
1199
|
-
|
|
1306
|
+
say(`[track] 画面 OCR: 抽帧…`)
|
|
1200
1307
|
const r = await grabFrames(bv, { step: opts.step ?? 0, p: v.p })
|
|
1201
1308
|
const text = await runOcr(r.dir)
|
|
1202
1309
|
if (text && !text.startsWith('[ocr] 失败')) {
|
|
1203
|
-
|
|
1310
|
+
if (emitTextJson(opts, {
|
|
1311
|
+
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1312
|
+
source: 'ocr',
|
|
1313
|
+
timeline: { exact: false, source: 'frame-ocr', step: opts.step ?? 'auto' },
|
|
1314
|
+
text,
|
|
1315
|
+
})) {
|
|
1316
|
+
done()
|
|
1317
|
+
return
|
|
1318
|
+
}
|
|
1319
|
+
say(`[track] 画面 OCR: ok(${r.dir};这是画面上的字,不是口播内容)`)
|
|
1204
1320
|
console.log(`\n${text}`)
|
|
1205
1321
|
done()
|
|
1206
1322
|
return
|
|
1207
1323
|
}
|
|
1208
|
-
|
|
1324
|
+
say(`[track] 画面 OCR: miss(${text.slice(0, 160)})`)
|
|
1209
1325
|
} else {
|
|
1210
|
-
|
|
1326
|
+
say(`[track] 画面 OCR: skip(环境不可用)`)
|
|
1211
1327
|
}
|
|
1212
1328
|
|
|
1213
1329
|
// ④ 兜底:抽帧读图
|
|
1214
|
-
|
|
1330
|
+
if (emitTextJson(opts, {
|
|
1331
|
+
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1332
|
+
source: 'vision',
|
|
1333
|
+
timeline: { exact: false, source: 'frames-for-vision' },
|
|
1334
|
+
text: '',
|
|
1335
|
+
note: '没有可直接导出的文本;已抽帧,交给模型读图',
|
|
1336
|
+
})) return
|
|
1337
|
+
say(`[track] 兜底:抽帧交给模型读图(已耗时 ${Math.round((Date.now() - t0) / 1000)}s)`)
|
|
1215
1338
|
await cmdFrames(bv, { step: opts.step ?? 0, p: v.p })
|
|
1216
1339
|
}
|
|
1217
1340
|
|
|
@@ -1219,7 +1342,7 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1219
1342
|
const argv = process.argv.slice(2)
|
|
1220
1343
|
const cmd = argv[0]
|
|
1221
1344
|
// 带值的选项:解析位置参数时必须跳过它们的值,否则 `hot --limit 2` 会把 "2" 当成 BV
|
|
1222
|
-
const VALUE_OPTS = new Set(['cred', 'days', 'ffmpeg', 'grid', 'key', 'limit', 'order', 'out', 'p', 'step'])
|
|
1345
|
+
const VALUE_OPTS = new Set(['cred', 'days', 'ffmpeg', 'grid', 'key', 'limit', 'order', 'out', 'p', 'step', 'format'])
|
|
1223
1346
|
function positional(list) {
|
|
1224
1347
|
const out = []
|
|
1225
1348
|
for (let i = 0; i < list.length; i++) {
|
|
@@ -1235,6 +1358,9 @@ function positional(list) {
|
|
|
1235
1358
|
}
|
|
1236
1359
|
const pos = positional(argv)
|
|
1237
1360
|
const opt = (name, dflt) => {
|
|
1361
|
+
// 兼容 `--name value` 与 `--name=value`(后者以前会被静默忽略,退回默认值)
|
|
1362
|
+
const eq = argv.find((a) => a.startsWith(`--${name}=`))
|
|
1363
|
+
if (eq) return eq.slice(name.length + 3)
|
|
1238
1364
|
const i = argv.indexOf(`--${name}`)
|
|
1239
1365
|
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt
|
|
1240
1366
|
}
|
|
@@ -1473,7 +1599,7 @@ const HELP = `用法:
|
|
|
1473
1599
|
node look.mjs hot [--limit 10] 综合热门榜(B 站按热度排)
|
|
1474
1600
|
node look.mjs peek <BV或链接> 先看标题/简介/热评 + 5 帧,判断内容类型(仅 B 站)
|
|
1475
1601
|
node look.mjs view <BV或链接或URL> 只看元数据与热评
|
|
1476
|
-
node look.mjs sub <BV或链接> [--cred 凭证路径]
|
|
1602
|
+
node look.mjs sub <BV或链接> [--cred 凭证路径] [--format srt|json] [--out 文件] 字幕(仅 B 站)
|
|
1477
1603
|
node look.mjs comments <BV或链接> [条数] 评论区(仅 B 站)
|
|
1478
1604
|
node look.mjs danmaku <BV或链接> 弹幕热区(仅 B 站)
|
|
1479
1605
|
node look.mjs asr <BV或链接或URL> [--ffmpeg 路径] 本地转写(B 站或通用 URL 均可)
|
|
@@ -1482,16 +1608,17 @@ const HELP = `用法:
|
|
|
1482
1608
|
node look.mjs sheet <BV或链接> [--grid 3x3] [--step 2] N 帧拼一张九宫格(仅 B 站)
|
|
1483
1609
|
node look.mjs substrip <BV或链接> [--step 7] 底部字幕带竖拼一张图(仅 B 站)
|
|
1484
1610
|
node look.mjs ocr <BV或链接> [--step 2] 抽帧 + OCR 出文字(仅 B 站)
|
|
1485
|
-
node look.mjs text <BV或链接或URL> [--cred 凭证路径] [--step 2] [--fast] [--mark-seen]
|
|
1611
|
+
node look.mjs text <BV或链接或URL> [--cred 凭证路径] [--step 2] [--fast] [--mark-seen] [--format json] [--out 文件]
|
|
1486
1612
|
node look.mjs login [--poll] [--check] 扫码登录,拿 AI 字幕用的本地凭证
|
|
1487
1613
|
node look.mjs clean
|
|
1488
1614
|
|
|
1489
1615
|
多 P 视频加 --p N 指定第几个分P(默认 P1)。
|
|
1616
|
+
--format srt|json:sub 给官方字幕的真时间戳;text 的 json 会带时间轴口径(字幕 exact / 转写与 OCR 是估算)。
|
|
1490
1617
|
B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/... 要先展开成完整链接。
|
|
1491
1618
|
通用视频 URL(YouTube/X 等)需要 yt-dlp:跑 scripts/fetch-deps.ps1 -Target ytdlp 装到 deps\\yt-dlp.exe(或设 BILI_WATCH_YTDLP / 系统 PATH)。
|
|
1492
1619
|
`
|
|
1493
1620
|
try {
|
|
1494
|
-
if (srcErr || src.type === 'error') {
|
|
1621
|
+
if (srcErr || (src.type === 'error' && cmd !== 'search' && cmd !== 'hot')) {
|
|
1495
1622
|
console.error(`✗ ${srcErr || src.message}`)
|
|
1496
1623
|
process.exit(1)
|
|
1497
1624
|
}
|
|
@@ -1510,7 +1637,7 @@ try {
|
|
|
1510
1637
|
} else if (cmd === 'view') {
|
|
1511
1638
|
await cmdView(BV, P)
|
|
1512
1639
|
} else if (cmd === 'sub') {
|
|
1513
|
-
await cmdSub(BV, CRED, P)
|
|
1640
|
+
await cmdSub(BV, CRED, P, { format: opt('format') || 'text', out: opt('out') || '' })
|
|
1514
1641
|
} else if (cmd === 'comments') {
|
|
1515
1642
|
await cmdComments(BV, Number(pos[2] || 10))
|
|
1516
1643
|
} else if (cmd === 'danmaku') {
|
|
@@ -1526,7 +1653,7 @@ try {
|
|
|
1526
1653
|
} else if (cmd === 'ocr') {
|
|
1527
1654
|
await cmdOcr(BV, { step: STEP, p: P })
|
|
1528
1655
|
} else if (cmd === 'text') {
|
|
1529
|
-
await cmdText(BV, CRED, { step: STEP, p: P, fast: flag('fast'), mark: flag('mark-seen') })
|
|
1656
|
+
await cmdText(BV, CRED, { step: STEP, p: P, fast: flag('fast'), mark: flag('mark-seen'), format: opt('format') || 'text', out: opt('out') || '' })
|
|
1530
1657
|
} else if (cmd === 'clean') {
|
|
1531
1658
|
if (existsSync(CACHE)) {
|
|
1532
1659
|
rmSync(CACHE, { recursive: true, force: true })
|