dsh-bili-watch 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +145 -2
- package/cordis.patch.yml +5 -0
- package/deps/.gitkeep +1 -0
- package/deps/_bili-asr.py +126 -0
- package/index.js +235 -0
- package/package.json +65 -4
- package/scripts/fetch-deps.ps1 +222 -0
- package/scripts/fetch-deps.sh +105 -0
- package/tools/look.mjs +1544 -0
- package/tools/ocr.py +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sacredbluedragon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-bili-watch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 AI「看」B 站视频的 DSH 插件:元数据、AI 字幕、评论区、本地转写、抽帧 OCR、九宫格拼图、弹幕热区。
|
|
4
|
+
|
|
5
|
+
一句话:模型能直接看一条 B 站视频——先用最便宜的方式拿到文字,文字不够再看画面。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
dsh plugin --profile web add dsh-bili-watch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**第二步不能省**:打开 profile 的 `package.json`,在 `dsh.profile.bundles` 数组里加一行 `"dsh-bili-watch"`。`dsh plugin add` 只写 `dependencies`,不写 bundles——漏了这一步插件装了但完全不生效(不报错、无入口)。装完重启 DSH。
|
|
14
|
+
|
|
15
|
+
也可以把本仓库直接放到 `profiles/<名字>/node_modules/dsh-bili-watch`。
|
|
16
|
+
|
|
17
|
+
> 插件依赖 DSH 自带的 `@deepseek-ai/dsh-tools`(peer,optional)。若 profile 缺这个内核工具包,`index.js` 会报 `ERR_MODULE_NOT_FOUND`,先补装再重启。
|
|
18
|
+
|
|
19
|
+
## 依赖
|
|
20
|
+
|
|
21
|
+
| 能力 | 需要 | 没有时 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 元数据 / 评论 / 弹幕 / 搜索 / 热榜 | 无(匿名 HTTP) | — |
|
|
24
|
+
| 抽帧 / 九宫格 / 字幕带 | `ffmpeg` | 跳过,文字档仍可用 |
|
|
25
|
+
| AI 字幕 | 一次扫码登录(SESSDATA) | 返回「无字幕轨」,自动降级 |
|
|
26
|
+
| 本地转写 | Python 3.10+ 与 faster-whisper(`deps/.venv-asr`) | 跳过,自动降级 |
|
|
27
|
+
| 画面 OCR | Python 3.10+ 与 rapidocr_onnxruntime(`deps/.venv-ocr`) | 跳过,只剩基础抽帧 |
|
|
28
|
+
| 通用视频 URL | yt-dlp | 只支持 B 站 |
|
|
29
|
+
|
|
30
|
+
前置:Node.js ≥ 22(插件运行环境本身就有 node,命令行跑也一样)。
|
|
31
|
+
|
|
32
|
+
`ffmpeg` 查找顺序:`--ffmpeg` 参数 → `BILI_LOOK_FFMPEG` → 插件 `deps/` → PATH。
|
|
33
|
+
|
|
34
|
+
`deps/` 是运行期目录:Python venv、模型缓存、yt-dlp、凭证都放这里,默认不进 git(见 `.gitignore`)。ASR/OCR 的 Python 环境是**可选依赖**,不配也能用,只是会少两档。
|
|
35
|
+
|
|
36
|
+
`deps/_bili-asr.py` 默认从 `hf-mirror.com` 下载模型(国内快);海外网络可先设 `HF_ENDPOINT=https://huggingface.co` 再跑 `fetch-deps`。
|
|
37
|
+
|
|
38
|
+
### 可选依赖(一键安装)
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
# Windows(powershell.exe 或 pwsh 都可以)
|
|
42
|
+
powershell -ExecutionPolicy Bypass -File scripts\fetch-deps.ps1 -Target all
|
|
43
|
+
powershell -ExecutionPolicy Bypass -File scripts\fetch-deps.ps1 -Target all -Mirror cn # 国内网络:gh-proxy + 清华 PyPI + hf-mirror
|
|
44
|
+
|
|
45
|
+
# 单项:ffmpeg / ytdlp / asr / ocr / model
|
|
46
|
+
powershell -ExecutionPolicy Bypass -File scripts\fetch-deps.ps1 -Target ytdlp
|
|
47
|
+
powershell -ExecutionPolicy Bypass -File scripts\fetch-deps.ps1 -Target model
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# macOS / Linux
|
|
52
|
+
bash scripts/fetch-deps.sh all
|
|
53
|
+
BILI_LOOK_MIRROR=cn bash scripts/fetch-deps.sh all
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
脚本是幂等的:目标已存在就跳过,`-Force` 重下(Windows);`-DryRun` 只打印命令、不下载。装 venv 时会验证包能不能 import,上次装到一半的半成品会被自动重建。`-Target model` 依赖 ASR 环境先装好(脚本会提示)。
|
|
57
|
+
|
|
58
|
+
**GitHub 下载不动怎么办**:ffmpeg 目标要从 GitHub 拉约 80 MB 的 BtbN 压缩包,国内直连经常断。三条替代路径:
|
|
59
|
+
|
|
60
|
+
1. 加 `-Mirror cn` 重试(走 gh-proxy);
|
|
61
|
+
2. 手动下载 `ffmpeg.exe` 放进 `deps\` 后重跑(会被跳过);
|
|
62
|
+
3. 已有 ffmpeg 就设 `BILI_LOOK_FFMPEG` 指向它。
|
|
63
|
+
|
|
64
|
+
macOS/Linux 的 ffmpeg 由系统包管理器安装(脚本只给提示),其余目标与 Windows 一致;sh 版本单项失败会继续跑完再汇总。
|
|
65
|
+
|
|
66
|
+
## 凭证
|
|
67
|
+
|
|
68
|
+
**匿名也能用**:元数据、评论、抽帧、弹幕都不需要登录。
|
|
69
|
+
|
|
70
|
+
只有「拿 AI 字幕」需要登录,二选一:
|
|
71
|
+
|
|
72
|
+
1. `login`:插件出二维码,手机 B 站扫码。凭证存插件 `deps/credential.json`,不出本机。
|
|
73
|
+
2. `--cred <文件路径>`:读一份现成的 `{sessdata, bili_jct, buvid3, buvid4}` JSON。
|
|
74
|
+
|
|
75
|
+
凭证取值:给了 `--cred` 就只用它(读不到就按匿名跑,不会偷偷换回 `deps/credential.json`);没给才用 `deps/credential.json`;都没有就匿名。
|
|
76
|
+
|
|
77
|
+
> 这份文件等于你的账号登录态。别分享、别提交、别上传。
|
|
78
|
+
|
|
79
|
+
## 命令
|
|
80
|
+
|
|
81
|
+
| 命令 | 做什么 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `view <BV或链接或URL>` | 元数据 + 简介 + 热评 |
|
|
84
|
+
| `peek <BV或链接>` | 元数据 + 热评 + 5 张均匀帧(最便宜;仅 B 站) |
|
|
85
|
+
| `sub <BV>` | AI 字幕 + 三判据(字/秒、关键词命中、时长) |
|
|
86
|
+
| `comments <BV> [N]` | 评论区,默认按时间倒序 |
|
|
87
|
+
| `asr <BV或URL>` | 本地转写音频 |
|
|
88
|
+
| `frames <BV或URL> [--step 秒] [--ocr]` | 抽帧;`--ocr` 顺带识别画面文字 |
|
|
89
|
+
| `ocr <BV>` | 抽帧并 OCR |
|
|
90
|
+
| `text <BV或URL>` | 四级链:字幕 → 转写 → 抽帧 OCR |
|
|
91
|
+
| `sheet <BV> [--grid 3x3]` | 均匀挑 9/16 帧拼成一张图 |
|
|
92
|
+
| `substrip <BV> [--step 秒]` | 底部字幕竖拼成一张图 |
|
|
93
|
+
| `danmaku <BV>` | 弹幕热区(按时间桶统计峰值) |
|
|
94
|
+
| `search <关键词> [--days 3]` | 搜视频 |
|
|
95
|
+
| `hot [--limit 10]` | 综合热门榜 |
|
|
96
|
+
| `login [--check] [--poll]` | 扫码登录 |
|
|
97
|
+
| `clean` | 清空临时目录 `%TEMP%\bili-watch` |
|
|
98
|
+
|
|
99
|
+
工具侧等价入口:`bili_watch` 工具(参数 `command` + `bv`),或 `/bili` 命令。
|
|
100
|
+
|
|
101
|
+
## 通用视频 URL
|
|
102
|
+
|
|
103
|
+
`view` / `text` / `asr` / `frames` 接受非 B 站 URL(YouTube、X、直链 mp4 等),走 yt-dlp:
|
|
104
|
+
|
|
105
|
+
- 把 `yt-dlp.exe` 放进 `deps/`,或设 `BILI_LOOK_YTDLP`,或让它在 PATH 里。
|
|
106
|
+
- 下载按 URL 哈希隔离在 `%TEMP%\bili-watch\`,不会串目录。
|
|
107
|
+
- `peek` / `sub` / `comments` / `danmaku` / `sheet` / `substrip` / `ocr` 只支持 B 站。
|
|
108
|
+
|
|
109
|
+
## 降级链
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
AI 字幕(需登录)
|
|
113
|
+
↓ 没有字幕轨
|
|
114
|
+
本地转写(可选依赖)
|
|
115
|
+
↓ 没装 / 转写质量差(< 2 字/秒)
|
|
116
|
+
抽帧 + OCR(可选依赖)
|
|
117
|
+
↓ 没装
|
|
118
|
+
读图(模型自己看帧)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
每一步都会打印「为什么降级」,不会静默跳过。
|
|
122
|
+
|
|
123
|
+
## 环境变量
|
|
124
|
+
|
|
125
|
+
| 变量 | 作用 |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `BILI_LOOK_PROJ` | ASR/OCR 环境目录(默认插件 `deps/`) |
|
|
128
|
+
| `BILI_LOOK_FFMPEG` | ffmpeg 可执行文件路径 |
|
|
129
|
+
| `BILI_LOOK_YTDLP` | yt-dlp 可执行文件路径 |
|
|
130
|
+
| `BILI_LOOK_SEEN` | `seen.md` 去重文件路径(默认 `deps/seen.md`) |
|
|
131
|
+
|
|
132
|
+
## 成本
|
|
133
|
+
|
|
134
|
+
- 字幕一次请求,最便宜。
|
|
135
|
+
- 抽帧会下载视频(360p)并跑 ffmpeg,CPU 占用可以用 `--step` 调低频率;脚本已限 `-threads 2`。
|
|
136
|
+
- 九宫格/字幕带是把多帧压成一张图,省 token。
|
|
137
|
+
|
|
138
|
+
## 风险
|
|
139
|
+
|
|
140
|
+
- **凭证**:等同账号登录态,泄露即被盗。只存本机、不进日志。
|
|
141
|
+
- **频率**:正常看几条没问题;批量扫几百条会触发风控。
|
|
142
|
+
- **只读**:插件只读,不点赞、不评论、不留观看历史。
|
|
143
|
+
|
|
144
|
+
## 许可
|
|
145
|
+
|
|
146
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/deps/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# 运行期依赖目录:Python venv、模型缓存、yt-dlp、凭证都放在这里(默认不进 git)。
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
_bili-asr.py —— 把本地音频/视频文件转成文字(faster-whisper,CPU int8)
|
|
5
|
+
|
|
6
|
+
为什么单独有这个文件:
|
|
7
|
+
B 站很多新视频没有 AI 字幕轨(刚发布几小时还没转写完,或者 UP 没开),
|
|
8
|
+
只靠 B 站字幕会把一大半候选判成「不可信」。本地转写能补上这一块。
|
|
9
|
+
|
|
10
|
+
口径:
|
|
11
|
+
· 只跑 CPU + int8:本机 PyPI 版 ctranslate2 没有 CUDA 算子,装 GPU 版要另配 cuDNN,不值得。
|
|
12
|
+
· 模型默认 small(约 250 MB,一个 3 分钟视频约 30–45 秒);可 --model 换 base/tiny。
|
|
13
|
+
· 模型与缓存落 D 盘项目目录(HF_HOME),不碰用户主目录。
|
|
14
|
+
· 音频由调用方先转成 16 kHz 单声道 wav 再传进来——这里不碰网络、不碰 B 站接口。
|
|
15
|
+
|
|
16
|
+
用法:
|
|
17
|
+
.venv-asr\\Scripts\\python.exe _bili-asr.py <音频或视频文件> [-o 输出.txt] [--model small] [--lang zh]
|
|
18
|
+
不给 -o 就往 stdout 打正文(调用方直接取 stdout 更省事)。
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# ⭐ 必须在 import faster_whisper 之前设好,否则它会先按默认去连 HuggingFace
|
|
25
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
os.environ.setdefault("HF_HOME", os.path.join(_HERE, ".hf-home"))
|
|
27
|
+
# 国内直连 huggingface.co 基本下不动,默认走镜像;已经能直连的可以自己改回
|
|
28
|
+
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
|
29
|
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
|
30
|
+
# ⭐ 必须关掉 Xet:新版本 huggingface_hub 默认走 cas-server.xethub.hf.co,
|
|
31
|
+
# 镜像站不代理那条重建接口,会报 CAS Client Error: 401 Unauthorized(2026-10-17 实测)。
|
|
32
|
+
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
|
33
|
+
# 避免 OpenMP 在多核上抢线程把机器拖死
|
|
34
|
+
os.environ.setdefault("OMP_NUM_THREADS", str(max(1, (os.cpu_count() or 4) // 2)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
ap = argparse.ArgumentParser()
|
|
39
|
+
ap.add_argument("src", help="音频或视频文件路径")
|
|
40
|
+
ap.add_argument("-o", "--out", help="输出文本文件;不给就打印到 stdout")
|
|
41
|
+
ap.add_argument("--model", default=os.environ.get("BILI_ASR_MODEL", "small"),
|
|
42
|
+
help="Whisper 模型:tiny / base / small / medium(默认 small)")
|
|
43
|
+
ap.add_argument("--lang", default="zh", help="语言,默认 zh(自动识别可传 auto)")
|
|
44
|
+
args = ap.parse_args()
|
|
45
|
+
|
|
46
|
+
if not os.path.isfile(args.src):
|
|
47
|
+
print("[asr] 找不到文件:" + args.src, file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from faster_whisper import WhisperModel
|
|
52
|
+
except Exception as e: # noqa: BLE001
|
|
53
|
+
print("[asr] 导入 faster_whisper 失败:%s" % e, file=sys.stderr)
|
|
54
|
+
return 3
|
|
55
|
+
|
|
56
|
+
print("[asr] 加载模型 %s(首次会下载,之后本地读)…" % args.model, file=sys.stderr)
|
|
57
|
+
try:
|
|
58
|
+
model = WhisperModel(args.model, device="cpu", compute_type="int8")
|
|
59
|
+
except Exception as e: # noqa: BLE001
|
|
60
|
+
print("[asr] 加载模型失败:%s" % e, file=sys.stderr)
|
|
61
|
+
return 4
|
|
62
|
+
|
|
63
|
+
print("[asr] 开始转写…", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
# ⭐ 先自己解码一次 + 量 VAD 语音时长,用来区分两种「空转写」(2026-10-17 实测踩到):
|
|
66
|
+
# · 纯 BGM 骑车视频,音频很响(mean -14.4 dB)但没人说话 → 语音时长≈0,空是内容结果;
|
|
67
|
+
# · 转写环境坏了(模型没加载/依赖缺/静音检测误杀)→ 明明有语音段却零字。
|
|
68
|
+
# 只看「有没有音频/时长多少」判不出来,必须看 VAD 到底找到了多少「像人说话」的片段。
|
|
69
|
+
speech_sec = 0.0
|
|
70
|
+
audio = None
|
|
71
|
+
vad_ok = False
|
|
72
|
+
try:
|
|
73
|
+
import numpy as np # noqa: F401
|
|
74
|
+
from faster_whisper.audio import decode_audio
|
|
75
|
+
from faster_whisper.vad import VadOptions, get_speech_timestamps
|
|
76
|
+
|
|
77
|
+
audio = decode_audio(args.src, sampling_rate=16000)
|
|
78
|
+
stamps = get_speech_timestamps(audio, VadOptions(min_silence_duration_ms=400))
|
|
79
|
+
speech_sec = sum(float(s["end"]) - float(s["start"]) for s in stamps) / 16000.0
|
|
80
|
+
vad_ok = True
|
|
81
|
+
print("[asr] VAD 语音段 %d 个,共 %.1f 秒" % (len(stamps), speech_sec), file=sys.stderr)
|
|
82
|
+
except Exception as e: # noqa: BLE001
|
|
83
|
+
# 量不出来不影响转写本身,但调用方会少一条判据,必须显式标出来(不能静默)
|
|
84
|
+
print("[asr] VAD 不可用:预检失败(不影响转写,但空文本将无法归因):%s" % e, file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
segments, info = model.transcribe(
|
|
88
|
+
args.src,
|
|
89
|
+
language=None if args.lang == "auto" else args.lang,
|
|
90
|
+
vad_filter=True, # 掐掉静音段,纯画面/纯音乐的视频会得到空文本
|
|
91
|
+
vad_parameters={"min_silence_duration_ms": 400},
|
|
92
|
+
beam_size=5,
|
|
93
|
+
condition_on_previous_text=False, # 防止长视频里一段错就带着后面全错
|
|
94
|
+
)
|
|
95
|
+
parts = []
|
|
96
|
+
for seg in segments:
|
|
97
|
+
t = (seg.text or "").strip()
|
|
98
|
+
if t:
|
|
99
|
+
parts.append(t)
|
|
100
|
+
except Exception as e: # noqa: BLE001
|
|
101
|
+
print("[asr] 转写失败:%s" % e, file=sys.stderr)
|
|
102
|
+
return 5
|
|
103
|
+
|
|
104
|
+
text = "".join(parts).strip()
|
|
105
|
+
print("[asr] 完成:%d 段,%d 字,语种 %s" % (len(parts), len(text), info.language), file=sys.stderr)
|
|
106
|
+
# 给调用方的机器可读标记:转写为空时靠它判「没人说话」还是「环境坏了」
|
|
107
|
+
# vad_ok 单独标出来:取不到语音秒数时调用方要报「VAD 不可用」,不能当成 0 秒
|
|
108
|
+
print("[asr] vad_ok=%d" % (1 if vad_ok else 0), file=sys.stderr)
|
|
109
|
+
print("[asr] speech_seconds=%.2f" % speech_sec, file=sys.stderr)
|
|
110
|
+
# ⭐ 段数给调用方做辅助判据:
|
|
111
|
+
# 总时长短不代表是噪声——「说了两句短话」每句 1 秒、两段,和「咳嗽一声」一段,
|
|
112
|
+
# 总时长都是 2 秒。段数 ≥2 说明有过多次发声,更可能是短话而非噪声。
|
|
113
|
+
print("[asr] speech_segments=%d" % (len(stamps) if vad_ok else -1), file=sys.stderr)
|
|
114
|
+
print("[asr] chars=%d" % len(text), file=sys.stderr)
|
|
115
|
+
if args.out:
|
|
116
|
+
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
|
|
117
|
+
f.write(text + "\n")
|
|
118
|
+
print("[asr] 已写入:" + args.out, file=sys.stderr)
|
|
119
|
+
else:
|
|
120
|
+
sys.stdout.write(text + "\n")
|
|
121
|
+
# 空文本是结果不是错误:纯音乐 / 纯录屏 / 无声演示的视频就该是空的
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
package/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-bili-watch —— B 站「看内容」的命令与工具入口。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独做插件:看视频的动作是固定的(取字幕 / 转写 / 抽帧 OCR),
|
|
5
|
+
* 让人敲 /bili 少花推理 token;同时注册一个 bili_watch 工具,模型自己也能调,
|
|
6
|
+
* 不会因为「只做了命令」而变成没人调度。
|
|
7
|
+
*
|
|
8
|
+
* 两侧执行同一份 lookup:spawn 插件自带的只读工具
|
|
9
|
+
* <插件目录>\tools\look.mjs
|
|
10
|
+
* 只读、默认匿名:不修改任何业务状态文件,可反复调用。
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const LOOK = join(HERE, 'tools', 'look.mjs')
|
|
19
|
+
const COMMANDS = ['search', 'hot', 'peek', 'view', 'sub', 'comments', 'danmaku', 'asr', 'frames', 'sheet', 'substrip', 'ocr', 'text', 'login', 'clean']
|
|
20
|
+
|
|
21
|
+
const name = 'dsh-bili-watch'
|
|
22
|
+
const inject = ['commands', 'tools']
|
|
23
|
+
|
|
24
|
+
function runLook(args, { timeoutMs = 1_200_000 } = {}) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let out = ''
|
|
27
|
+
let err = ''
|
|
28
|
+
let done = false
|
|
29
|
+
const finish = (res) => {
|
|
30
|
+
if (done) return
|
|
31
|
+
done = true
|
|
32
|
+
resolve(res)
|
|
33
|
+
}
|
|
34
|
+
let child
|
|
35
|
+
try {
|
|
36
|
+
child = spawn(process.execPath, [LOOK, ...args], { windowsHide: true })
|
|
37
|
+
} catch (e) {
|
|
38
|
+
finish({ code: -1, out: '', err: `启动失败:${e?.message ?? e}` })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
try {
|
|
43
|
+
child.kill()
|
|
44
|
+
} catch {}
|
|
45
|
+
finish({ code: -2, out: out.trim(), err: (err + '\n[超时]').trim() })
|
|
46
|
+
}, timeoutMs)
|
|
47
|
+
child.stdout.on('data', (d) => {
|
|
48
|
+
out += d
|
|
49
|
+
})
|
|
50
|
+
child.stderr.on('data', (d) => {
|
|
51
|
+
err += d
|
|
52
|
+
})
|
|
53
|
+
child.on('error', (e) => {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
finish({ code: -1, out: out.trim(), err: String(e?.message ?? e) })
|
|
56
|
+
})
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
clearTimeout(timer)
|
|
59
|
+
finish({ code, out: out.trim(), err: err.trim() })
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 把 "text BV1xx --cred path --step 2" 拆成 { command, bv, extra }。 */
|
|
65
|
+
function parseCommandLine(raw) {
|
|
66
|
+
const tokens = String(raw ?? '')
|
|
67
|
+
.trim()
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
const command = tokens.shift()
|
|
71
|
+
const bv = tokens.length && !tokens[0].startsWith('--') ? tokens.shift() : ''
|
|
72
|
+
const extra = []
|
|
73
|
+
let step = ''
|
|
74
|
+
let cred = ''
|
|
75
|
+
while (tokens.length) {
|
|
76
|
+
const t = tokens.shift()
|
|
77
|
+
if (t === '--step' && tokens.length) {
|
|
78
|
+
step = tokens.shift()
|
|
79
|
+
extra.push('--step', step)
|
|
80
|
+
} else if (t === '--p' && tokens.length) {
|
|
81
|
+
extra.push('--p', tokens.shift())
|
|
82
|
+
} else if (t === '--cred' && tokens.length) {
|
|
83
|
+
cred = tokens.shift()
|
|
84
|
+
extra.push('--cred', cred)
|
|
85
|
+
} else if (t === '--days' && tokens.length) {
|
|
86
|
+
extra.push('--days', tokens.shift())
|
|
87
|
+
} else if (t === '--limit' && tokens.length) {
|
|
88
|
+
extra.push('--limit', tokens.shift())
|
|
89
|
+
} else if (t === '--order' && tokens.length) {
|
|
90
|
+
extra.push('--order', tokens.shift())
|
|
91
|
+
} else if (t === '--grid' && tokens.length) {
|
|
92
|
+
extra.push('--grid', tokens.shift())
|
|
93
|
+
} else if (t === '--out' && tokens.length) {
|
|
94
|
+
extra.push('--out', tokens.shift())
|
|
95
|
+
} else if (t === '--ffmpeg' && tokens.length) {
|
|
96
|
+
extra.push('--ffmpeg', tokens.shift())
|
|
97
|
+
} else if (t === '--fresh' || t === '--ocr' || t === '--poll' || t === '--check' || t === '--fast' || t === '--scene' || t === '--mark-seen') {
|
|
98
|
+
extra.push(t)
|
|
99
|
+
}
|
|
100
|
+
// 其余 token 直接丢弃(命令本身不接受别的参数)
|
|
101
|
+
}
|
|
102
|
+
return { command, bv, extra, step, cred }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function lookup({ command, bv, extra = [] }) {
|
|
106
|
+
if (!command || !COMMANDS.includes(command)) {
|
|
107
|
+
return { ok: false, text: `用法:/bili <${COMMANDS.join('|')}> <BV> [--step 2] [--cred 凭证路径]` }
|
|
108
|
+
}
|
|
109
|
+
if (!['clean', 'login', 'hot'].includes(command) && !bv) {
|
|
110
|
+
return { ok: false, text: `缺少 BV 号,用法:/bili ${command} <BV>` }
|
|
111
|
+
}
|
|
112
|
+
const args =
|
|
113
|
+
command === 'clean'
|
|
114
|
+
? ['clean']
|
|
115
|
+
: command === 'login'
|
|
116
|
+
? ['login', ...extra]
|
|
117
|
+
: command === 'hot'
|
|
118
|
+
? ['hot', ...extra]
|
|
119
|
+
: [command, bv, ...extra]
|
|
120
|
+
const r = await runLook(args)
|
|
121
|
+
const text = (r.out || r.err || '').trim() || '(没有输出)'
|
|
122
|
+
return { ok: r.code === 0, text }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function apply(ctx) {
|
|
126
|
+
// ① 斜杠命令:人敲,确定性执行,不花推理 token
|
|
127
|
+
ctx.commands.register({
|
|
128
|
+
definitionId: 'dsh-bili-watch',
|
|
129
|
+
name: 'bili',
|
|
130
|
+
description: '看 B 站视频内容:字幕 → 本地转写 → 抽帧 OCR,输出最可信的一份文本',
|
|
131
|
+
input: {
|
|
132
|
+
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]',
|
|
133
|
+
},
|
|
134
|
+
handler: async (invocation) => {
|
|
135
|
+
const { command, bv, extra } = parseCommandLine(invocation?.rawInput)
|
|
136
|
+
const r = await lookup({ command, bv, extra })
|
|
137
|
+
return { kind: r.ok ? 'success' : 'error', text: r.text }
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ② 同名工具:模型可调,需要看视频时自己来,不依赖用户敲命令
|
|
142
|
+
// 必须用 defineTool 包一层:ctx.tools.register 收的是编译好的 tool 对象,
|
|
143
|
+
// 直接传 options 会让发往模型的 parameters 变成 null(报 INVALID_REQUEST: schema must be type "object")。
|
|
144
|
+
ctx.tools.register(defineTool({
|
|
145
|
+
name: 'bili_watch',
|
|
146
|
+
description:
|
|
147
|
+
'用户给出 B 站视频链接 / BV 号,要我看、总结、判断这条视频讲了什么、有没有坑、值不值得看时使用。读取一个 B 站视频的内容材料(只读、匿名)。优先级:字幕 → 本地 ASR 转写 → 抽帧 OCR。command=peek 最便宜:只拿标题/简介/热评 + 均匀 5 帧,用来先判断是口播还是画面,再决定走 text 还是 ocr;command=text 会按优先级自动降级并返回最可信的一份文本(推荐);view=元数据与热评;comments=评论区;asr=强制本地转写;frames=抽帧(可加 ocr=true 顺带识别画面文字);ocr=抽帧并识别画面文字;sub=只要字幕;search=按关键词搜视频(配 days 可只留最近 N 天发布的,结果按播放量降序,用来看「这几天有什么值得看」);hot=综合热门榜(B 站按热度排的前 N 条);danmaku=弹幕热区(观众在哪一段反应最集中,判断名场面在哪);sheet=把 N 帧拼成九宫格一张图(看图省 token);substrip=把底部字幕逐段竖拼成一张图(硬字幕视频最省)。多 P 视频用 p 指定第几个分P(默认 P1)。拿到文本后由你负责理解与总结。',
|
|
148
|
+
parameters: {
|
|
149
|
+
command: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
required: true,
|
|
152
|
+
enum: ['search', 'hot', 'peek', 'view', 'sub', 'comments', 'danmaku', 'asr', 'frames', 'sheet', 'substrip', 'ocr', 'text'],
|
|
153
|
+
description: '要看哪一类材料;想要「讲了什么」的原料用 text,想先看有什么可看的用 search/hot。',
|
|
154
|
+
},
|
|
155
|
+
keyword: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description: 'command=search 时的搜索关键词(如「DeepSeek」「AI 编程」)。',
|
|
158
|
+
},
|
|
159
|
+
days: {
|
|
160
|
+
type: 'number',
|
|
161
|
+
description: 'command=search 时只保留最近 N 天发布的(再按播放量降序);不传则不限时间。',
|
|
162
|
+
},
|
|
163
|
+
bv: { type: 'string', description: 'BV 号(如 BV1xx411c7mD),或直接贴 B 站分享链接(https://www.bilibili.com/video/BV.../?...),会自动提取 BV 号。search/hot 不需要。' },
|
|
164
|
+
p: {
|
|
165
|
+
type: 'number',
|
|
166
|
+
description: '多 P 视频的分P序号(1 起);不传默认 P1。',
|
|
167
|
+
},
|
|
168
|
+
step: {
|
|
169
|
+
type: 'number',
|
|
170
|
+
description: '抽帧间隔秒数(frames/ocr/text 用);不传则按视频时长自适应(长视频更疏,省 CPU)。',
|
|
171
|
+
},
|
|
172
|
+
ocr: {
|
|
173
|
+
type: 'boolean',
|
|
174
|
+
description: 'command=frames 时是否顺带识别画面文字。',
|
|
175
|
+
},
|
|
176
|
+
fast: {
|
|
177
|
+
type: 'boolean',
|
|
178
|
+
description: 'command=text 时跳过本地转写,字幕拿不到就直接抽帧 OCR(画面类视频更快)。',
|
|
179
|
+
},
|
|
180
|
+
scene: {
|
|
181
|
+
type: 'boolean',
|
|
182
|
+
description: 'command=frames 时按画面变化点抽帧(吸附到变化最多的时刻,比等间隔更准,默认关)。',
|
|
183
|
+
},
|
|
184
|
+
grid: {
|
|
185
|
+
type: 'string',
|
|
186
|
+
description: 'command=sheet 时的格子,如 3x3 或 4x4(默认 3x3)。',
|
|
187
|
+
},
|
|
188
|
+
limit: {
|
|
189
|
+
type: 'number',
|
|
190
|
+
description: 'command=search 时最多返回几条(默认 10)、command=hot 时取前 N 条(默认 10)。',
|
|
191
|
+
},
|
|
192
|
+
cred: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
description: '可选的 B 站凭证 JSON 路径(只在 command=sub/text 时需要;不传就匿名,匿名拿不到 AI 字幕轨)。',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
output: {
|
|
198
|
+
// 注意:DSH 的 output.schema 是「author-facing spec」,编译时 allowRequired=false,
|
|
199
|
+
// 写 required(无论属性级还是顶层数组)会抛 JsonSchemaError: unsupported JSON schema。
|
|
200
|
+
schema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
additionalProperties: false,
|
|
203
|
+
properties: {
|
|
204
|
+
ok: { type: 'boolean', description: '执行是否成功' },
|
|
205
|
+
text: { type: 'string', description: '输出文本' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '').slice(0, 24000) }],
|
|
209
|
+
},
|
|
210
|
+
execute: async (args) => {
|
|
211
|
+
const extra = []
|
|
212
|
+
if (typeof args.p === 'number' && Number.isInteger(args.p) && args.p > 0) extra.push('--p', String(args.p))
|
|
213
|
+
if (typeof args.step === 'number' && Number.isFinite(args.step) && args.step > 0) extra.push('--step', String(args.step))
|
|
214
|
+
if (args.ocr) extra.push('--ocr')
|
|
215
|
+
if (args.fast) extra.push('--fast')
|
|
216
|
+
if (args.scene) extra.push('--scene')
|
|
217
|
+
if (typeof args.grid === 'string' && args.grid.trim()) extra.push('--grid', args.grid.trim())
|
|
218
|
+
if (typeof args.days === 'number' && args.days > 0) extra.push('--days', String(Math.floor(args.days)))
|
|
219
|
+
if (args.command === 'hot') {
|
|
220
|
+
if (typeof args.limit === 'number' && args.limit > 0) extra.push('--limit', String(Math.floor(args.limit)))
|
|
221
|
+
const r = await lookup({ command: 'hot', bv: '', extra })
|
|
222
|
+
return { ok: r.ok, text: r.text }
|
|
223
|
+
}
|
|
224
|
+
if (typeof args.limit === 'number' && args.limit > 0) extra.push('--limit', String(Math.floor(args.limit)))
|
|
225
|
+
if (typeof args.cred === 'string' && args.cred.trim()) extra.push('--cred', args.cred.trim())
|
|
226
|
+
const target = args.command === 'search' ? String(args.keyword ?? '').trim() : String(args.bv ?? '').trim()
|
|
227
|
+
if (args.command === 'search' && !target) return { ok: false, text: 'search 需要 keyword 参数(要搜什么词)' }
|
|
228
|
+
if (!target) return { ok: false, text: '缺少 BV 号或分享链接' }
|
|
229
|
+
const r = await lookup({ command: args.command, bv: target, extra })
|
|
230
|
+
return { ok: r.ok, text: r.text }
|
|
231
|
+
},
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { apply, inject, name }
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-watch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "A DSH plugin that lets an AI agent \"watch\" Bilibili videos: AI subtitles, local ASR, frame OCR, danmaku heatmap, comments, and nine-grid frame sheets.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"bili-watch": "tools/look.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"bundle": {
|
|
18
|
+
"patch": "./cordis.patch.yml"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.js",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"tools/",
|
|
25
|
+
"scripts/",
|
|
26
|
+
"deps/.gitkeep",
|
|
27
|
+
"deps/_bili-asr.py"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"bilibili",
|
|
31
|
+
"video",
|
|
32
|
+
"asr",
|
|
33
|
+
"ocr",
|
|
34
|
+
"danmaku",
|
|
35
|
+
"dsh",
|
|
36
|
+
"deepseek",
|
|
37
|
+
"subtitles"
|
|
38
|
+
],
|
|
39
|
+
"author": "Sacredbluedragon",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"qrcode": "^1.5.4"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.3.0-0",
|
|
57
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@deepseek-ai/dsh": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@deepseek-ai/dsh-tools": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|