dsh-bili-watch 0.2.2 → 0.2.4
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 +48 -17
- package/client.js +386 -0
- package/index.js +500 -14
- package/package.json +10 -1
- package/scripts/fetch-deps.ps1 +2 -2
- package/scripts/fetch-deps.sh +2 -2
- package/tools/look.mjs +7 -7
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# dsh-bili-watch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
将一条 B 站视频变成模型能读的内容:元数据、评论、弹幕、AI 字幕、本地转写、抽帧 OCR。可作为 DSH 插件运行,也可直接当命令行工具。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
取内容的顺序是先用文字(字幕或转写),文字不够则抽帧看画面。只读元数据、评论、弹幕、搜索、热榜不需要任何额外依赖;抽帧相关命令需要 ffmpeg,未安装会明确报错并指出装法。
|
|
6
|
+
|
|
7
|
+
> [!IMPORTANT]
|
|
8
|
+
> 面板要重启 DSH 后才出现,插件只在启动时加载。
|
|
9
|
+
> 抽帧类命令(`frames` / `ocr` / `sheet` / `substrip` / `text` 的兜底档)需要 ffmpeg,没有会直接报错,不会静默降级。
|
|
10
|
+
> 登录只影响 AI 字幕;其余命令匿名可用。
|
|
6
11
|
|
|
7
12
|
## 安装
|
|
8
13
|
|
|
@@ -12,12 +17,14 @@
|
|
|
12
17
|
dsh plugin --profile web add dsh-bili-watch
|
|
13
18
|
```
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
**第二步易漏**:打开 profile 的 `package.json`,在 `dsh.profile.bundles` 数组里加一行 `"dsh-bili-watch"`。`dsh plugin add` 只写 `dependencies`,不写 bundles。漏了这步插件装了但不生效,也不报错。改完重启 DSH。
|
|
16
21
|
|
|
17
22
|
也可以把本仓库直接放进 `profiles/<名字>/node_modules/dsh-bili-watch`。
|
|
18
23
|
|
|
19
24
|
插件依赖 DSH 自带的 `@deepseek-ai/dsh-tools`(peer,optional)。profile 里缺这个包时 `index.js` 会报 `ERR_MODULE_NOT_FOUND`,先补装再重启。
|
|
20
25
|
|
|
26
|
+
插件尚未收录到插件市场,目前只能按上面的命令装。
|
|
27
|
+
|
|
21
28
|
### 只当命令行工具
|
|
22
29
|
|
|
23
30
|
```bash
|
|
@@ -27,12 +34,34 @@ bili-watch view BV1EPnG6DEJC
|
|
|
27
34
|
|
|
28
35
|
不装全局也行:clone 仓库后在目录里跑 `node tools/look.mjs view BV1EPnG6DEJC`。命令行的依赖和插件完全一样,`scripts/fetch-deps` 同样可用。
|
|
29
36
|
|
|
37
|
+
### 更新与卸载
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
dsh plugin --profile web add dsh-bili-watch@latest
|
|
41
|
+
dsh plugin --profile web remove dsh-bili-watch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
升级后重启 DSH。卸载不会删除 `deps/` 里的模型、环境与凭证;要清理就在面板里逐项删,或手动删目录。
|
|
45
|
+
|
|
46
|
+
## 插件面板
|
|
47
|
+
|
|
48
|
+
装进 DSH 后,设置 → 插件 → `dsh-bili-watch` 的详情页有一块面板,常规操作无需去对话里敲命令:
|
|
49
|
+
|
|
50
|
+
- **依赖状态**:ffmpeg、yt-dlp、ASR 环境、OCR 环境、转写模型、登录凭证逐项列出是否就绪、实际路径、占用体积。缺的点击行末「下载」,后台跑 `scripts/fetch-deps`(体积是估算值,实际以跑完为准)。
|
|
51
|
+
- **扫码登录**:凭证未登录时点「重新扫码」,面板里弹出二维码,手机 B 站扫完自动刷新。凭证保存在本机 `deps/credential.json`。
|
|
52
|
+
- **删除**:面板按实际路径判断——装在插件目录里的(yt-dlp、转写模型、登录凭证)可直接删;装在插件目录外的(比如复用系统 ffmpeg 或已有的 Python 环境)按钮置灰,旁边标出它实际在哪。
|
|
53
|
+
- **批量**:标题行的「批量下载 / 批量清理」打开勾选清单,默认勾上所有可操作的项,删除前会再确认一次。
|
|
54
|
+
- **抽帧缓存**:显示当前占用与文件数,「立即清除」一键清空;不点则会按规则自动清理——保留天数(默认 7 天)与总量上限(默认 2048 MB),超过则从最旧的删起。规则存在 `deps/cache-config.json`,插件启动和打开面板时各检查一次。
|
|
55
|
+
- **最近一次调用**:显示命令、时间、成败,用来判断上一次看视频是否卡住。
|
|
56
|
+
|
|
57
|
+
装完第一次重启 DSH,面板才会出现。
|
|
58
|
+
|
|
30
59
|
## 依赖
|
|
31
60
|
|
|
32
61
|
| 能力 | 需要 | 没有时 |
|
|
33
62
|
|---|---|---|
|
|
34
63
|
| 元数据 / 评论 / 弹幕 / 搜索 / 热榜 | 无(匿名 HTTP) | — |
|
|
35
|
-
| 抽帧 / 九宫格 / 字幕带 | `ffmpeg` | 报错并提示装法(`text`
|
|
64
|
+
| 抽帧 / 九宫格 / 字幕带 | `ffmpeg` | 报错并提示装法(`text` 的兜底档就是抽帧,所以同样要 ffmpeg) |
|
|
36
65
|
| AI 字幕 | 一次扫码登录(SESSDATA) | 返回「无字幕轨」,自动降级 |
|
|
37
66
|
| 本地转写 | Python 3.10+ 与 faster-whisper(`deps/.venv-asr`) | 跳过,自动降级 |
|
|
38
67
|
| 画面 OCR | Python 3.10+ 与 rapidocr_onnxruntime(`deps/.venv-ocr`) | 跳过,只剩基础抽帧 |
|
|
@@ -40,7 +69,7 @@ bili-watch view BV1EPnG6DEJC
|
|
|
40
69
|
|
|
41
70
|
环境:Node.js ≥ 22。装在 DSH 里时 DSH 自带 node,命令行跑也一样。
|
|
42
71
|
|
|
43
|
-
`ffmpeg` 查找顺序:`--ffmpeg` 参数 → `
|
|
72
|
+
`ffmpeg` 查找顺序:`--ffmpeg` 参数 → `BILI_WATCH_FFMPEG` → 插件 `deps/` → PATH。
|
|
44
73
|
|
|
45
74
|
`deps/` 用来放运行期文件:Python venv、模型、yt-dlp、凭证,默认不进 git(见 `.gitignore`)。ASR 和 OCR 的 Python 环境是可选的,不装也能用,只是少这两档。
|
|
46
75
|
|
|
@@ -61,16 +90,16 @@ powershell -ExecutionPolicy Bypass -File scripts\fetch-deps.ps1 -Target model
|
|
|
61
90
|
```bash
|
|
62
91
|
# macOS / Linux
|
|
63
92
|
bash scripts/fetch-deps.sh all
|
|
64
|
-
|
|
93
|
+
BILI_WATCH_MIRROR=cn bash scripts/fetch-deps.sh all
|
|
65
94
|
```
|
|
66
95
|
|
|
67
|
-
|
|
96
|
+
脚本可以重复跑:目标已存在就跳过;Windows 用 `-Force` 重下、`-DryRun` 只打印命令(sh 版没有这两个参数,要重装就删掉目标目录)。装 venv 时会验证包能不能 import,上次装到一半的会被重建。`-Target model` 需要 ASR 环境先装好,脚本会提示。
|
|
68
97
|
|
|
69
98
|
**从 GitHub 下载很慢或断**:ffmpeg 要从 GitHub 拉约 80 MB 的压缩包,国内直连经常断。三条替代路径:
|
|
70
99
|
|
|
71
100
|
1. 加 `-Mirror cn` 重试(走 gh-proxy);
|
|
72
101
|
2. 手动下载 `ffmpeg.exe` 放进 `deps\` 再重跑(会被跳过);
|
|
73
|
-
3. 已经有 ffmpeg 就设 `
|
|
102
|
+
3. 已经有 ffmpeg 就设 `BILI_WATCH_FFMPEG` 指向它。
|
|
74
103
|
|
|
75
104
|
macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目标和 Windows 一样;sh 版本单项失败会跑完再汇总。
|
|
76
105
|
|
|
@@ -85,7 +114,7 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
85
114
|
|
|
86
115
|
凭证顺序:给了 `--cred` 就只用它,读不到就按匿名跑;没给 `--cred` 才用 `deps/credential.json`;两者都没有就匿名。
|
|
87
116
|
|
|
88
|
-
|
|
117
|
+
这份文件等同账号登录态。请勿分享、提交、上传。
|
|
89
118
|
|
|
90
119
|
## 命令
|
|
91
120
|
|
|
@@ -94,7 +123,7 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
94
123
|
| `view <BV或链接或URL>` | 元数据 + 简介 + 热评 |
|
|
95
124
|
| `peek <BV或链接>` | 元数据 + 热评 + 5 张均匀帧(最便宜;仅 B 站) |
|
|
96
125
|
| `sub <BV>` | AI 字幕 + 三判据(字/秒、关键词命中、时长) |
|
|
97
|
-
| `comments <BV> [N]` |
|
|
126
|
+
| `comments <BV> [N]` | 评论区,默认按热度排序 |
|
|
98
127
|
| `asr <BV或URL>` | 本地转写音频 |
|
|
99
128
|
| `frames <BV或URL> [--step 秒] [--ocr]` | 抽帧;`--ocr` 顺带识别画面文字 |
|
|
100
129
|
| `ocr <BV>` | 抽帧并 OCR |
|
|
@@ -115,7 +144,7 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
115
144
|
|
|
116
145
|
`view` / `text` / `asr` / `frames` 接受非 B 站 URL(YouTube、X、直链 mp4 等),走 yt-dlp:
|
|
117
146
|
|
|
118
|
-
- `yt-dlp.exe` 放进 `deps/`,或设 `
|
|
147
|
+
- `yt-dlp.exe` 放进 `deps/`,或设 `BILI_WATCH_YTDLP`,或让它出现在 PATH 里。
|
|
119
148
|
- 下载按 URL 哈希隔离在 `%TEMP%\bili-watch\`,不会串目录。
|
|
120
149
|
- `peek` / `sub` / `comments` / `danmaku` / `sheet` / `substrip` / `ocr` 只支持 B 站。
|
|
121
150
|
|
|
@@ -137,10 +166,10 @@ AI 字幕(需登录)
|
|
|
137
166
|
|
|
138
167
|
| 变量 | 作用 |
|
|
139
168
|
|---|---|
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
169
|
+
| `BILI_WATCH_PROJ` | ASR/OCR 环境目录(默认插件 `deps/`) |
|
|
170
|
+
| `BILI_WATCH_FFMPEG` | ffmpeg 可执行文件路径 |
|
|
171
|
+
| `BILI_WATCH_YTDLP` | yt-dlp 可执行文件路径 |
|
|
172
|
+
| `BILI_WATCH_SEEN` | `seen.md` 去重文件路径(默认 `deps/seen.md`) |
|
|
144
173
|
|
|
145
174
|
## 成本
|
|
146
175
|
|
|
@@ -148,11 +177,13 @@ AI 字幕(需登录)
|
|
|
148
177
|
- 抽帧会下载视频(360p)并跑 ffmpeg。`--step` 调低抽帧频率,脚本另外限了 `-threads 2`。
|
|
149
178
|
- 九宫格和字幕带把多帧压成一张图,省 token。
|
|
150
179
|
|
|
151
|
-
##
|
|
180
|
+
## 限制与风险
|
|
152
181
|
|
|
182
|
+
- **不支持**:点赞、评论、发弹幕、留观看历史;B 站侧只读。本机会写登录凭证、抽帧缓存与 `seen.md` 去重文件。
|
|
183
|
+
- **只支持 B 站与 yt-dlp 能下的站点**;`peek` / `sub` / `comments` / `danmaku` / `sheet` / `substrip` / `ocr` 仅限 B 站。
|
|
153
184
|
- **凭证**:等同账号登录态,泄露即被盗。只存本机,不进日志。
|
|
154
185
|
- **频率**:正常看几条没问题;批量扫几百条会触发风控。
|
|
155
|
-
-
|
|
186
|
+
- **任意 http(s) 地址都会被请求**:通用 URL 走 yt-dlp 时不做内网地址过滤,别拿它请求内网服务。
|
|
156
187
|
|
|
157
188
|
## 许可
|
|
158
189
|
|
package/client.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-bili-watch —— 浏览器半端:插件详情页里的「依赖与状态」面板。
|
|
3
|
+
*
|
|
4
|
+
* 面板回答三件事(不碰业务流程):
|
|
5
|
+
* ① 这个插件依赖的 ffmpeg / yt-dlp / ASR / OCR / 模型 / 登录凭证,现在配好了没有;
|
|
6
|
+
* ② 缺的可以一键下载(后台跑插件自带的 fetch-deps 脚本,带预估体积);
|
|
7
|
+
* ③ 抽帧缓存占了多少、可以一键清,平时按「天数 + 总量阈值」自动清。
|
|
8
|
+
*
|
|
9
|
+
* 后端只走 host 的 /api/bili-watch(读盘 + 清缓存 + 起下载),面板不存任何凭证。
|
|
10
|
+
* 挂载点:plugins.bundle.config(key = 包名),因此设置页里看不到它,只在插件详情页出现。
|
|
11
|
+
*/
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: 'dsh-bili-watch',
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
const React = require('react')
|
|
16
|
+
const h = React.createElement
|
|
17
|
+
|
|
18
|
+
// 包名:生产插件是 dsh-bili-watch,发布树派生时改成 dsh-bili-watch
|
|
19
|
+
const PKG = 'dsh-bili-watch'
|
|
20
|
+
const API = '/api/bili-watch'
|
|
21
|
+
const jget = async (p) => (await fetch(API + p, { credentials: 'same-origin' })).json()
|
|
22
|
+
const jpost = async (p, body) =>
|
|
23
|
+
(await fetch(API + p, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
credentials: 'same-origin',
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: JSON.stringify(body ?? {}),
|
|
28
|
+
})).json()
|
|
29
|
+
|
|
30
|
+
const CSS = `
|
|
31
|
+
[data-bl-page]{display:flex;flex-direction:column;gap:12px;padding:4px 2px 10px;max-width:720px;font-size:13px;}
|
|
32
|
+
[data-bl-head]{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;}
|
|
33
|
+
[data-bl-title]{font-size:14px;font-weight:600;}
|
|
34
|
+
[data-bl-sub]{font-size:12px;color:var(--dsw-alias-label-tertiary,#8a9099);}
|
|
35
|
+
[data-bl-sec]{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.18));padding-top:10px;}
|
|
36
|
+
[data-bl-sec-title]{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#6b7280);}
|
|
37
|
+
[data-bl-row]{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:28px;}
|
|
38
|
+
[data-bl-dot]{width:8px;height:8px;border-radius:50%;flex:none;}
|
|
39
|
+
[data-bl-dot="ok"]{background:#3c9a5f;}
|
|
40
|
+
[data-bl-dot="bad"]{background:#d9534f;}
|
|
41
|
+
[data-bl-name]{min-width:150px;flex:none;}
|
|
42
|
+
[data-bl-detail]{flex:1;min-width:0;color:var(--dsw-alias-label-tertiary,#8a9099);font-size:12px;word-break:break-all;}
|
|
43
|
+
[data-bl-size]{flex:none;color:var(--dsw-alias-label-tertiary,#8a9099);font-size:12px;min-width:64px;text-align:right;}
|
|
44
|
+
[data-bl-btn]{font-size:12px;min-height:26px;padding:2px 10px;border-radius:6px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));background:transparent;color:inherit;flex:none;}
|
|
45
|
+
[data-bl-btn:disabled]{opacity:.45;cursor:default;}
|
|
46
|
+
[data-bl-input]{width:64px;font-size:12px;padding:2px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));background:transparent;color:inherit;}
|
|
47
|
+
[data-bl-note]{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a9099);}
|
|
48
|
+
[data-bl-bad]{color:#d9534f;}
|
|
49
|
+
[data-bl-good]{color:#3c9a5f;}
|
|
50
|
+
[data-bl-qrbox]{display:flex;gap:16px;align-items:center;flex-wrap:wrap;}
|
|
51
|
+
[data-bl-qr]{flex:none;width:220px;height:220px;background:#fff;padding:6px;border-radius:8px;}
|
|
52
|
+
[data-bl-qr svg]{width:100%;height:100%;display:block;}
|
|
53
|
+
[data-bl-qrside]{display:flex;flex-direction:column;gap:10px;max-width:320px;}
|
|
54
|
+
[data-bl-sechead]{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
|
|
55
|
+
[data-bl-sechead] [data-bl-sec-title]{margin-right:auto;}
|
|
56
|
+
[data-bl-dlg]{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.25));border-radius:8px;padding:10px;gap:8px;}
|
|
57
|
+
[data-bl-dlgrow]{display:flex;align-items:center;gap:8px;font-size:12px;min-height:24px;}
|
|
58
|
+
[data-bl-dlgrow] input{accent-color:#3c9a5f;}
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
const fmtBytes = (b) => {
|
|
62
|
+
const n = Number(b) || 0
|
|
63
|
+
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB'
|
|
64
|
+
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'
|
|
65
|
+
return (n / 1024).toFixed(1) + ' KB'
|
|
66
|
+
}
|
|
67
|
+
const fmtTime = (t) => {
|
|
68
|
+
if (!t) return '—'
|
|
69
|
+
const d = new Date(t)
|
|
70
|
+
return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString('zh-CN', { hour12: false })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function DepRow({ d, busy, onDownload, onDelete }) {
|
|
74
|
+
const canDownload = !d.ok && d.id !== 'credential'
|
|
75
|
+
return h('div', { 'data-bl-row': '' },
|
|
76
|
+
h('span', { 'data-bl-dot': d.ok ? 'ok' : 'bad' }),
|
|
77
|
+
h('span', { 'data-bl-name': '' }, d.name),
|
|
78
|
+
h('span', { 'data-bl-detail': '' }, d.ok ? d.detail : h('span', { 'data-bl-bad': '' }, d.detail)),
|
|
79
|
+
h('span', { 'data-bl-size': '' }, d.ok ? fmtBytes(d.size) : d.estimate),
|
|
80
|
+
d.id === 'credential'
|
|
81
|
+
? h('button', { 'data-bl-btn': '', onClick: () => onDownload('login') }, d.ok ? '重新扫码' : '去扫码登录')
|
|
82
|
+
: canDownload
|
|
83
|
+
? h('button', { 'data-bl-btn': '', disabled: busy, onClick: () => onDownload(d.id) }, busy ? '下载中…' : '一键下载')
|
|
84
|
+
: null,
|
|
85
|
+
d.id === 'credential'
|
|
86
|
+
? h('button', { 'data-bl-btn': '', disabled: !d.ok, onClick: () => onDelete(d.id) }, '清除凭证')
|
|
87
|
+
: d.removable
|
|
88
|
+
? h('button', { 'data-bl-btn': '', onClick: () => onDelete(d.id) }, '删除')
|
|
89
|
+
: h('button', { 'data-bl-btn': '', disabled: true, title: d.removableWhy || '不在插件目录内' }, '删除'),
|
|
90
|
+
d.id === 'credential' && d.path ? h('span', { 'data-bl-note': '' }, '文件:' + d.path) : null,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Panel() {
|
|
95
|
+
const [st, setSt] = React.useState(null)
|
|
96
|
+
const [err, setErr] = React.useState('')
|
|
97
|
+
const [msg, setMsg] = React.useState('')
|
|
98
|
+
const [busy, setBusy] = React.useState('')
|
|
99
|
+
const [days, setDays] = React.useState('7')
|
|
100
|
+
const [maxMb, setMaxMb] = React.useState('2048')
|
|
101
|
+
const [qr, setQr] = React.useState(null)
|
|
102
|
+
const [qrState, setQrState] = React.useState('')
|
|
103
|
+
const [dlg, setDlg] = React.useState(null) // 'download' | 'clean'
|
|
104
|
+
const [picked, setPicked] = React.useState([])
|
|
105
|
+
|
|
106
|
+
const load = React.useCallback(async () => {
|
|
107
|
+
try {
|
|
108
|
+
const j = await jget('/status')
|
|
109
|
+
if (j.ok) {
|
|
110
|
+
setSt(j.data)
|
|
111
|
+
setErr('')
|
|
112
|
+
setDays(String(j.data.cache?.days ?? 7))
|
|
113
|
+
setMaxMb(String(j.data.cache?.maxMb ?? 2048))
|
|
114
|
+
} else {
|
|
115
|
+
setErr(j.why || '状态读取失败')
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
setErr(String(e?.message || e))
|
|
119
|
+
}
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
load()
|
|
124
|
+
const t = setInterval(load, 8000)
|
|
125
|
+
return () => clearInterval(t)
|
|
126
|
+
}, [load])
|
|
127
|
+
|
|
128
|
+
// 二维码出现后每 2.5 秒问一次扫码结果;成功就刷新依赖状态(凭证会变绿)
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!qr?.key) return undefined
|
|
131
|
+
let stop = false
|
|
132
|
+
const tick = async () => {
|
|
133
|
+
if (stop) return
|
|
134
|
+
try {
|
|
135
|
+
const j = await jpost('/login/poll', { key: qr.key })
|
|
136
|
+
if (stop) return
|
|
137
|
+
if (j.state === 'ok') {
|
|
138
|
+
stop = true
|
|
139
|
+
setQr(null)
|
|
140
|
+
setQrState('ok')
|
|
141
|
+
setMsg('登录成功,凭证已保存。')
|
|
142
|
+
await load()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (j.state === 'expired' || j.state === 'error') {
|
|
146
|
+
stop = true
|
|
147
|
+
setQrState(j.state)
|
|
148
|
+
setMsg(j.why || (j.state === 'expired' ? '二维码已过期,点「重新扫码」再试。' : '轮询失败'))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
setQrState(j.state)
|
|
152
|
+
setTimeout(tick, 2500)
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (!stop) setTimeout(tick, 4000)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const t = setTimeout(tick, 1500)
|
|
158
|
+
return () => {
|
|
159
|
+
stop = true
|
|
160
|
+
clearTimeout(t)
|
|
161
|
+
}
|
|
162
|
+
}, [qr, load])
|
|
163
|
+
|
|
164
|
+
const startLogin = async () => {
|
|
165
|
+
setMsg('')
|
|
166
|
+
setQr(null)
|
|
167
|
+
setQrState('loading')
|
|
168
|
+
try {
|
|
169
|
+
const j = await jpost('/login/qr', {})
|
|
170
|
+
if (j.ok) {
|
|
171
|
+
setQr(j.data)
|
|
172
|
+
setQrState('waiting')
|
|
173
|
+
} else {
|
|
174
|
+
setQrState('error')
|
|
175
|
+
setMsg('生成二维码失败:' + (j.why || '未知错误'))
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
setQrState('error')
|
|
179
|
+
setMsg(String(e?.message || e))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const download = async (target) => {
|
|
184
|
+
if (target === 'login') {
|
|
185
|
+
await startLogin()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
setBusy(target)
|
|
189
|
+
setMsg('')
|
|
190
|
+
try {
|
|
191
|
+
const j = await jpost('/download', { target })
|
|
192
|
+
setMsg(j.ok ? `已开始下载(后台跑,日志 ${j.data.log})。下载较久,过一会儿点刷新看状态。` : (j.data?.why || j.why || '启动失败'))
|
|
193
|
+
} catch (e) {
|
|
194
|
+
setMsg(String(e?.message || e))
|
|
195
|
+
} finally {
|
|
196
|
+
setBusy('')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const saveCache = async () => {
|
|
201
|
+
try {
|
|
202
|
+
const j = await jpost('/cache/clear', { days: Number(days), maxMb: Number(maxMb) })
|
|
203
|
+
setMsg(j.ok ? `缓存已清(${j.data.removed} 个文件 / ${fmtBytes(j.data.bytes)});之后按 ${days} 天、${maxMb} MB 自动清` : (j.why || '清缓存失败'))
|
|
204
|
+
await load()
|
|
205
|
+
} catch (e) {
|
|
206
|
+
setMsg(String(e?.message || e))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const saveRule = async () => {
|
|
211
|
+
try {
|
|
212
|
+
await jpost('/cache/clear', { days: Number(days), maxMb: Number(maxMb) })
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rows = st?.deps || []
|
|
217
|
+
// 打开批量弹层:默认全勾选(可下载 = 缺的且不是凭证;可清理 = 插件目录内且已存在)
|
|
218
|
+
const openDlg = (kind) => {
|
|
219
|
+
if (kind === 'download') setPicked(rows.filter((d) => d.id !== 'credential' && !d.ok).map((d) => d.id))
|
|
220
|
+
else setPicked(rows.filter((d) => d.removable && d.ok).map((d) => d.id))
|
|
221
|
+
setDlg(kind)
|
|
222
|
+
}
|
|
223
|
+
const toggle = (id) => setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]))
|
|
224
|
+
|
|
225
|
+
const confirmDlg = async () => {
|
|
226
|
+
const kind = dlg
|
|
227
|
+
setDlg(null)
|
|
228
|
+
if (kind === 'download') {
|
|
229
|
+
if (!picked.length) {
|
|
230
|
+
setMsg('没有勾选可下载的项。')
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
for (const t of picked) {
|
|
234
|
+
try {
|
|
235
|
+
await jpost('/download', { target: t })
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
setMsg(`已启动 ${picked.length} 个下载(后台跑,过一会儿刷新看状态)。`)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (!picked.length) {
|
|
242
|
+
setMsg('没有勾选可清理的项。')
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const j = await jpost('/delete', { targets: picked })
|
|
247
|
+
const d = j.data || {}
|
|
248
|
+
const skipped = (d.skipped || []).map((s) => `${s.id}(${s.why})`).join('、')
|
|
249
|
+
setMsg(`已删除:${(d.deleted || []).join('、') || '无'}` + (skipped ? `;跳过:${skipped}` : ''))
|
|
250
|
+
await load()
|
|
251
|
+
} catch (e) {
|
|
252
|
+
setMsg('删除失败:' + String(e?.message || e))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const delOne = async (target) => {
|
|
257
|
+
const row = rows.find((d) => d.id === target)
|
|
258
|
+
const label = target === 'credential' ? '登录凭证(删了要重新扫码)' : (row?.name || target)
|
|
259
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
260
|
+
if (!window.confirm(`确定删除 ${label}?`)) return
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const j = await jpost('/delete', { targets: [target] })
|
|
264
|
+
const d = j.data || {}
|
|
265
|
+
const skipped = (d.skipped || []).map((s) => s.why).join('、')
|
|
266
|
+
setMsg(d.deleted?.length ? `已删除 ${target}` : `没删:${skipped || '未知原因'}`)
|
|
267
|
+
await load()
|
|
268
|
+
} catch (e) {
|
|
269
|
+
setMsg('删除失败:' + String(e?.message || e))
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (err) return h('div', { 'data-bl-page': '' }, h('div', { 'data-bl-bad': '' }, '状态读取失败:' + err))
|
|
274
|
+
if (!st) return h('div', { 'data-bl-page': '' }, h('div', { 'data-bl-note': '' }, '读取中…'))
|
|
275
|
+
|
|
276
|
+
const deps = st.deps || []
|
|
277
|
+
const cache = st.cache || { bytes: 0, files: 0 }
|
|
278
|
+
const lastRun = st.lastRun || {}
|
|
279
|
+
|
|
280
|
+
return h('div', { 'data-bl-page': '' },
|
|
281
|
+
h('div', { 'data-bl-head': '' },
|
|
282
|
+
h('span', { 'data-bl-title': '' }, '依赖与状态'),
|
|
283
|
+
h('span', { 'data-bl-sub': '' }, '缺什么点下载,装好后这里会变绿。')),
|
|
284
|
+
h('div', { 'data-bl-sec': '' },
|
|
285
|
+
h('div', { 'data-bl-sechead': '' },
|
|
286
|
+
h('span', { 'data-bl-sec-title': '' }, '运行依赖'),
|
|
287
|
+
h('button', { 'data-bl-btn': '', onClick: () => openDlg('download') }, '批量下载'),
|
|
288
|
+
h('button', { 'data-bl-btn': '', onClick: () => openDlg('clean') }, '批量清理')),
|
|
289
|
+
...deps.map((d) => h(DepRow, { key: d.id, d, busy: busy === d.id, onDownload: download, onDelete: delOne })),
|
|
290
|
+
h('div', { 'data-bl-note': '' },
|
|
291
|
+
'下载在后台跑,用插件自带的 fetch-deps 脚本;体积是预估,实际以跑完为准。')),
|
|
292
|
+
dlg
|
|
293
|
+
? h('div', { 'data-bl-sec': '', 'data-bl-dlg': '' },
|
|
294
|
+
h('span', { 'data-bl-sec-title': '' }, dlg === 'download' ? '批量下载(默认全勾选,可取消)' : '批量清理(默认全勾选,可取消)'),
|
|
295
|
+
...(dlg === 'download'
|
|
296
|
+
? deps
|
|
297
|
+
.filter((d) => d.id !== 'credential')
|
|
298
|
+
.map((d) =>
|
|
299
|
+
h('label', { 'data-bl-dlgrow': '' },
|
|
300
|
+
h('input', { type: 'checkbox', checked: picked.includes(d.id), disabled: d.ok, onChange: () => toggle(d.id) }),
|
|
301
|
+
h('span', { 'data-bl-name': '' }, d.name),
|
|
302
|
+
h('span', { 'data-bl-size': '' }, d.estimate),
|
|
303
|
+
d.ok ? h('span', { 'data-bl-good': '' }, '已就绪,跳过') : null),
|
|
304
|
+
)
|
|
305
|
+
: deps.map((d) =>
|
|
306
|
+
h('label', { 'data-bl-dlgrow': '' },
|
|
307
|
+
h('input', {
|
|
308
|
+
type: 'checkbox',
|
|
309
|
+
checked: picked.includes(d.id),
|
|
310
|
+
disabled: !d.removable || !d.ok,
|
|
311
|
+
onChange: () => toggle(d.id),
|
|
312
|
+
}),
|
|
313
|
+
h('span', { 'data-bl-name': '' }, d.name),
|
|
314
|
+
h('span', { 'data-bl-size': '' }, d.path || ''),
|
|
315
|
+
d.removable
|
|
316
|
+
? d.ok
|
|
317
|
+
? h('span', { 'data-bl-good': '' }, '可清理')
|
|
318
|
+
: h('span', { 'data-bl-note': '' }, '没有可删的')
|
|
319
|
+
: h('span', { 'data-bl-note': '' }, d.removableWhy || '不在插件目录内'),
|
|
320
|
+
),
|
|
321
|
+
)),
|
|
322
|
+
h('div', { 'data-bl-row': '' },
|
|
323
|
+
h('button', { 'data-bl-btn': '', onClick: confirmDlg }, dlg === 'download' ? '开始下载' : '确认删除'),
|
|
324
|
+
h('button', { 'data-bl-btn': '', onClick: () => setDlg(null) }, '取消')))
|
|
325
|
+
: null,
|
|
326
|
+
qr
|
|
327
|
+
? h('div', { 'data-bl-sec': '' },
|
|
328
|
+
h('span', { 'data-bl-sec-title': '' }, '扫码登录'),
|
|
329
|
+
h('div', { 'data-bl-qrbox': '' },
|
|
330
|
+
h('span', { 'data-bl-qr': '', dangerouslySetInnerHTML: { __html: qr.svg } }),
|
|
331
|
+
h('div', { 'data-bl-qrside': '' },
|
|
332
|
+
h('div', { 'data-bl-note': '' },
|
|
333
|
+
qrState === 'scanned'
|
|
334
|
+
? '已扫码,请在手机 B 上点确认。'
|
|
335
|
+
: qrState === 'loading'
|
|
336
|
+
? '正在生成二维码…'
|
|
337
|
+
: '用手机 B 站「扫一扫」扫左边这张;扫完这里会自动变绿。'),
|
|
338
|
+
h('button', { 'data-bl-btn': '', onClick: () => { setQr(null); setQrState('') } }, '取消'))))
|
|
339
|
+
: null,
|
|
340
|
+
h('div', { 'data-bl-sec': '' },
|
|
341
|
+
h('span', { 'data-bl-sec-title': '' }, '抽帧缓存'),
|
|
342
|
+
h('div', { 'data-bl-row': '' },
|
|
343
|
+
h('span', { 'data-bl-name': '' }, '当前占用'),
|
|
344
|
+
h('span', { 'data-bl-detail': '' }, `${fmtBytes(cache.bytes)} / ${cache.files} 个文件`),
|
|
345
|
+
h('button', { 'data-bl-btn': '', onClick: saveCache }, '立即清除')),
|
|
346
|
+
h('div', { 'data-bl-row': '' },
|
|
347
|
+
h('span', { 'data-bl-name': '' }, '自动清理规则'),
|
|
348
|
+
h('span', { 'data-bl-detail': '' },
|
|
349
|
+
'保留 ',
|
|
350
|
+
h('input', { 'data-bl-input': '', value: days, onChange: (e) => setDays(e.target.value), inputMode: 'numeric' }),
|
|
351
|
+
' 天;总量超过 ',
|
|
352
|
+
h('input', { 'data-bl-input': '', value: maxMb, onChange: (e) => setMaxMb(e.target.value), inputMode: 'numeric' }),
|
|
353
|
+
' MB 时从最旧的删。'),
|
|
354
|
+
h('button', { 'data-bl-btn': '', onClick: saveRule }, '保存规则')),
|
|
355
|
+
h('div', { 'data-bl-note': '' },
|
|
356
|
+
'不点也会按上面的规则自动清(每次打开面板与插件启动时检查一次)。')),
|
|
357
|
+
h('div', { 'data-bl-sec': '' },
|
|
358
|
+
h('span', { 'data-bl-sec-title': '' }, '最近一次调用'),
|
|
359
|
+
h('div', { 'data-bl-row': '' },
|
|
360
|
+
h('span', { 'data-bl-name': '' }, lastRun.command || '—'),
|
|
361
|
+
h('span', { 'data-bl-detail': '' }, lastRun.time ? `${fmtTime(lastRun.time)}(${lastRun.ok ? '成功' : '失败'})` : '还没有记录'),
|
|
362
|
+
h('button', { 'data-bl-btn': '', onClick: load }, '刷新'))),
|
|
363
|
+
msg ? h('div', { 'data-bl-note': '' }, msg) : null,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
inject: ['slots'],
|
|
369
|
+
apply(ctx) {
|
|
370
|
+
const style = document.createElement('style')
|
|
371
|
+
style.setAttribute('data-plugin-css', 'dsh-bili-watch')
|
|
372
|
+
style.textContent = CSS
|
|
373
|
+
document.head.appendChild(style)
|
|
374
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => style.remove(), 'dsh-bili-watch: 面板样式')
|
|
375
|
+
|
|
376
|
+
const slots = (typeof ctx.get === 'function' ? ctx.get('slots') : undefined) || ctx.slots
|
|
377
|
+
if (!slots) return
|
|
378
|
+
// 插件详情页的配置区:key 用包名,按包页面 dispatch(见 Slot 清单 plugins.bundle.config)
|
|
379
|
+
slots.inject('plugins.bundle.config', () => slots.register(
|
|
380
|
+
{ name: 'plugins.bundle.config', key: PKG },
|
|
381
|
+
Panel,
|
|
382
|
+
))
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
})
|
package/index.js
CHANGED
|
@@ -7,19 +7,59 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 两侧执行同一份 lookup:spawn 插件自带的只读工具
|
|
9
9
|
* <插件目录>\tools\look.mjs
|
|
10
|
-
*
|
|
10
|
+
* 只读、匿名,不改任何业务状态。
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
13
15
|
import { dirname, join } from 'node:path'
|
|
14
16
|
import { fileURLToPath } from 'node:url'
|
|
15
17
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
18
|
+
import QRCode from 'qrcode'
|
|
16
19
|
|
|
17
20
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
18
21
|
const LOOK = join(HERE, 'tools', 'look.mjs')
|
|
19
22
|
const COMMANDS = ['search', 'hot', 'peek', 'view', 'sub', 'comments', 'danmaku', 'asr', 'frames', 'sheet', 'substrip', 'ocr', 'text', 'login', 'clean']
|
|
20
23
|
|
|
24
|
+
// 设置页端点读的落点,与 tools/look.mjs 同源(look.mjs:41/52/54/46/48/51)
|
|
25
|
+
const DEPS = join(HERE, 'deps')
|
|
26
|
+
const PROJ = process.env.BILI_WATCH_PROJ || DEPS
|
|
27
|
+
const CRED_FILE = join(DEPS, 'credential.json')
|
|
28
|
+
const LAST_RUN = join(DEPS, 'last-run.json')
|
|
29
|
+
const CACHE = join(tmpdir(), 'bili-watch')
|
|
30
|
+
const PY_DIR = process.platform === 'win32' ? 'Scripts' : 'bin'
|
|
31
|
+
const PY_EXE = process.platform === 'win32' ? 'python.exe' : 'python'
|
|
32
|
+
const VENV_ASR = [join(DEPS, '.venv-asr', PY_DIR, PY_EXE), join(PROJ, '.venv-asr', PY_DIR, PY_EXE)]
|
|
33
|
+
const VENV_OCR = [join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(PROJ, '.venv-ocr', PY_DIR, PY_EXE)]
|
|
34
|
+
const FFMPEG_CANDS = [
|
|
35
|
+
process.env.BILI_WATCH_FFMPEG,
|
|
36
|
+
join(DEPS, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
|
37
|
+
].filter(Boolean)
|
|
38
|
+
const HF_HOME = [join(DEPS, '.hf-home'), join(PROJ, '.hf-home')]
|
|
39
|
+
const SCRIPTS = join(HERE, 'scripts')
|
|
40
|
+
const CACHE_CFG = join(DEPS, 'cache-config.json')
|
|
41
|
+
const DEFAULT_CACHE_DAYS = 7
|
|
42
|
+
const DEFAULT_CACHE_MAX_MB = 2048
|
|
43
|
+
const DOWNLOAD_LOG = join(DEPS, 'download.log')
|
|
44
|
+
// 一键下载的预估体积,只给面板显示,不参与逻辑
|
|
45
|
+
const ESTIMATES = {
|
|
46
|
+
ffmpeg: '约 94 MB',
|
|
47
|
+
ytdlp: '约 17 MB',
|
|
48
|
+
asr: '约 294 MB(venv)',
|
|
49
|
+
ocr: '约 263 MB(venv)',
|
|
50
|
+
model: '约 464 MB',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 面板内扫码登录(与 tools/look.mjs 的 login 同一套接口,凭证落同一个文件)
|
|
54
|
+
const UA =
|
|
55
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
|
56
|
+
const QR_GEN = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
|
|
57
|
+
const QR_POLL = (key) =>
|
|
58
|
+
'https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=' + encodeURIComponent(key) + '&source=main-fe-header'
|
|
59
|
+
const SPI = 'https://api.bilibili.com/x/frontend/finger/spi'
|
|
60
|
+
|
|
21
61
|
const name = 'dsh-bili-watch'
|
|
22
|
-
const inject = ['commands', 'tools']
|
|
62
|
+
const inject = ['commands', 'tools', 'webServer']
|
|
23
63
|
|
|
24
64
|
function runLook(args, { timeoutMs = 1_200_000 } = {}) {
|
|
25
65
|
return new Promise((resolve) => {
|
|
@@ -61,6 +101,382 @@ function runLook(args, { timeoutMs = 1_200_000 } = {}) {
|
|
|
61
101
|
})
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
// ── 面板内扫码登录 ─────────────────────────────────────────────────────────
|
|
105
|
+
/** 从 poll 响应的 Set-Cookie 里挑登录需要的几个 cookie。 */
|
|
106
|
+
function pickLoginCookies(list) {
|
|
107
|
+
const jar = {}
|
|
108
|
+
const want = { SESSDATA: 'sessdata', bili_jct: 'bili_jct', DedeUserID: 'dedeuserid', buvid3: 'buvid3', buvid4: 'buvid4' }
|
|
109
|
+
for (const raw of list ?? []) {
|
|
110
|
+
const first = String(raw).split(';')[0] || ''
|
|
111
|
+
const i = first.indexOf('=')
|
|
112
|
+
if (i <= 0) continue
|
|
113
|
+
const k = first.slice(0, i).trim()
|
|
114
|
+
const v = first.slice(i + 1).trim()
|
|
115
|
+
if (want[k] && v) jar[want[k]] = v
|
|
116
|
+
}
|
|
117
|
+
return jar
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** poll 往往只给 buvid3,buvid4 走 finger/spi 补。 */
|
|
121
|
+
async function fillBuvids(jar) {
|
|
122
|
+
if (jar.buvid3 && jar.buvid4) return jar
|
|
123
|
+
try {
|
|
124
|
+
const r = await fetch(SPI, { headers: { 'User-Agent': UA } })
|
|
125
|
+
const j = await r.json()
|
|
126
|
+
if (!jar.buvid3 && j?.data?.b_3) jar.buvid3 = j.data.b_3
|
|
127
|
+
if (!jar.buvid4 && j?.data?.b_4) jar.buvid4 = j.data.b_4
|
|
128
|
+
} catch {}
|
|
129
|
+
return jar
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 生成扫码登录二维码:返回 qrcode_key 与可直接插进页面的 SVG。 */
|
|
133
|
+
async function loginQr() {
|
|
134
|
+
const r = await fetch(QR_GEN, { headers: { 'User-Agent': UA } })
|
|
135
|
+
const j = await r.json()
|
|
136
|
+
const d = j?.data
|
|
137
|
+
if (!d?.url || !d?.qrcode_key) return { ok: false, why: j?.message || '生成二维码失败' }
|
|
138
|
+
let svg = ''
|
|
139
|
+
try {
|
|
140
|
+
svg = await QRCode.toString(d.url, { type: 'svg', margin: 1, width: 220, errorCorrectionLevel: 'M' })
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return { ok: false, why: `二维码渲染失败:${e?.message ?? e}` }
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, data: { key: d.qrcode_key, url: d.url, svg } }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 轮询一次扫码结果(前端每 2.5 秒调一次)。
|
|
149
|
+
* 成功时把凭证写进 deps\credential.json——与 tools/look.mjs login --poll 同一个格式。
|
|
150
|
+
*/
|
|
151
|
+
async function loginPollOnce(key) {
|
|
152
|
+
if (!key) return { ok: false, state: 'error', why: '缺少二维码 key' }
|
|
153
|
+
const res = await fetch(QR_POLL(key), { headers: { 'User-Agent': UA }, redirect: 'manual' })
|
|
154
|
+
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
155
|
+
let body = {}
|
|
156
|
+
try {
|
|
157
|
+
body = await res.json()
|
|
158
|
+
} catch {}
|
|
159
|
+
const code = body?.data?.code
|
|
160
|
+
if (code === 0) {
|
|
161
|
+
const jar = await fillBuvids(pickLoginCookies(setCookies))
|
|
162
|
+
if (!jar.sessdata) return { ok: false, state: 'error', why: '登录成功但响应里没有 SESSDATA' }
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(
|
|
165
|
+
CRED_FILE,
|
|
166
|
+
JSON.stringify(
|
|
167
|
+
{
|
|
168
|
+
sessdata: jar.sessdata,
|
|
169
|
+
bili_jct: jar.bili_jct || '',
|
|
170
|
+
buvid3: jar.buvid3 || '',
|
|
171
|
+
buvid4: jar.buvid4 || '',
|
|
172
|
+
mid: jar.dedeuserid || '',
|
|
173
|
+
note: '扫码登录(插件面板),只用于读取 AI 字幕',
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
},
|
|
176
|
+
null,
|
|
177
|
+
2,
|
|
178
|
+
),
|
|
179
|
+
'utf8',
|
|
180
|
+
)
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return { ok: false, state: 'error', why: `凭证写入失败:${e?.message ?? e}` }
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, state: 'ok', data: { mid: jar.dedeuserid || '' } }
|
|
185
|
+
}
|
|
186
|
+
const map = { 86101: 'waiting', 86090: 'scanned', 86038: 'expired' }
|
|
187
|
+
return { ok: false, state: map[code] ?? 'error', why: code === undefined ? body?.message || 'poll 返回异常' : '' }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── 设置页状态(只读端点 + 清缓存按钮)──────────────────────────────────────
|
|
191
|
+
function writeLastRun(command, code) {
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(LAST_RUN, JSON.stringify({ time: new Date().toISOString(), command, code, ok: code === 0 }), 'utf8')
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readLastRun() {
|
|
198
|
+
try {
|
|
199
|
+
const j = JSON.parse(readFileSync(LAST_RUN, 'utf8'))
|
|
200
|
+
return { time: j.time ?? null, command: j.command ?? null, ok: j.ok ?? null }
|
|
201
|
+
} catch {
|
|
202
|
+
return { time: null, command: null, ok: null }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function credState() {
|
|
207
|
+
if (!existsSync(CRED_FILE)) return { exists: false, state: 'missing', updatedAt: null, ageDays: null }
|
|
208
|
+
try {
|
|
209
|
+
const j = JSON.parse(readFileSync(CRED_FILE, 'utf8'))
|
|
210
|
+
const t = j.updatedAt || statSync(CRED_FILE).mtime.toISOString()
|
|
211
|
+
const ageDays = Math.max(0, Math.floor((Date.now() - new Date(t).getTime()) / 86400000))
|
|
212
|
+
return { exists: true, state: ageDays > 30 ? 'expired' : 'ok', updatedAt: t, ageDays }
|
|
213
|
+
} catch {
|
|
214
|
+
return { exists: true, state: 'unknown', updatedAt: null, ageDays: null }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function dirStat(dir) {
|
|
219
|
+
let bytes = 0
|
|
220
|
+
let files = 0
|
|
221
|
+
const walk = (d) => {
|
|
222
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
223
|
+
const p = join(d, e.name)
|
|
224
|
+
if (e.isDirectory()) walk(p)
|
|
225
|
+
else {
|
|
226
|
+
files += 1
|
|
227
|
+
try {
|
|
228
|
+
bytes += statSync(p).size
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
walk(dir)
|
|
235
|
+
} catch {}
|
|
236
|
+
return { bytes, files }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 缓存目录里每个文件的 { path, size, mtime },用于按天数/总量清理。 */
|
|
240
|
+
function walkEntries(dir) {
|
|
241
|
+
const out = []
|
|
242
|
+
const walk = (d) => {
|
|
243
|
+
let list = []
|
|
244
|
+
try {
|
|
245
|
+
list = readdirSync(d, { withFileTypes: true })
|
|
246
|
+
} catch {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
for (const e of list) {
|
|
250
|
+
const p = join(d, e.name)
|
|
251
|
+
if (e.isDirectory()) walk(p)
|
|
252
|
+
else {
|
|
253
|
+
try {
|
|
254
|
+
const st = statSync(p)
|
|
255
|
+
out.push({ path: p, size: st.size, mtime: st.mtimeMs })
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
walk(dir)
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cacheCfg() {
|
|
265
|
+
try {
|
|
266
|
+
const j = JSON.parse(readFileSync(CACHE_CFG, 'utf8'))
|
|
267
|
+
return {
|
|
268
|
+
days: Number.isFinite(j.days) ? Math.max(1, j.days) : DEFAULT_CACHE_DAYS,
|
|
269
|
+
maxMb: Number.isFinite(j.maxMb) ? Math.max(100, j.maxMb) : DEFAULT_CACHE_MAX_MB,
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
return { days: DEFAULT_CACHE_DAYS, maxMb: DEFAULT_CACHE_MAX_MB }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeCacheCfg(days, maxMb) {
|
|
277
|
+
try {
|
|
278
|
+
writeFileSync(CACHE_CFG, JSON.stringify({ days, maxMb }), 'utf8')
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 缓存自动清理:先按天数删过期文件,再按总量从最旧删到阈值内。
|
|
284
|
+
* 返回被删统计,供面板显示。
|
|
285
|
+
*/
|
|
286
|
+
function autoCleanCache() {
|
|
287
|
+
if (!existsSync(CACHE)) return { removed: 0, bytes: 0 }
|
|
288
|
+
const { days, maxMb } = cacheCfg()
|
|
289
|
+
const cutoff = Date.now() - days * 86400000
|
|
290
|
+
const limit = maxMb * 1024 * 1024
|
|
291
|
+
const entries = walkEntries(CACHE)
|
|
292
|
+
let removed = 0
|
|
293
|
+
let bytes = 0
|
|
294
|
+
const fresh = []
|
|
295
|
+
for (const e of entries) {
|
|
296
|
+
if (e.mtime < cutoff) {
|
|
297
|
+
try {
|
|
298
|
+
rmSync(e.path, { force: true })
|
|
299
|
+
removed += 1
|
|
300
|
+
bytes += e.size
|
|
301
|
+
} catch {}
|
|
302
|
+
} else {
|
|
303
|
+
fresh.push(e)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
let total = fresh.reduce((n, e) => n + e.size, 0)
|
|
307
|
+
if (total > limit) {
|
|
308
|
+
fresh.sort((a, b) => a.mtime - b.mtime)
|
|
309
|
+
for (const e of fresh) {
|
|
310
|
+
try {
|
|
311
|
+
rmSync(e.path, { force: true })
|
|
312
|
+
removed += 1
|
|
313
|
+
bytes += e.size
|
|
314
|
+
total -= e.size
|
|
315
|
+
} catch {}
|
|
316
|
+
if (total <= limit) break
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { removed, bytes }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 面板上的依赖行:一条依赖一个状态 + 体积 + 是否需要下载。 */
|
|
323
|
+
function depRows() {
|
|
324
|
+
const ffmpeg = FFMPEG_CANDS.find((p) => existsSync(p)) || ''
|
|
325
|
+
let ffmpegSize = 0
|
|
326
|
+
try {
|
|
327
|
+
ffmpegSize = ffmpeg ? statSync(ffmpeg).size : 0
|
|
328
|
+
} catch {}
|
|
329
|
+
const ytdlp = join(DEPS, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp')
|
|
330
|
+
let ytdlpSize = 0
|
|
331
|
+
try {
|
|
332
|
+
ytdlpSize = existsSync(ytdlp) ? statSync(ytdlp).size : 0
|
|
333
|
+
} catch {}
|
|
334
|
+
const asrPython = VENV_ASR.find((p) => existsSync(p)) || ''
|
|
335
|
+
const ocrPython = VENV_OCR.find((p) => existsSync(p)) || ''
|
|
336
|
+
const model = HF_HOME.find((d) => existsSync(d)) || ''
|
|
337
|
+
const rows = [
|
|
338
|
+
{
|
|
339
|
+
id: 'ffmpeg',
|
|
340
|
+
name: 'ffmpeg(抽帧 / 取音轨)',
|
|
341
|
+
ok: Boolean(ffmpeg),
|
|
342
|
+
detail: ffmpeg || '没找到,抽帧与转写取音轨会失败',
|
|
343
|
+
path: ffmpeg,
|
|
344
|
+
removable: false,
|
|
345
|
+
removableWhy: '在插件目录外,这里不删',
|
|
346
|
+
size: ffmpegSize,
|
|
347
|
+
estimate: ESTIMATES.ffmpeg,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: 'ytdlp',
|
|
351
|
+
name: 'yt-dlp(通用 URL)',
|
|
352
|
+
ok: existsSync(ytdlp),
|
|
353
|
+
detail: existsSync(ytdlp) ? ytdlp : '没找到,非 B 站 URL 不可用',
|
|
354
|
+
path: ytdlp,
|
|
355
|
+
removable: true,
|
|
356
|
+
size: ytdlpSize,
|
|
357
|
+
estimate: ESTIMATES.ytdlp,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: 'asr',
|
|
361
|
+
name: 'ASR 环境(本地转写)',
|
|
362
|
+
ok: Boolean(asrPython),
|
|
363
|
+
detail: asrPython || '没找到 python 环境,无字幕视频无法转写',
|
|
364
|
+
path: asrPython,
|
|
365
|
+
removable: false,
|
|
366
|
+
removableWhy: '在插件目录外,这里不删',
|
|
367
|
+
size: asrPython ? dirStat(join(asrPython, '..', '..')).bytes : 0,
|
|
368
|
+
estimate: ESTIMATES.asr,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'ocr',
|
|
372
|
+
name: 'OCR 环境(画面文字)',
|
|
373
|
+
ok: Boolean(ocrPython),
|
|
374
|
+
detail: ocrPython || '没找到 python 环境,画面文字无法识别',
|
|
375
|
+
path: ocrPython,
|
|
376
|
+
removable: false,
|
|
377
|
+
removableWhy: '在插件目录外,这里不删',
|
|
378
|
+
size: ocrPython ? dirStat(join(ocrPython, '..', '..')).bytes : 0,
|
|
379
|
+
estimate: ESTIMATES.ocr,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: 'model',
|
|
383
|
+
name: '转写模型(faster-whisper small)',
|
|
384
|
+
ok: Boolean(model),
|
|
385
|
+
detail: model || '没找到模型目录,本地转写不可用',
|
|
386
|
+
path: model,
|
|
387
|
+
removable: true,
|
|
388
|
+
size: model ? dirStat(model).bytes : 0,
|
|
389
|
+
estimate: ESTIMATES.model,
|
|
390
|
+
},
|
|
391
|
+
]
|
|
392
|
+
const cred = credState()
|
|
393
|
+
rows.push({
|
|
394
|
+
id: 'credential',
|
|
395
|
+
name: 'B 站登录凭证',
|
|
396
|
+
ok: cred.state === 'ok',
|
|
397
|
+
detail: cred.exists ? `更新于 ${cred.updatedAt ?? '未知时间'}(${cred.ageDays ?? '?'} 天前)` : '没登录,AI 字幕与部分接口不可用',
|
|
398
|
+
path: CRED_FILE,
|
|
399
|
+
removable: true,
|
|
400
|
+
size: 0,
|
|
401
|
+
estimate: '扫码登录',
|
|
402
|
+
})
|
|
403
|
+
return rows
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 一键下载:跑插件自带的 fetch-deps 脚本,后台执行,返回日志路径。 */
|
|
407
|
+
function runDownload(target) {
|
|
408
|
+
const script = join(SCRIPTS, 'fetch-deps.ps1')
|
|
409
|
+
if (!existsSync(script)) return { started: false, why: '插件目录里没有 scripts/fetch-deps.ps1' }
|
|
410
|
+
const allowed = ['ffmpeg', 'ytdlp', 'asr', 'ocr', 'model', 'all']
|
|
411
|
+
if (!allowed.includes(target)) return { started: false, why: `不支持的依赖:${target}` }
|
|
412
|
+
try {
|
|
413
|
+
appendFileSync(DOWNLOAD_LOG, `[${new Date().toISOString()}] 开始下载 ${target}\n`, 'utf8')
|
|
414
|
+
} catch {}
|
|
415
|
+
const child = spawn(
|
|
416
|
+
'powershell.exe',
|
|
417
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Target', target],
|
|
418
|
+
{ windowsHide: true, stdio: 'ignore', detached: true },
|
|
419
|
+
)
|
|
420
|
+
child.unref()
|
|
421
|
+
return { started: true, log: DOWNLOAD_LOG }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 删除插件 deps\ 里的依赖。
|
|
426
|
+
* 只认这三个:yt-dlp、模型目录、登录凭证;ffmpeg / ASR / OCR 在插件目录外,一律跳过。
|
|
427
|
+
*/
|
|
428
|
+
function deleteDeps(targets) {
|
|
429
|
+
const map = {
|
|
430
|
+
ytdlp: join(DEPS, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'),
|
|
431
|
+
model: join(DEPS, '.hf-home'),
|
|
432
|
+
credential: CRED_FILE,
|
|
433
|
+
}
|
|
434
|
+
const deleted = []
|
|
435
|
+
const skipped = []
|
|
436
|
+
for (const t of targets) {
|
|
437
|
+
const p = map[t]
|
|
438
|
+
if (!p) {
|
|
439
|
+
skipped.push({ id: t, why: '不在插件目录内,不能从这里删' })
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
if (!existsSync(p)) {
|
|
444
|
+
skipped.push({ id: t, why: '本来就不存在' })
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
rmSync(p, { recursive: true, force: true })
|
|
448
|
+
deleted.push(t)
|
|
449
|
+
} catch (e) {
|
|
450
|
+
skipped.push({ id: t, why: String(e?.message || e) })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { deleted, skipped }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function sendJson(res, code, obj) {
|
|
457
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
458
|
+
res.end(JSON.stringify(obj))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 读 POST 的小 JSON body;读不到就返回 {},端点自己决定默认值。 */
|
|
462
|
+
function readJsonBody(req) {
|
|
463
|
+
return new Promise((resolve) => {
|
|
464
|
+
let raw = ''
|
|
465
|
+
req.on('data', (d) => {
|
|
466
|
+
raw += d
|
|
467
|
+
if (raw.length > 65536) req.destroy()
|
|
468
|
+
})
|
|
469
|
+
req.on('end', () => {
|
|
470
|
+
try {
|
|
471
|
+
resolve(JSON.parse(raw))
|
|
472
|
+
} catch {
|
|
473
|
+
resolve({})
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
req.on('error', () => resolve({}))
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
64
480
|
/** 把 "text BV1xx --cred path --step 2" 拆成 { command, bv, extra }。 */
|
|
65
481
|
function parseCommandLine(raw) {
|
|
66
482
|
const tokens = String(raw ?? '')
|
|
@@ -118,11 +534,19 @@ async function lookup({ command, bv, extra = [] }) {
|
|
|
118
534
|
? ['hot', ...extra]
|
|
119
535
|
: [command, bv, ...extra]
|
|
120
536
|
const r = await runLook(args)
|
|
537
|
+
// login 只生成二维码,真正的登录成功由面板 poll 写;命令层不记 lastRun,避免「生成成功」被读成「已登录」
|
|
538
|
+
if (command !== 'login') writeLastRun(command, r.code)
|
|
121
539
|
const text = (r.out || r.err || '').trim() || '(没有输出)'
|
|
122
540
|
return { ok: r.code === 0, text }
|
|
123
541
|
}
|
|
124
542
|
|
|
125
543
|
function apply(ctx) {
|
|
544
|
+
// 启动时按「天数 + 总量阈值」清一次缓存,不点按钮也不会无限涨
|
|
545
|
+
try {
|
|
546
|
+
const r = autoCleanCache()
|
|
547
|
+
if (r.removed) ctx.logger?.info?.(`[dsh-bili-watch] 自动清理缓存 ${r.removed} 个文件 / ${(r.bytes / 1048576).toFixed(1)} MB`)
|
|
548
|
+
} catch {}
|
|
549
|
+
|
|
126
550
|
// ① 斜杠命令:人敲,确定性执行,不花推理 token
|
|
127
551
|
ctx.commands.register({
|
|
128
552
|
definitionId: 'dsh-bili-watch',
|
|
@@ -185,14 +609,6 @@ function apply(ctx) {
|
|
|
185
609
|
type: 'string',
|
|
186
610
|
description: 'command=sheet 时的格子,如 3x3 或 4x4(默认 3x3)。',
|
|
187
611
|
},
|
|
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
612
|
},
|
|
197
613
|
output: {
|
|
198
614
|
// 注意:DSH 的 output.schema 是「author-facing spec」,编译时 allowRequired=false,
|
|
@@ -217,12 +633,9 @@ function apply(ctx) {
|
|
|
217
633
|
if (typeof args.grid === 'string' && args.grid.trim()) extra.push('--grid', args.grid.trim())
|
|
218
634
|
if (typeof args.days === 'number' && args.days > 0) extra.push('--days', String(Math.floor(args.days)))
|
|
219
635
|
if (args.command === 'hot') {
|
|
220
|
-
|
|
221
|
-
const r = await lookup({ command: 'hot', bv: '', extra })
|
|
636
|
+
const r = await lookup({ command: 'hot', bv: '' })
|
|
222
637
|
return { ok: r.ok, text: r.text }
|
|
223
638
|
}
|
|
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
639
|
const target = args.command === 'search' ? String(args.keyword ?? '').trim() : String(args.bv ?? '').trim()
|
|
227
640
|
if (args.command === 'search' && !target) return { ok: false, text: 'search 需要 keyword 参数(要搜什么词)' }
|
|
228
641
|
if (!target) return { ok: false, text: '缺少 BV 号或分享链接' }
|
|
@@ -230,6 +643,79 @@ function apply(ctx) {
|
|
|
230
643
|
return { ok: r.ok, text: r.text }
|
|
231
644
|
},
|
|
232
645
|
}))
|
|
646
|
+
|
|
647
|
+
// ③ 设置页:读盘状态 + 清缓存(只读优先;唯一写操作是删本机抽帧缓存目录)
|
|
648
|
+
try {
|
|
649
|
+
ctx.effect(
|
|
650
|
+
() =>
|
|
651
|
+
ctx.webServer.register({
|
|
652
|
+
kind: 'prefix',
|
|
653
|
+
path: '/api/bili-watch',
|
|
654
|
+
handler: async (req, res) => {
|
|
655
|
+
try {
|
|
656
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
657
|
+
if (req.method === 'GET' && url.pathname === '/api/bili-watch/status') {
|
|
658
|
+
const cleaned = autoCleanCache()
|
|
659
|
+
return sendJson(res, 200, {
|
|
660
|
+
ok: true,
|
|
661
|
+
data: {
|
|
662
|
+
credential: credState(),
|
|
663
|
+
deps: depRows(),
|
|
664
|
+
cache: { ...dirStat(CACHE), ...cacheCfg(), lastAutoClean: cleaned },
|
|
665
|
+
lastRun: readLastRun(),
|
|
666
|
+
},
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/cache/clear') {
|
|
670
|
+
const body = await readJsonBody(req)
|
|
671
|
+
if (Number.isFinite(body?.days) || Number.isFinite(body?.maxMb)) {
|
|
672
|
+
writeCacheCfg(
|
|
673
|
+
Number.isFinite(body?.days) ? Math.max(1, body.days) : cacheCfg().days,
|
|
674
|
+
Number.isFinite(body?.maxMb) ? Math.max(100, body.maxMb) : cacheCfg().maxMb,
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
const before = dirStat(CACHE)
|
|
678
|
+
try {
|
|
679
|
+
rmSync(CACHE, { recursive: true, force: true })
|
|
680
|
+
} catch {}
|
|
681
|
+
return sendJson(res, 200, { ok: true, data: { removed: before.files, bytes: before.bytes } })
|
|
682
|
+
}
|
|
683
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/download') {
|
|
684
|
+
const body = await readJsonBody(req)
|
|
685
|
+
const targets = Array.isArray(body?.targets) ? body.targets.map(String) : [String(body?.target ?? '')]
|
|
686
|
+
const results = targets.map((t) => runDownload(t))
|
|
687
|
+
const started = results.filter((r) => r.started).length
|
|
688
|
+
return sendJson(res, started ? 200 : 400, { ok: started > 0, data: { results, started } })
|
|
689
|
+
}
|
|
690
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/delete') {
|
|
691
|
+
const body = await readJsonBody(req)
|
|
692
|
+
const targets = Array.isArray(body?.targets) ? body.targets.map(String) : [String(body?.target ?? '')]
|
|
693
|
+
const r = deleteDeps(targets)
|
|
694
|
+
return sendJson(res, 200, { ok: true, data: r })
|
|
695
|
+
}
|
|
696
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/login/qr') {
|
|
697
|
+
const r = await loginQr()
|
|
698
|
+
return sendJson(res, r.ok ? 200 : 502, r)
|
|
699
|
+
}
|
|
700
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/login/poll') {
|
|
701
|
+
const body = await readJsonBody(req)
|
|
702
|
+
const r = await loginPollOnce(String(body?.key ?? ''))
|
|
703
|
+
if (r.state === 'ok') writeLastRun('login', 0)
|
|
704
|
+
return sendJson(res, 200, r)
|
|
705
|
+
}
|
|
706
|
+
sendJson(res, 404, { ok: false, why: 'not found' })
|
|
707
|
+
} catch (e) {
|
|
708
|
+
sendJson(res, 500, { ok: false, why: String(e?.message || e) })
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
'dsh-bili-watch: /api/bili-watch 设置页端点',
|
|
713
|
+
)
|
|
714
|
+
} catch (e) {
|
|
715
|
+
try {
|
|
716
|
+
ctx.logger?.info?.(`[dsh-bili-watch] 设置页端点没挂上:${e?.message ?? e}`)
|
|
717
|
+
} catch {}
|
|
718
|
+
}
|
|
233
719
|
}
|
|
234
720
|
|
|
235
721
|
export { apply, inject, name }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-watch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Bilibili video reader for DSH: subtitles, ASR, frame OCR, comments, danmaku.",
|
|
6
6
|
"type": "module",
|
|
@@ -10,16 +10,25 @@
|
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./index.js",
|
|
13
|
+
"./client": "./client.js",
|
|
13
14
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
16
17
|
"dsh": {
|
|
17
18
|
"bundle": {
|
|
18
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
|
+
]
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
"files": [
|
|
22
30
|
"index.js",
|
|
31
|
+
"client.js",
|
|
23
32
|
"cordis.patch.yml",
|
|
24
33
|
"tools/",
|
|
25
34
|
"scripts/",
|
package/scripts/fetch-deps.ps1
CHANGED
|
@@ -39,7 +39,7 @@ $Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
|
|
39
39
|
$Deps = Join-Path $Root 'deps'
|
|
40
40
|
if (-not $DryRun) { New-Item -ItemType Directory -Force -Path $Deps | Out-Null }
|
|
41
41
|
|
|
42
|
-
$UseCn = ($Mirror -eq 'cn') -or (($Mirror -eq 'auto') -and ($env:
|
|
42
|
+
$UseCn = ($Mirror -eq 'cn') -or (($Mirror -eq 'auto') -and ($env:BILI_WATCH_MIRROR -eq 'cn'))
|
|
43
43
|
$GhProxy = if ($UseCn) { 'https://gh-proxy.com/' } else { '' }
|
|
44
44
|
$PyIndex = if ($UseCn) { 'https://pypi.tuna.tsinghua.edu.cn/simple' } else { 'https://pypi.org/simple' }
|
|
45
45
|
$HfEndpoint = if ($UseCn) { 'https://hf-mirror.com' } else { 'https://huggingface.co' }
|
|
@@ -75,7 +75,7 @@ function Ensure-Ffmpeg {
|
|
|
75
75
|
# 80 MB 的 BtbN zip 在 GitHub 直连不稳时大概率断流;给替代路径,别让安装卡死在这
|
|
76
76
|
Write-Warning 'ffmpeg 下载失败。可选替代路径:'
|
|
77
77
|
Write-Host ' 1) 手动下载 ffmpeg.exe 放进 deps\ 后重跑本脚本(会自动跳过)'
|
|
78
|
-
Write-Host ' 2) 已有 ffmpeg 时设 $env:
|
|
78
|
+
Write-Host ' 2) 已有 ffmpeg 时设 $env:BILI_WATCH_FFMPEG = <ffmpeg.exe 路径>'
|
|
79
79
|
Write-Host ' 3) 换镜像重试:-Mirror cn(走 gh-proxy)'
|
|
80
80
|
throw 'ffmpeg 未安装(见上面的替代路径)'
|
|
81
81
|
}
|
package/scripts/fetch-deps.sh
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# 用法:
|
|
5
5
|
# bash scripts/fetch-deps.sh all
|
|
6
6
|
# bash scripts/fetch-deps.sh ytdlp
|
|
7
|
-
#
|
|
7
|
+
# BILI_WATCH_MIRROR=cn bash scripts/fetch-deps.sh all # 国内网络
|
|
8
8
|
#
|
|
9
9
|
# 目标:
|
|
10
10
|
# ffmpeg 只提示用系统包管理器安装(脚本不下载)
|
|
@@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
|
19
19
|
DEPS="$ROOT/deps"
|
|
20
20
|
mkdir -p "$DEPS"
|
|
21
21
|
TARGET="${1:-all}"
|
|
22
|
-
MIRROR="${
|
|
22
|
+
MIRROR="${BILI_WATCH_MIRROR:-}"
|
|
23
23
|
|
|
24
24
|
if [ "$MIRROR" = "cn" ]; then
|
|
25
25
|
GH="https://gh-proxy.com/https://github.com"
|
package/tools/look.mjs
CHANGED
|
@@ -36,9 +36,9 @@ import { tmpdir } from 'node:os'
|
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
37
|
|
|
38
38
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
39
|
-
// 依赖自包含:优先用插件目录 deps\ 下的环境,缺失时回退
|
|
39
|
+
// 依赖自包含:优先用插件目录 deps\ 下的环境,缺失时回退 BILI_WATCH_PROJ
|
|
40
40
|
const DEPS = join(HERE, '..', 'deps')
|
|
41
|
-
const PROJ = process.env.
|
|
41
|
+
const PROJ = process.env.BILI_WATCH_PROJ || DEPS
|
|
42
42
|
const pick = (...cands) => cands.find((p) => existsSync(p)) ?? cands[cands.length - 1]
|
|
43
43
|
const PY_DIR = process.platform === 'win32' ? 'Scripts' : 'bin'
|
|
44
44
|
const PY_EXE = process.platform === 'win32' ? 'python.exe' : 'python'
|
|
@@ -47,10 +47,10 @@ const ASR_SCRIPT = pick(join(DEPS, '_bili-asr.py'), join(PROJ, '_bili-asr.py'))
|
|
|
47
47
|
const OCR_PY = pick(join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(PROJ, '.venv-ocr', PY_DIR, PY_EXE))
|
|
48
48
|
const HF_HOME_DIR = pick(join(DEPS, '.hf-home'), join(PROJ, '.hf-home'))
|
|
49
49
|
const OCR_SCRIPT = join(HERE, 'ocr.py')
|
|
50
|
-
const FFMPEG_CAND = process.env.
|
|
50
|
+
const FFMPEG_CAND = process.env.BILI_WATCH_FFMPEG || ''
|
|
51
51
|
// 缺 ffmpeg 时的统一指路(一键脚本 / 环境变量 / --ffmpeg 三条出路)
|
|
52
52
|
const FFMPEG_HINT =
|
|
53
|
-
'跑 scripts/fetch-deps.ps1 -Target ffmpeg(macOS/Linux:bash scripts/fetch-deps.sh 或系统包管理器装),或设
|
|
53
|
+
'跑 scripts/fetch-deps.ps1 -Target ffmpeg(macOS/Linux:bash scripts/fetch-deps.sh 或系统包管理器装),或设 BILI_WATCH_FFMPEG,或用 --ffmpeg 传路径'
|
|
54
54
|
const CACHE = join(tmpdir(), 'bili-watch')
|
|
55
55
|
const require = createRequire(import.meta.url)
|
|
56
56
|
const CRED_FILE = join(DEPS, 'credential.json')
|
|
@@ -1112,7 +1112,7 @@ async function cmdLogin(opts = {}) {
|
|
|
1112
1112
|
try { chmodSync(LOGIN_KEY_FILE, 0o600) } catch {}
|
|
1113
1113
|
}
|
|
1114
1114
|
|
|
1115
|
-
const SEEN_FILE = process.env.
|
|
1115
|
+
const SEEN_FILE = process.env.BILI_WATCH_SEEN || join(DEPS, 'seen.md')
|
|
1116
1116
|
|
|
1117
1117
|
/**
|
|
1118
1118
|
* 往项目判重表追加一行「看过」。只在 --mark-seen 时调用:
|
|
@@ -1260,7 +1260,7 @@ function resolveSource(input) {
|
|
|
1260
1260
|
// 定位 yt-dlp:环境变量 → 插件 deps\yt-dlp.exe → PATH;找到后 --version 验一次
|
|
1261
1261
|
function findYtDlp() {
|
|
1262
1262
|
const localExe = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
|
1263
|
-
const cands = [process.env.
|
|
1263
|
+
const cands = [process.env.BILI_WATCH_YTDLP, join(DEPS, localExe), 'yt-dlp'].filter(Boolean)
|
|
1264
1264
|
for (const c of cands) {
|
|
1265
1265
|
if (c !== 'yt-dlp' && !existsSync(c)) continue
|
|
1266
1266
|
try {
|
|
@@ -1488,7 +1488,7 @@ const HELP = `用法:
|
|
|
1488
1488
|
|
|
1489
1489
|
多 P 视频加 --p N 指定第几个分P(默认 P1)。
|
|
1490
1490
|
B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/... 要先展开成完整链接。
|
|
1491
|
-
通用视频 URL(YouTube/X 等)需要 yt-dlp:跑 scripts/fetch-deps.ps1 -Target ytdlp 装到 deps\\yt-dlp.exe(或设
|
|
1491
|
+
通用视频 URL(YouTube/X 等)需要 yt-dlp:跑 scripts/fetch-deps.ps1 -Target ytdlp 装到 deps\\yt-dlp.exe(或设 BILI_WATCH_YTDLP / 系统 PATH)。
|
|
1492
1492
|
`
|
|
1493
1493
|
try {
|
|
1494
1494
|
if (srcErr || src.type === 'error') {
|