dsh-bili-scroll 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +30 -0
- package/README.md +38 -0
- package/client.js +96 -8
- package/index.js +215 -18
- package/lib/asr.py +130 -0
- package/lib/state.mjs +1 -0
- package/lib/uvfetch.mjs +161 -0
- package/package.json +6 -1
- package/prompts/duty.md +26 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 给 AI 助手看的(本仓库 = 一个 DSH 插件)
|
|
2
|
+
|
|
3
|
+
`dsh-bili-scroll`(中文名「**刷B站**」):每天替用户刷一轮 B 站 —— 搜、**读字幕原文**判断、写一份日报,特别值得的会发一条评论 **@ 用户**。
|
|
4
|
+
|
|
5
|
+
## 帮用户安装时,按这个顺序
|
|
6
|
+
|
|
7
|
+
1. **前提**:机器上有 `Python 3.10+` **或** `uv`(有一个即可)。
|
|
8
|
+
两个都没有**也不用让用户先装** —— 插件点「装依赖」时会**自己下一份 `uv` 到自己的家目录**(免管理员、不用 PATH);
|
|
9
|
+
只有它自己也下不动时(网络到不了 GitHub / 加速代理 / PyPI 镜像),才需要引导用户手动装。
|
|
10
|
+
· 它是**直连 GitHub → 国内加速代理 → PyPI 镜像**依次试的,并且会**核对官方 sha256** ✓
|
|
11
|
+
· ⚠️ 若用户机器上的**杀软拦了这次下载**:让他把 `%USERPROFILE%\.dsh\bili-duty\bin\` 加白,或干脆自己装 uv / Python 3.10+ ✓
|
|
12
|
+
2. `dsh plugin --profile web add github:c1overzz/dsh-bili-scroll`
|
|
13
|
+
(npm 的 `dsh-bili-scroll` 同内容;审核通过后用它更省事)
|
|
14
|
+
3. ⚠️ **提醒用户重启一次 dsh**(必须,bundle 层要重启才组装)。
|
|
15
|
+
4. 让用户进 **设置 → 刷B站**:点「**装依赖**」→ 点「**扫码登录**」(手机扫码,选"记住我 / 长期")。
|
|
16
|
+
5. **别擅自打开「自动评论并 @ 你」** —— 那是拿用户的号说话;要用得让用户自己填「要 @ 谁」。
|
|
17
|
+
|
|
18
|
+
## 仓库结构(改代码时看这里)
|
|
19
|
+
|
|
20
|
+
- `index.js` —— host 半端:11 个工具 + 谷时定时器 + HTTP 接口 + 设置命名空间
|
|
21
|
+
- `client.js` —— 浏览器半端:设置页(注册在 `settings.section`)
|
|
22
|
+
- `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录
|
|
23
|
+
- `prompts/duty.md` —— 一轮要做的事(判定口径 + 红线,**用户可以改**)
|
|
24
|
+
- `vendor/bilibili-mcp/` —— 内置的 Python MCP(MIT,AGI_Ananas)
|
|
25
|
+
- `selfcheck.mjs` + `tools/` —— 本地预检:`node --import ./tools/register.mjs selfcheck.mjs`
|
|
26
|
+
|
|
27
|
+
## 约定
|
|
28
|
+
|
|
29
|
+
- 配置与运行数据都在用户的 `$DSH_HOME/bili-duty/`,**不写进插件包目录**
|
|
30
|
+
- 谷时窗口按 DeepSeek 官方口径:工作日 9–12 / 14–18 是高峰,其余(含周末)都是谷时
|
package/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# dsh-bili-scroll | 刷B站
|
|
2
2
|
|
|
3
|
+
> **先看三行**
|
|
4
|
+
> - 需要 **dsh ≥ `0.1.7-rc.1`**(本机是 `0.1.7-rc.2`)。⚠️ 版本不满足时,**内核会静默跳过这个插件** —— 装了跟没装一样,不会报错。
|
|
5
|
+
> - **空间**:不装「本地转写」约 **84 MB**;装了转写约 **1 GB**(其中 Whisper 模型 538 MB,可在设置里换更小的模型)。
|
|
6
|
+
> - 装完**必须重启一次** dsh(插件在 bundle 层组装)。
|
|
7
|
+
>
|
|
8
|
+
> ⚠️ **装的是旧版吗?** 如果设置页里那个按钮写的是「**重装依赖**」(而不是「**重装主依赖**」),说明你装的是 0.1.0 ——
|
|
9
|
+
> 跑一次 `dsh plugin --profile web add dsh-bili-scroll` 升级就好(那一版建 Python 环境时缺一个参数,
|
|
10
|
+
> 依赖装不上、二维码也画不出来,重装多少次都一样)。
|
|
11
|
+
|
|
3
12
|
> DSH 插件:**每天替你刷一轮 B站** —— 它自己搜、**读字幕原文**判断、把值得的写成报告,特别值得的会留一条评论 **@ 你**。
|
|
4
13
|
> **你收到的每一条 @,都是它先筛过一遍的。**
|
|
5
14
|
|
|
@@ -11,8 +20,24 @@
|
|
|
11
20
|
- 开着「自动评论」时,每轮最多发一条评论、**@ 你**一次
|
|
12
21
|
- 可以在**峰谷时段自动跑**(DeepSeek 的优惠时段),省 token
|
|
13
22
|
|
|
23
|
+
## 装之前(一条前提,30 秒)
|
|
24
|
+
|
|
25
|
+
机器上要有 **`Python 3.10+` 或 `uv`** —— **有一个就够**:
|
|
26
|
+
|
|
27
|
+
- **有 `uv` 最好**:单个 ~32 MB 的 exe,**解压就能用、不用管理员、不用进 PATH**,而且它还能自己把 Python 拉下来;
|
|
28
|
+
- 只有 Python 也行(装官方安装器时记得勾 **`Add to PATH`**);
|
|
29
|
+
- ⭐ **两个都没有?也没关系**:点「装依赖」时**插件会自己下一份 `uv` 放到自己的家目录**
|
|
30
|
+
(单文件、约 15MB、**免管理员、不用进 PATH** ✓),之后全程自动 ✓
|
|
31
|
+
· 它会**先直连 GitHub,不通就走国内加速代理**,还配了 PyPI 镜像兜底 ✓
|
|
32
|
+
· 下载完会**核对官方 `sha256`**(对不上就丢弃换下一个源 ✓)—— 因为它下来的东西是要**被执行**的
|
|
33
|
+
· ⚠️ **如果杀软 / Windows Defender 报这个下载**:把插件家目录 `%USERPROFILE%\.dsh\bili-duty\bin\` **加白**即可;
|
|
34
|
+
不想加白也行,自己装一份 uv 或 Python 3.10+ 就绕过去了 ✓
|
|
35
|
+
|
|
36
|
+
> 计划中(v0.1.1):**插件自己把 `uv` 下到自己的家目录** ⇒ 做到"一键准备环境",这条前提就不用用户操心了。
|
|
37
|
+
|
|
14
38
|
## 装
|
|
15
39
|
|
|
40
|
+
|
|
16
41
|
```bash
|
|
17
42
|
dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
18
43
|
```
|
|
@@ -52,6 +77,19 @@ dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
|
52
77
|
- 那一轮的**工作目录 = 你填的工作区** ⇒ 那里的 `AGENTS.md` 会自动加载 ⇒ **它按你的设定说话**,
|
|
53
78
|
而不是用一套通用人格
|
|
54
79
|
|
|
80
|
+
## 如果你是一个 AI 助手,正在帮用户装这个插件
|
|
81
|
+
|
|
82
|
+
按顺序来,别跳步:
|
|
83
|
+
|
|
84
|
+
1. **先确认前提**:机器上有 `Python 3.10+` 或 `uv` 吗?(查 `python --version` / `uv --version`)
|
|
85
|
+
两个都没有 ⇒ **建议装 `uv`**(单文件 exe、免管理员、能自带 Python);
|
|
86
|
+
2. **装插件**:`dsh plugin --profile web add github:c1overzz/dsh-bili-scroll`
|
|
87
|
+
(npm 上的 `dsh-bili-scroll` 同名同内容,审核通过后用 `add dsh-bili-scroll` 更省事)
|
|
88
|
+
3. ⚠️ **必须提醒用户重启一次 dsh** —— bundle 层要重启才组装,不重启插件不会生效;
|
|
89
|
+
4. 重启后请用户进 **设置 → 刷B站**:先点「**装依赖**」(几十秒,进度就显示在页面下面)、再点「**扫码登录**」
|
|
90
|
+
(手机 B 站扫码,**确认时选"记住我 / 长期"**);
|
|
91
|
+
5. **不要**替用户打开「自动评论并 @ 你」—— 那是拿用户的号说话。要用得让用户自己在设置里填「要 @ 谁」。
|
|
92
|
+
|
|
55
93
|
## 依赖与致谢
|
|
56
94
|
|
|
57
95
|
- 内置 [**bilibili-mcp**](https://github.com/adoresever/bilibili-mcp)(MIT,Copyright (c) 2026 AGI_Ananas)——
|
package/client.js
CHANGED
|
@@ -134,12 +134,15 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
const [msgTone, setMsgTone] = React.useState('')
|
|
135
135
|
const [kws, setKws] = React.useState(null)
|
|
136
136
|
const [ws, setWs] = React.useState(null)
|
|
137
|
+
const [rl, setRl] = React.useState(null) // 日报形态:full / brief / off
|
|
137
138
|
// ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
|
|
138
139
|
// ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
|
|
139
140
|
const [perDay, setPerDay] = React.useState(null)
|
|
140
141
|
const [perRound, setPerRound] = React.useState(null)
|
|
141
142
|
const [mention, setMention] = React.useState(null)
|
|
142
143
|
const [login, setLogin] = React.useState(null)
|
|
144
|
+
const [runNote, setRunNote] = React.useState(null)
|
|
145
|
+
const [report, setReport] = React.useState(null) // 非 null = 报告已展开 // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
|
|
143
146
|
const [loginState, setLoginState] = React.useState('')
|
|
144
147
|
const [loginTone, setLoginTone] = React.useState('')
|
|
145
148
|
|
|
@@ -147,6 +150,7 @@ window.__ModuleLoader__.load({
|
|
|
147
150
|
React.useEffect(() => {
|
|
148
151
|
if (cfg && kws === null) setKws((cfg.keywords || []).join('\n'))
|
|
149
152
|
if (cfg && ws === null) setWs(cfg.workspace || '')
|
|
153
|
+
if (cfg && rl === null) setRl(cfg.reportLevel || 'full')
|
|
150
154
|
if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
|
|
151
155
|
if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
|
|
152
156
|
if (cfg && mention === null) setMention(cfg.mentionName || '')
|
|
@@ -154,7 +158,10 @@ window.__ModuleLoader__.load({
|
|
|
154
158
|
|
|
155
159
|
const win = { ...DEFAULT_WINDOWS, ...(st?.windows || {}) }
|
|
156
160
|
|
|
157
|
-
const say = (text, tone) => {
|
|
161
|
+
const say = (text, tone) => {
|
|
162
|
+
setMsg(text); setMsgTone(tone || '')
|
|
163
|
+
setRunNote(text ? { t: text, tone: tone || '' } : null) // ⭐ 同时镜像到顶部,免得提示掉到页面最底下看不见
|
|
164
|
+
}
|
|
158
165
|
|
|
159
166
|
const patch = async (p, what) => {
|
|
160
167
|
setBusy(JSON.stringify(p))
|
|
@@ -166,6 +173,53 @@ window.__ModuleLoader__.load({
|
|
|
166
173
|
finally { setBusy('') }
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
const onShowReport = async () => {
|
|
177
|
+
if (report !== null) { setReport(null); return } // 再点一次收起
|
|
178
|
+
setBusy('report')
|
|
179
|
+
try {
|
|
180
|
+
const r = await jget('/report')
|
|
181
|
+
setReport(r && r.exists ? (r.text || '(报告是空的)') : '今天还没有报告 —— 跑一轮之后就有了')
|
|
182
|
+
} catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const copyText = async (text) => {
|
|
186
|
+
const v = String(text || '')
|
|
187
|
+
try {
|
|
188
|
+
await navigator.clipboard.writeText(v)
|
|
189
|
+
say('已复制:' + v, 'good')
|
|
190
|
+
} catch {
|
|
191
|
+
say('复制失败 —— 路径是:' + v) // 兜底:至少把路径显示出来,手动选中也能复制
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onOpenFolder = async (what) => {
|
|
196
|
+
try {
|
|
197
|
+
const r = await jpost('/open', { what })
|
|
198
|
+
if (r && r.ok) say('已请系统打开:' + (r.opened || '') + '(要是没看到窗口,用旁边的「复制报告路径」——那个一定成)', 'good')
|
|
199
|
+
else say('打不开(' + ((r && r.error) || '未知原因') + ')—— 你可以手动打开这个路径:' + ((r && r.dir) || ''))
|
|
200
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const onCheck = async () => {
|
|
204
|
+
setBusy('check')
|
|
205
|
+
say('')
|
|
206
|
+
try {
|
|
207
|
+
const r = await jpost('/check', {})
|
|
208
|
+
if (r && r.ok) say('能用!' + (r.steps || []).join('、'))
|
|
209
|
+
else say('还不能用:' + (r && r.error ? r.error : '没拿到结果'))
|
|
210
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const onInstallAsr = async () => {
|
|
214
|
+
setBusy('asr')
|
|
215
|
+
say('')
|
|
216
|
+
try {
|
|
217
|
+
const r = await jpost('/install-asr', {})
|
|
218
|
+
say(r?.started ? '开始装转写依赖 —— 进度显示在下面(要下模型,慢一点正常)' : (r?.hint || '没起来'),
|
|
219
|
+
r?.started ? 'good' : 'warn')
|
|
220
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
221
|
+
}
|
|
222
|
+
|
|
169
223
|
const onInstall = async () => {
|
|
170
224
|
setBusy('install')
|
|
171
225
|
say('')
|
|
@@ -230,10 +284,12 @@ window.__ModuleLoader__.load({
|
|
|
230
284
|
/* ── 今天 ── */
|
|
231
285
|
h('div', { 'data-bd-row': '' },
|
|
232
286
|
h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.ranToday} / ${cfg.perDay} 条`),
|
|
233
|
-
h('span', { 'data-bd-badge': '' },
|
|
287
|
+
h('span', { 'data-bd-badge': '' }, '环境' + (st.venvReady ? '已就绪' : (st.installing ? '正在装' : '未装'))),
|
|
234
288
|
h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
|
|
235
289
|
h('div', { 'data-bd-grow': '' }),
|
|
236
290
|
h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
|
|
291
|
+
runNote ? h('div', { 'data-bd-msg': '', 'data-good': runNote.tone === 'good' ? '' : undefined,
|
|
292
|
+
'data-warn': runNote.tone === 'warn' ? '' : undefined }, runNote.t) : null,
|
|
237
293
|
|
|
238
294
|
/* ── 定时 ── */
|
|
239
295
|
h('div', { 'data-bd-sec': '' },
|
|
@@ -298,10 +354,24 @@ window.__ModuleLoader__.load({
|
|
|
298
354
|
h('div', { 'data-bd-note': '' }, '留空的话它就不发评论了 —— 因为评论存在的意义就是 @ 那一下')) : null,
|
|
299
355
|
h(ToggleRow, {
|
|
300
356
|
label: '本地转写没字幕的视频',
|
|
301
|
-
note: '
|
|
357
|
+
note: '默认关;开了以后,没字幕的视频会在本机转写一遍(不花 API 钱)',
|
|
302
358
|
on: sw('transcribe'),
|
|
303
359
|
onChange: (v) => patch({ transcribe: v }, '本地转写'),
|
|
304
|
-
})
|
|
360
|
+
}),
|
|
361
|
+
sw('transcribe') ? h('div', { 'data-bd-sub': '' },
|
|
362
|
+
h('div', { 'data-bd-row': '' },
|
|
363
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
|
|
364
|
+
st.asrReady ? '重装转写依赖' : '装转写依赖'),
|
|
365
|
+
h('div', { 'data-bd-note': '' },
|
|
366
|
+
!st.asrReady ? '还没装 —— 没装的话转写用不了'
|
|
367
|
+
: (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
|
|
368
|
+
h('div', { 'data-bd-note': '' },
|
|
369
|
+
'点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
|
|
370
|
+
h('br'),
|
|
371
|
+
'(模型默认 small,几百 MB,走国内镜像;装一次就够了)'),
|
|
372
|
+
st.asrTail ? h('div', null,
|
|
373
|
+
h('div', { 'data-bd-note': '' }, st.asrInstalling ? '正在装转写依赖(进度):' : '转写依赖的日志(末尾)—— 只有「没字幕的视频」用得到它:'),
|
|
374
|
+
h('pre', { 'data-bd-pre': '' }, st.asrTail)) : null) : null),
|
|
305
375
|
|
|
306
376
|
/* ── 关注什么 / 在哪儿刷 ── */
|
|
307
377
|
h('div', { 'data-bd-sec': '' },
|
|
@@ -315,6 +385,14 @@ window.__ModuleLoader__.load({
|
|
|
315
385
|
'data-bd-input': '', value: ws ?? '', placeholder: '留空 = dsh 自己的工作目录',
|
|
316
386
|
onChange: (e) => setWs(e.target.value),
|
|
317
387
|
}),
|
|
388
|
+
h('div', { 'data-bd-label': '' }, '日报写多细'),
|
|
389
|
+
h('select', {
|
|
390
|
+
'data-bd-input': '', value: rl ?? 'full',
|
|
391
|
+
onChange: (e) => { setRl(e.target.value); patch({ reportLevel: e.target.value }, '日报形态') },
|
|
392
|
+
},
|
|
393
|
+
h('option', { value: 'full' }, '完整 —— 每条:标题 / UP / 为什么值得(默认)'),
|
|
394
|
+
h('option', { value: 'brief' }, '薄 —— 每条一两行,只留值不值得 + 链接'),
|
|
395
|
+
h('option', { value: 'off' }, '不写 —— 只发评论,不生成日报文件')),
|
|
318
396
|
h('div', { 'data-bd-row': '' },
|
|
319
397
|
h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onSaveText }, '保存'),
|
|
320
398
|
msg ? h('div', { 'data-bd-msg': '', 'data-good': msgTone === 'good' ? '' : undefined, 'data-warn': msgTone === 'warn' ? '' : undefined }, msg) : null)),
|
|
@@ -324,12 +402,15 @@ window.__ModuleLoader__.load({
|
|
|
324
402
|
h('div', { 'data-bd-h': '' }, '环境与账号'),
|
|
325
403
|
h('div', { 'data-bd-row': '' },
|
|
326
404
|
h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
|
|
327
|
-
st.venvReady ? '
|
|
405
|
+
st.venvReady ? '重装主依赖' : '装主依赖(建 venv)'),
|
|
406
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'check', onClick: onCheck }, '测一下'),
|
|
328
407
|
h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
|
|
329
|
-
h('div', { 'data-bd-note': '' },
|
|
408
|
+
h('div', { 'data-bd-note': '' },
|
|
409
|
+
!st.venvReady ? (st.installFailed ? '上次没装成 → 看下面日志' : '还没装 → 先点「装主依赖」')
|
|
410
|
+
: (st.loggedIn ? '装好了 → 下一步「扫码登录」' : '环境好了 → 点「扫码登录」'))),
|
|
330
411
|
loginState ? h('div', { 'data-bd-msg': '', 'data-good': loginTone === 'good' ? '' : undefined, 'data-warn': loginTone === 'warn' ? '' : undefined }, loginState) : null,
|
|
331
412
|
st.installTail ? h('div', null,
|
|
332
|
-
h('div', { 'data-bd-note': '' }, st.installing ? '
|
|
413
|
+
h('div', { 'data-bd-note': '' }, st.installing ? '正在装主依赖(进度):' : '主依赖的日志(B站 搜索 / 字幕 / 评论 用得到,末尾):'),
|
|
333
414
|
h('pre', { 'data-bd-pre': '' }, st.installTail)) : null,
|
|
334
415
|
login?.qr ? h('div', { 'data-bd-qr': '' },
|
|
335
416
|
h('img', { src: login.qr, alt: 'B站登录二维码' }),
|
|
@@ -344,7 +425,14 @@ window.__ModuleLoader__.load({
|
|
|
344
425
|
h('div', { 'data-bd-kv': '' },
|
|
345
426
|
`家目录:${st.home}`,
|
|
346
427
|
h('br'),
|
|
347
|
-
`今天的报告:${st.report}`)
|
|
428
|
+
`今天的报告:${st.report}`),
|
|
429
|
+
h('div', { 'data-bd-row': '' },
|
|
430
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'report', onClick: onShowReport },
|
|
431
|
+
report !== null ? '收起报告' : '看今天的报告'),
|
|
432
|
+
h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
|
|
433
|
+
h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
|
|
434
|
+
h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
|
|
435
|
+
report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
|
|
348
436
|
)
|
|
349
437
|
}
|
|
350
438
|
|
package/index.js
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
import z from '@deepseek-ai/schemastery'
|
|
18
18
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
19
19
|
import { spawn } from 'node:child_process'
|
|
20
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
21
21
|
import { join, dirname } from 'node:path'
|
|
22
22
|
import { fileURLToPath } from 'node:url'
|
|
23
23
|
import { McpClient } from './lib/mcp.mjs'
|
|
24
24
|
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, inWindow, dayKey } from './lib/state.mjs'
|
|
25
25
|
import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
|
|
26
|
+
import { ensureUv } from './lib/uvfetch.mjs'
|
|
26
27
|
|
|
27
28
|
export const name = 'bili-scroll'
|
|
28
29
|
export const inject = ['webServer', 'tools', 'settings']
|
|
@@ -45,6 +46,8 @@ export const Config = z.object({
|
|
|
45
46
|
autoComment: z.boolean().default(false).extra('volatile', true),
|
|
46
47
|
/** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
|
|
47
48
|
transcribe: z.boolean().default(false).extra('volatile', true),
|
|
49
|
+
/** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
|
|
50
|
+
asrModel: z.string().default('small').extra('volatile', true),
|
|
48
51
|
/** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人) */
|
|
49
52
|
commentPerRound: z.natural().default(1).extra('volatile', true),
|
|
50
53
|
/** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
|
|
@@ -61,11 +64,34 @@ export const Config = z.object({
|
|
|
61
64
|
overlayMcp: z.boolean().default(true).extra('volatile', true),
|
|
62
65
|
/** 跑 headless 时的工作目录 */
|
|
63
66
|
workspace: z.string().default('').extra('volatile', true),
|
|
67
|
+
/** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
|
|
68
|
+
reportLevel: z.string().default('full').extra('volatile', true),
|
|
64
69
|
})
|
|
65
70
|
|
|
66
71
|
export function apply(ctx, config) {
|
|
67
72
|
const pluginDir = dirname(fileURLToPath(import.meta.url))
|
|
68
73
|
const stateDir = stateDirOf(ctx)
|
|
74
|
+
|
|
75
|
+
// ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
|
|
76
|
+
// (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
|
|
77
|
+
const sitePkgs = (venvRoot) => {
|
|
78
|
+
const win = join(venvRoot, 'Lib', 'site-packages')
|
|
79
|
+
if (existsSync(win)) return win
|
|
80
|
+
try {
|
|
81
|
+
const lib = join(venvRoot, 'lib')
|
|
82
|
+
for (const d of readdirSync(lib)) {
|
|
83
|
+
if (!d.startsWith('python')) continue
|
|
84
|
+
const p = join(lib, d, 'site-packages')
|
|
85
|
+
if (existsSync(p)) return p
|
|
86
|
+
}
|
|
87
|
+
} catch { /* 没有就算了 */ }
|
|
88
|
+
return win
|
|
89
|
+
}
|
|
90
|
+
const hasPkg = (venvRoot, prefix) => {
|
|
91
|
+
try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
|
|
92
|
+
}
|
|
93
|
+
const venvReadyNow = () => existsSync(venvPython()) && hasPkg(join(stateDir, '.venv'), 'bilibili_api')
|
|
94
|
+
const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
|
|
69
95
|
const cfgFile = join(stateDir, 'config.json')
|
|
70
96
|
const logFile = join(stateDir, 'duty.log')
|
|
71
97
|
const outDir = join(stateDir, 'out')
|
|
@@ -88,8 +114,8 @@ export function apply(ctx, config) {
|
|
|
88
114
|
* ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
|
|
89
115
|
*/
|
|
90
116
|
const DEFAULTS = {
|
|
91
|
-
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false,
|
|
92
|
-
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '',
|
|
117
|
+
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
|
|
118
|
+
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full',
|
|
93
119
|
}
|
|
94
120
|
const normalize = (src, base) => {
|
|
95
121
|
const out = { ...base }
|
|
@@ -335,7 +361,7 @@ export function apply(ctx, config) {
|
|
|
335
361
|
parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
|
|
336
362
|
async execute(args) {
|
|
337
363
|
if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) saveCfg(args.patch)
|
|
338
|
-
return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady:
|
|
364
|
+
return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow() }
|
|
339
365
|
},
|
|
340
366
|
})
|
|
341
367
|
|
|
@@ -391,9 +417,13 @@ export function apply(ctx, config) {
|
|
|
391
417
|
+ `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
|
|
392
418
|
+ ownerDoc
|
|
393
419
|
+ `然后读章程 ${duty},按里面的要求刷一轮 B站,`
|
|
394
|
-
+
|
|
420
|
+
+ (cfg.reportLevel === 'brief'
|
|
421
|
+
? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
|
|
422
|
+
: cfg.reportLevel === 'off'
|
|
423
|
+
? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
|
|
424
|
+
: `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
|
|
395
425
|
+ `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
|
|
396
|
-
+ `本地转写是${cfg.transcribe ? '
|
|
426
|
+
+ `本地转写是${cfg.transcribe ? '开着的(没字幕就用家目录里的 asr.py 转,命令见章程)' : '关着的(没字幕就跳过)'};`
|
|
397
427
|
+ `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
|
|
398
428
|
? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
|
|
399
429
|
: '关着的(只写报告,别发评论)'}。`
|
|
@@ -558,6 +588,91 @@ export function apply(ctx, config) {
|
|
|
558
588
|
c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
|
|
559
589
|
})
|
|
560
590
|
|
|
591
|
+
// ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
|
|
592
|
+
const asrDir = join(stateDir, '.venv-asr')
|
|
593
|
+
const asrLogFile = join(stateDir, 'asr-install.log')
|
|
594
|
+
const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
|
|
595
|
+
process.platform === 'win32' ? 'python.exe' : 'python')
|
|
596
|
+
const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
|
|
597
|
+
// ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
|
|
598
|
+
// 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
|
|
599
|
+
const asrTmp = join(stateDir, 'tmp', 'asr')
|
|
600
|
+
try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
|
|
601
|
+
let installingAsr = false
|
|
602
|
+
|
|
603
|
+
/** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
|
|
604
|
+
const resolveUv = async () => {
|
|
605
|
+
const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
606
|
+
if (await haveUv()) return 'uv'
|
|
607
|
+
if (existsSync(bundled)) return bundled
|
|
608
|
+
appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
|
|
609
|
+
const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
|
|
610
|
+
if (got) appendInstall('uv 已就位:' + got + '\n')
|
|
611
|
+
return got
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
|
|
615
|
+
const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
|
|
616
|
+
sink(`\n$ ${cmd} ${args.join(' ')}\n`)
|
|
617
|
+
let c
|
|
618
|
+
try {
|
|
619
|
+
c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
620
|
+
env: env ? { ...process.env, ...env } : process.env })
|
|
621
|
+
} catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
|
|
622
|
+
const w = (b) => sink(String(b))
|
|
623
|
+
c.stdout?.on('data', w)
|
|
624
|
+
c.stderr?.on('data', w)
|
|
625
|
+
c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
|
|
626
|
+
c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
/** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
|
|
630
|
+
const runAsrInstall = () => {
|
|
631
|
+
if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
|
|
632
|
+
installingAsr = true
|
|
633
|
+
appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
|
|
634
|
+
;(async () => {
|
|
635
|
+
try {
|
|
636
|
+
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
637
|
+
const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
|
|
638
|
+
const py = asrPython()
|
|
639
|
+
const uvCmd = await resolveUv()
|
|
640
|
+
if (!existsSync(py)) {
|
|
641
|
+
// --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
|
|
642
|
+
if (uvCmd) await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
|
|
643
|
+
else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
|
|
644
|
+
}
|
|
645
|
+
if (!existsSync(py)) {
|
|
646
|
+
appendAsr('建环境失败 —— 先点上面的「装依赖」,把 uv 或 Python 准备好\n')
|
|
647
|
+
log('转写:建环境失败'); return
|
|
648
|
+
}
|
|
649
|
+
appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
|
|
650
|
+
let code = 1
|
|
651
|
+
// ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
|
|
652
|
+
// 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
|
|
653
|
+
const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
|
|
654
|
+
if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
655
|
+
if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
656
|
+
if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
|
|
657
|
+
// ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
|
|
658
|
+
const model = String(cfg.asrModel || 'small')
|
|
659
|
+
// 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
|
|
660
|
+
const hfHome = join(stateDir, '.hf-cache')
|
|
661
|
+
try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
|
|
662
|
+
appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
|
|
663
|
+
const code2 = await spawnTo(py, ['-c',
|
|
664
|
+
'import os,sys;os.environ["HF_HUB_DISABLE_XET"]="1";from faster_whisper import WhisperModel;WhisperModel(sys.argv[1], device="cpu", compute_type="int8");print("模型就绪")',
|
|
665
|
+
model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
|
|
666
|
+
appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
|
|
667
|
+
log(`装转写依赖结束:exit=${code}/${code2}`)
|
|
668
|
+
} catch (e) {
|
|
669
|
+
appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
|
|
670
|
+
log(`装转写依赖崩了:${e?.message ?? e}`)
|
|
671
|
+
} finally { installingAsr = false }
|
|
672
|
+
})()
|
|
673
|
+
return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
|
|
674
|
+
}
|
|
675
|
+
|
|
561
676
|
const runInstall = () => {
|
|
562
677
|
if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
|
|
563
678
|
installing = true
|
|
@@ -567,21 +682,23 @@ export function apply(ctx, config) {
|
|
|
567
682
|
const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
|
|
568
683
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
569
684
|
const venvPy = venvPython()
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
685
|
+
const py = String(cfg.pythonPath || 'python')
|
|
686
|
+
const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
|
|
687
|
+
if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
|
|
688
|
+
const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
|
|
689
|
+
+ '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
|
|
690
|
+
+ '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
|
|
574
691
|
appendInstall(msg + '\n'); log(msg); installing = false; return
|
|
575
692
|
}
|
|
576
693
|
if (!existsSync(venvPy)) {
|
|
577
|
-
if (
|
|
578
|
-
if (!existsSync(venvPy)) await spawnLogged(
|
|
694
|
+
if (uvCmd) await spawnLogged(uvCmd, ['venv', join(stateDir, '.venv')])
|
|
695
|
+
if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
|
|
579
696
|
}
|
|
580
697
|
let code = 1
|
|
581
|
-
if (
|
|
698
|
+
if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
|
|
582
699
|
if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
|
|
583
|
-
if (existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
|
|
584
|
-
appendInstall(`=== 结束(exit ${code},venvReady=${
|
|
700
|
+
if (code === 0 && existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
|
|
701
|
+
appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
|
|
585
702
|
log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
|
|
586
703
|
} catch (e) {
|
|
587
704
|
appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
|
|
@@ -591,6 +708,27 @@ export function apply(ctx, config) {
|
|
|
591
708
|
return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
|
|
592
709
|
}
|
|
593
710
|
|
|
711
|
+
/** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
|
|
712
|
+
const checkUp = async () => {
|
|
713
|
+
const out = { ok: false, steps: [] }
|
|
714
|
+
if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
|
|
715
|
+
if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
|
|
716
|
+
try {
|
|
717
|
+
const who = await call('bili_check_credential', {})
|
|
718
|
+
out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
|
|
719
|
+
out.steps.push('登录有效')
|
|
720
|
+
} catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
|
|
721
|
+
try {
|
|
722
|
+
const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
|
|
723
|
+
const t = typeof s === 'string' ? s : JSON.stringify(s)
|
|
724
|
+
out.sample = t.slice(0, 160)
|
|
725
|
+
out.steps.push('能搜到东西')
|
|
726
|
+
out.ok = t.length > 2
|
|
727
|
+
if (!out.ok) out.error = '搜到了空结果'
|
|
728
|
+
} catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
|
|
729
|
+
return out
|
|
730
|
+
}
|
|
731
|
+
|
|
594
732
|
const status = () => {
|
|
595
733
|
const st = readJson(dutyStateFile, {})
|
|
596
734
|
const today = dayKey()
|
|
@@ -604,8 +742,16 @@ export function apply(ctx, config) {
|
|
|
604
742
|
lastRunAt: st.lastRunAt || null,
|
|
605
743
|
lastWhy: st.lastWhy || null,
|
|
606
744
|
loggedIn: hasCredential(),
|
|
607
|
-
venvReady:
|
|
745
|
+
venvReady: venvReadyNow(),
|
|
746
|
+
installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
|
|
608
747
|
installing,
|
|
748
|
+
// ── 本地转写那一套的状态(设置页要显示进度)──
|
|
749
|
+
asrReady: asrReadyNow(),
|
|
750
|
+
// 模型下没下(免得界面写"就绪"、其实还得现下)
|
|
751
|
+
asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
|
|
752
|
+
asrInstalling: installingAsr,
|
|
753
|
+
asrLog: asrLogFile,
|
|
754
|
+
asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
|
|
609
755
|
installLog: installLogFile,
|
|
610
756
|
// ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
|
|
611
757
|
installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
|
|
@@ -625,12 +771,13 @@ export function apply(ctx, config) {
|
|
|
625
771
|
if (req.method === 'POST' && path === '/api/bili-duty/config') {
|
|
626
772
|
const body = await readBody(req)
|
|
627
773
|
const patch = {}
|
|
628
|
-
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe',
|
|
774
|
+
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
|
|
629
775
|
'commentPerRound', 'mentionName',
|
|
630
|
-
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace']) {
|
|
776
|
+
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel']) {
|
|
631
777
|
if (k in body) patch[k] = body[k]
|
|
632
778
|
}
|
|
633
779
|
if ('perDay' in patch) patch.perDay = Math.max(0, Math.min(Number(patch.perDay) || 0, 50))
|
|
780
|
+
if ('reportLevel' in patch) patch.reportLevel = ['full', 'brief', 'off'].includes(String(patch.reportLevel)) ? String(patch.reportLevel) : 'full'
|
|
634
781
|
saveCfg(patch)
|
|
635
782
|
log(`设置已更新:${JSON.stringify(patch)}`)
|
|
636
783
|
return sendJson(res, 200, { ok: true, config: cfg })
|
|
@@ -645,6 +792,56 @@ export function apply(ctx, config) {
|
|
|
645
792
|
return sendJson(res, 200, runInstall())
|
|
646
793
|
}
|
|
647
794
|
|
|
795
|
+
// 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
|
|
796
|
+
if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
|
|
797
|
+
return sendJson(res, 200, runAsrInstall())
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
|
|
801
|
+
if (req.method === 'GET' && path === '/api/bili-duty/report') {
|
|
802
|
+
const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
|
|
803
|
+
const file = join(outDir, day + '.md')
|
|
804
|
+
let text = ''
|
|
805
|
+
let exists = false
|
|
806
|
+
try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
|
|
807
|
+
return sendJson(res, 200, { ok: true, day, exists, file, text })
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
|
|
811
|
+
if (req.method === 'POST' && path === '/api/bili-duty/open') {
|
|
812
|
+
const body = await readBody(req)
|
|
813
|
+
const dir = String(body?.what || 'out') === 'home' ? stateDir : outDir
|
|
814
|
+
if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
|
|
815
|
+
// ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
|
|
816
|
+
// try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
|
|
817
|
+
// ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
|
|
818
|
+
// 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
|
|
819
|
+
const attempts = [
|
|
820
|
+
['cmd.exe', ['/c', 'start', '', dir]],
|
|
821
|
+
[join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
|
|
822
|
+
['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
|
|
823
|
+
]
|
|
824
|
+
let lastErr = ''
|
|
825
|
+
for (const [cmd, args] of attempts) {
|
|
826
|
+
const err = await new Promise((resolve) => {
|
|
827
|
+
let done = false
|
|
828
|
+
let child
|
|
829
|
+
try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
|
|
830
|
+
child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
|
|
831
|
+
setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
|
|
832
|
+
})
|
|
833
|
+
if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
|
|
834
|
+
lastErr = err
|
|
835
|
+
log('打开文件夹失败(' + cmd + '):' + err)
|
|
836
|
+
}
|
|
837
|
+
return sendJson(res, 200, { ok: false, error: lastErr, dir })
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// 「测一下」:真跑一次,回答"能不能用"
|
|
841
|
+
if (req.method === 'POST' && path === '/api/bili-duty/check') {
|
|
842
|
+
return sendJson(res, 200, await checkUp())
|
|
843
|
+
}
|
|
844
|
+
|
|
648
845
|
// ── 扫码登录(设置页用)──
|
|
649
846
|
if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
|
|
650
847
|
const { key, url } = await loginStart()
|
package/lib/asr.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
asr.py —— 把一条 B站 视频(或本地音视频文件)转成文字。
|
|
5
|
+
给"这个视频没有字幕、但又值得判断"的情况用。
|
|
6
|
+
|
|
7
|
+
用法(在插件的 ASR 环境里跑):
|
|
8
|
+
.venv-asr/Scripts/python.exe asr.py BV1xxxxxxxxx # Windows
|
|
9
|
+
.venv-asr/bin/python asr.py https://www.bilibili.com/video/BV1xx
|
|
10
|
+
.venv-asr/Scripts/python.exe asr.py D:\\some\\clip.mp4 -o out.txt
|
|
11
|
+
…还可以加 --model small / --model medium 换模型(默认 small)
|
|
12
|
+
|
|
13
|
+
设计上的两个要点:
|
|
14
|
+
① **模型走国内镜像**(HF_ENDPOINT=hf-mirror.com)—— 否则 HuggingFace 拉不动
|
|
15
|
+
② **音频不落盘到别处**:yt-dlp 下到临时目录,转写完就随系统清理
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
# ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
|
|
24
|
+
# yt-dlp 默认往 %TEMP% 写、会被直接拒掉(2026-10-14 在主人机器上实证:
|
|
25
|
+
# yt_dlp DownloadError: unable to open for writing: [Errno 13] Permission denied)
|
|
26
|
+
_HOME = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
_TMP = os.path.join(_HOME, 'tmp', 'asr')
|
|
28
|
+
try:
|
|
29
|
+
os.makedirs(_TMP, exist_ok=True)
|
|
30
|
+
tempfile.tempdir = _TMP
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
# ⭐ 必须在 import faster_whisper **之前**设好,否则它已经去连 HuggingFace 了
|
|
35
|
+
os.environ.setdefault('HF_ENDPOINT', 'https://hf-mirror.com')
|
|
36
|
+
# 模型缓存放本脚本所在目录(= 插件家):默认的 ~/.cache 在本机拒绝写入
|
|
37
|
+
os.environ.setdefault('HF_HOME', os.path.join(os.path.dirname(os.path.abspath(__file__)), '.hf-cache'))
|
|
38
|
+
# HuggingFace 新版存储(Xet)国内镜像不代理 ⇒ 关掉它,走老 CDN 通道(否则 401)
|
|
39
|
+
os.environ.setdefault('HF_HUB_DISABLE_XET', '1')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def to_url(target: str) -> str:
|
|
43
|
+
t = target.strip()
|
|
44
|
+
if t.upper().startswith('BV') and len(t) <= 20:
|
|
45
|
+
return 'https://www.bilibili.com/video/' + t
|
|
46
|
+
return t
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_audio(url: str, tmp: str, tag: str) -> str:
|
|
50
|
+
"""用 yt-dlp 把音频拉下来(B站 是 DASH 音视频分离,取 bestaudio 即可,不需要 ffmpeg 合并)"""
|
|
51
|
+
from yt_dlp import YoutubeDL
|
|
52
|
+
opts = {
|
|
53
|
+
'format': 'bestaudio/best',
|
|
54
|
+
'outtmpl': os.path.join(tmp, tag + '.%(ext)s'), # ⭐ 用 BV 号当文件名:固定名会和别的轮次撞车(踩过)
|
|
55
|
+
'paths': {'home': tmp, 'temp': tmp}, # 别让 yt-dlp 往 %TEMP% 写(沙箱会拒)
|
|
56
|
+
'cachedir': False, # 顺手别去动用户目录里的缓存
|
|
57
|
+
# ⭐ nopart:不写 .part 再改名,直接落最终文件。
|
|
58
|
+
# 沙箱里"新建文件 + 改名"会被拒,而这条能绕开(2026-10-14 值班那一轮自己试出来的)
|
|
59
|
+
'nopart': True,
|
|
60
|
+
'quiet': True,
|
|
61
|
+
'no_warnings': True,
|
|
62
|
+
'noplaylist': True,
|
|
63
|
+
'http_headers': {
|
|
64
|
+
'Referer': 'https://www.bilibili.com',
|
|
65
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
with YoutubeDL(opts) as ydl:
|
|
69
|
+
info = ydl.extract_info(url, download=True)
|
|
70
|
+
return ydl.prepare_filename(info)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> int:
|
|
74
|
+
ap = argparse.ArgumentParser(description='把一条视频转成文字(默认中文)')
|
|
75
|
+
ap.add_argument('target', help='BV 号 / B站链接 / 本地音视频文件')
|
|
76
|
+
ap.add_argument('-o', '--out', help='写到这个文件(不给就打到标准输出)')
|
|
77
|
+
ap.add_argument('--model', default=os.environ.get('BILI_DUTY_ASR_MODEL', 'small'),
|
|
78
|
+
help='Whisper 模型大小:tiny / base / small / medium(默认 small)')
|
|
79
|
+
ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
|
|
80
|
+
args = ap.parse_args()
|
|
81
|
+
|
|
82
|
+
src = args.target
|
|
83
|
+
# ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
|
|
84
|
+
# 这个目录由宿主(插件)预先建好;文件名固定,跑完自己清掉
|
|
85
|
+
tmp = _TMP
|
|
86
|
+
# ⭐ 每个视频用**自己的**文件名:以前写死 audio.m4a ⇒ 两轮同时跑会互相覆盖,
|
|
87
|
+
# 上一轮转出来的其实是另一个视频的内容(2026-10-14 值班那轮亲踩 ✗)
|
|
88
|
+
tag = os.path.basename(src).rsplit('.', 1)[0] or 'clip'
|
|
89
|
+
tag = re.sub(r'[^0-9A-Za-z_-]', '_', tag)[:40] or 'clip'
|
|
90
|
+
try:
|
|
91
|
+
if src.lower().startswith('http') or src.upper().startswith('BV'):
|
|
92
|
+
print('[asr] 先下音频…', file=sys.stderr)
|
|
93
|
+
src = fetch_audio(to_url(src), tmp, tag)
|
|
94
|
+
if not os.path.exists(src):
|
|
95
|
+
print('[asr] 找不到文件:' + src, file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
|
|
98
|
+
from faster_whisper import WhisperModel
|
|
99
|
+
print('[asr] 加载模型 %s(第一次会自动下模型,之后就是本地读)…' % args.model, file=sys.stderr)
|
|
100
|
+
model = WhisperModel(args.model, device='cpu', compute_type='int8')
|
|
101
|
+
print('[asr] 开始转写…', file=sys.stderr)
|
|
102
|
+
segments, _info = model.transcribe(
|
|
103
|
+
src,
|
|
104
|
+
language=None if args.lang == 'auto' else args.lang,
|
|
105
|
+
vad_filter=True, # 掐掉静音段,省时间也少出错
|
|
106
|
+
)
|
|
107
|
+
text = ''.join(seg.text for seg in segments).strip()
|
|
108
|
+
finally:
|
|
109
|
+
# 临时音频不留在磁盘上
|
|
110
|
+
try:
|
|
111
|
+
import glob
|
|
112
|
+
for f in glob.glob(os.path.join(tmp, tag + '.*')):
|
|
113
|
+
try:
|
|
114
|
+
os.remove(f)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
if args.out:
|
|
121
|
+
with open(args.out, 'w', encoding='utf-8') as f:
|
|
122
|
+
f.write(text)
|
|
123
|
+
print('[asr] 已写入:' + args.out, file=sys.stderr)
|
|
124
|
+
else:
|
|
125
|
+
print(text)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == '__main__':
|
|
130
|
+
raise SystemExit(main())
|
package/lib/state.mjs
CHANGED
|
@@ -60,6 +60,7 @@ export function ensureHomeFiles(pluginDir, stateDir, log = () => {}) {
|
|
|
60
60
|
const copies = [
|
|
61
61
|
[join(pluginDir, 'vendor', 'bilibili-mcp', 'mcp_server.py'), join(stateDir, 'mcp_server.py'), true],
|
|
62
62
|
[join(pluginDir, 'prompts', 'duty.md'), join(stateDir, 'duty.md'), false],
|
|
63
|
+
[join(pluginDir, 'lib', 'asr.py'), join(stateDir, 'asr.py'), true],
|
|
63
64
|
]
|
|
64
65
|
for (const [from, to, overwrite] of copies) {
|
|
65
66
|
try {
|
package/lib/uvfetch.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/uvfetch.mjs —— 「机器上连 uv 和 Python 都没有」时,**插件自己把 uv 弄到手**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么值得单独做:uv 是**单个可执行文件**(Windows 上 ~32MB 的 uv.exe ✓),
|
|
5
|
+
* **解压就能用、不用管理员、不用进 PATH** ✓,而且它还能自己把 Python 拉下来 ✓
|
|
6
|
+
* ⇒ 只要拿到它,"首次安装"就从「先去装个 Python」变成「点一下就好」✓
|
|
7
|
+
*
|
|
8
|
+
* 两个源,按顺序试:
|
|
9
|
+
* ① **GitHub 官方 release**(`uv-x86_64-pc-windows-msvc.zip`)—— 有加速器时最快 ✓
|
|
10
|
+
* ② ⭐ **PyPI 清华镜像的 uv wheel** —— wheel 本身就是个 zip,里面装着 `uv.exe` ✓
|
|
11
|
+
* (国内速度快、不依赖 GitHub ✓)
|
|
12
|
+
*
|
|
13
|
+
* 解压:优先用 **系统自带的 `tar`**(Windows 10+ 的 bsdtar 能直接读 zip ✓),
|
|
14
|
+
* 再退到 `unzip`;然后**在解压出来的整棵树里按文件名找** ✓
|
|
15
|
+
* —— 不管官方把它放在哪一层都能找着 ✓
|
|
16
|
+
* ============================================================================
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, chmodSync, rmSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { Readable } from 'node:stream'
|
|
23
|
+
import { pipeline } from 'node:stream/promises'
|
|
24
|
+
|
|
25
|
+
const ASSETS = {
|
|
26
|
+
win32: { name: 'uv-x86_64-pc-windows-msvc.zip', bin: 'uv.exe' },
|
|
27
|
+
darwin: { name: 'uv-aarch64-apple-darwin.tar.gz', bin: 'uv' },
|
|
28
|
+
linux: { name: 'uv-x86_64-unknown-linux-gnu.tar.gz', bin: 'uv' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 算一个文件的 sha256 */
|
|
32
|
+
function sha256Of(file) {
|
|
33
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 取"官方期望的 sha256":
|
|
38
|
+
* · PyPI wheel:JSON 里自带 `digests.sha256` ✓
|
|
39
|
+
* · GitHub 资产:同目录下有一份 `<文件名>.sha256` ✓(代理会一起转 ✓)
|
|
40
|
+
* 取不到就返回 null(那时只记一句"未校验",不拦着)
|
|
41
|
+
*/
|
|
42
|
+
async function expectedSha(source) {
|
|
43
|
+
if (source.sha) return String(source.sha).toLowerCase()
|
|
44
|
+
try {
|
|
45
|
+
const r = await fetch(source.url + '.sha256')
|
|
46
|
+
if (!r.ok) return null
|
|
47
|
+
const m = /([0-9a-f]{64})/i.exec(await r.text())
|
|
48
|
+
return m ? m[1].toLowerCase() : null
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 在目录树里按文件名找可执行文件(递归) */
|
|
53
|
+
export function findBin(name, dir) {
|
|
54
|
+
let entries = []
|
|
55
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const p = join(dir, e.name)
|
|
58
|
+
if (e.isDirectory()) { const hit = findBin(name, p); if (hit) return hit }
|
|
59
|
+
else if (e.name === name) return p
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 解压一个压缩包并找出目标文件(tar 优先 —— Windows 自带那份能读 zip) */
|
|
65
|
+
export function extractArchive(binName, archive, into) {
|
|
66
|
+
const attempts = [['tar', ['-xf', archive, '-C', into]], ['unzip', ['-o', archive, '-d', into]]]
|
|
67
|
+
for (const [cmd, args] of attempts) {
|
|
68
|
+
try {
|
|
69
|
+
const r = spawnSync(cmd, args, { stdio: 'ignore' })
|
|
70
|
+
if (r.status === 0) { const hit = findBin(binName, into); if (hit) return hit }
|
|
71
|
+
} catch { /* 换下一个工具 */ }
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 确保家目录里有一份可用的 uv,返回它的路径(拿不到就返回 null)。
|
|
78
|
+
* @param {{ dir: string, log?: (m: string) => void }} opts
|
|
79
|
+
*/
|
|
80
|
+
export async function ensureUv({ dir, log = () => {} }) {
|
|
81
|
+
const a = ASSETS[process.platform] || ASSETS.linux
|
|
82
|
+
const target = join(dir, a.bin)
|
|
83
|
+
if (existsSync(target)) return target
|
|
84
|
+
|
|
85
|
+
mkdirSync(dir, { recursive: true })
|
|
86
|
+
const tmp = join(dir, '_tmp')
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
88
|
+
mkdirSync(tmp, { recursive: true })
|
|
89
|
+
|
|
90
|
+
// 源按顺序试:直连 → 两个国内能用的 GitHub 加速代理(2026-10-14 实测这两条能出真 zip ✓)
|
|
91
|
+
// → 最后兜底是 PyPI 镜像上的 uv wheel(⚠️ 那个停在 0.2.33/2024-08,能用但不是最新 ✗)
|
|
92
|
+
const asset = `https://github.com/astral-sh/uv/releases/latest/download/${a.name}`
|
|
93
|
+
const sources = [
|
|
94
|
+
{ label: 'GitHub 直连', url: asset, file: join(tmp, a.name) },
|
|
95
|
+
{ label: 'GitHub 加速代理 gh-proxy.com', url: `https://gh-proxy.com/${asset}`, file: join(tmp, a.name) },
|
|
96
|
+
{ label: 'GitHub 加速代理 ghproxy.net', url: `https://ghproxy.net/${asset}`, file: join(tmp, a.name) },
|
|
97
|
+
]
|
|
98
|
+
if (process.platform === 'win32') {
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch('https://pypi.tuna.tsinghua.edu.cn/pypi/uv/json')
|
|
101
|
+
const j = await r.json()
|
|
102
|
+
// ⚠️ PyPI 的 urls 顺序**不保证最新在前** ⇒ 必须按版本号自己排序挑最新(踩过:取到了 0.2.33 的老版本 ✗)
|
|
103
|
+
const verOf = (name) => { const m = /-(\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?)-py3-none-win_amd64\.whl$/.exec(String(name)); return m ? m[1] : '0.0.0' }
|
|
104
|
+
const cmpVer = (a, b) => {
|
|
105
|
+
const A = verOf(a).split(/[.-]/).map(Number), B = verOf(b).split(/[.-]/).map(Number)
|
|
106
|
+
for (let i = 0; i < 4; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (B[i] || 0) - (A[i] || 0) }
|
|
107
|
+
return 0
|
|
108
|
+
}
|
|
109
|
+
const w = (j.urls || [])
|
|
110
|
+
.filter((u) => /win_amd64\.whl$/.test(String(u.filename)))
|
|
111
|
+
.sort((x, y) => cmpVer(x.filename, y.filename))[0]
|
|
112
|
+
if (w?.url) {
|
|
113
|
+
sources.push({ label: 'PyPI 清华镜像(uv wheel)', url: w.url, file: join(tmp, String(w.filename)), sha: w.digests?.sha256 })
|
|
114
|
+
log(` (备选源已就绪:${w.filename})`)
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log(` 列 PyPI 版本时出错(不影响,继续试别的源):${e?.message ?? e}`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const s of sources) {
|
|
122
|
+
try {
|
|
123
|
+
log(`正在下载 uv —— ${s.label}`)
|
|
124
|
+
const res = await fetch(s.url)
|
|
125
|
+
if (!res.ok) { log(` → 返回 ${res.status},换下一个源`); continue }
|
|
126
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(s.file))
|
|
127
|
+
const mb = (statSync(s.file).size / 1048576).toFixed(1)
|
|
128
|
+
log(` → 下载完成 ${mb} MB`)
|
|
129
|
+
// ⭐ 完整性校验:下载下来是要**被执行**的,必须跟官方 sha256 对上(防的是第三方代理动过包 ✗)
|
|
130
|
+
const want = await expectedSha(s)
|
|
131
|
+
if (want) {
|
|
132
|
+
const gotSha = sha256Of(s.file)
|
|
133
|
+
if (gotSha !== want) {
|
|
134
|
+
log(` → ⚠️ sha256 校验不过(期望 ${want.slice(0, 12)}… 实际 ${gotSha.slice(0, 12)}…)⇒ 丢弃这个源`)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
log(' → 官方 sha256 校验通过 ✓')
|
|
138
|
+
} else {
|
|
139
|
+
log(' → 没取到官方 sha256,跳过校验(下载来自 HTTPS 的官方地址时风险很小)')
|
|
140
|
+
}
|
|
141
|
+
log(' → 开始解压')
|
|
142
|
+
const hit = extractArchive(a.bin, s.file, tmp)
|
|
143
|
+
if (!hit) { log(' → 解压后没找到可执行文件,换下一个源'); continue }
|
|
144
|
+
copyFileSync(hit, target)
|
|
145
|
+
try { chmodSync(target, 0o755) } catch { /* Windows 上不需要 */ }
|
|
146
|
+
try {
|
|
147
|
+
const r = spawnSync(target, ['--version'], { stdio: 'ignore' })
|
|
148
|
+
log(' → 版本检查:' + (r.status === 0 ? '能跑 ✓' : '跑不起来 ✗'))
|
|
149
|
+
} catch { /* 跑不起来也别拦着,后面自己会报错 */ }
|
|
150
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
151
|
+
log(` → uv 已就位:${target}`)
|
|
152
|
+
return target
|
|
153
|
+
} catch (e) {
|
|
154
|
+
log(` → ${s.label} 失败:${e?.message ?? e}`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
159
|
+
log('所有源都拿不到 uv(大概是网络到不了 GitHub / PyPI 镜像)')
|
|
160
|
+
return null
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-scroll",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DSH 插件:每天替你刷一轮 B站 —— 读字幕挑出值得的、写一份日报,值得的就 @ 你",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"mcp",
|
|
11
11
|
"duty"
|
|
12
12
|
],
|
|
13
|
+
"repository": { "type": "git", "url": "git+https://github.com/c1overzz/dsh-bili-scroll.git" },
|
|
14
|
+
"homepage": "https://github.com/c1overzz/dsh-bili-scroll#readme",
|
|
15
|
+
"bugs": { "url": "https://github.com/c1overzz/dsh-bili-scroll/issues" },
|
|
13
16
|
"license": "MIT",
|
|
14
17
|
"type": "module",
|
|
15
18
|
"main": "index.js",
|
|
@@ -26,9 +29,11 @@
|
|
|
26
29
|
"prompts",
|
|
27
30
|
"vendor",
|
|
28
31
|
"README.md",
|
|
32
|
+
"AGENTS.md",
|
|
29
33
|
"LICENSE"
|
|
30
34
|
],
|
|
31
35
|
"engines": {
|
|
36
|
+
"dsh": ">=0.1.7-rc.1",
|
|
32
37
|
"node": ">=22"
|
|
33
38
|
},
|
|
34
39
|
"peerDependencies": {
|
package/prompts/duty.md
CHANGED
|
@@ -13,14 +13,38 @@
|
|
|
13
13
|
⚠️ **不要把日记内容抄进报告或评论** —— 那是他的私事。
|
|
14
14
|
- 两份都没有?那就中性一点,别擅自扮演任何人格。
|
|
15
15
|
|
|
16
|
+
## 先记住两条(省钱,也是纪律)
|
|
17
|
+
|
|
18
|
+
- ⚠️ **看够了就下结论**:不要反复权衡同一条该不该收 —— 拿不准就按"跳过"处理,别来回改主意。
|
|
19
|
+
- ⚠️ **工具失败两次就记为"跳过"**:不要反复重试同一个命令、也不要为了绕过它写一堆临时脚本。
|
|
20
|
+
(这两条省的是使用者的钱:实测里"纠结"那一轮比干净的一轮多花了**近一倍** token。)
|
|
21
|
+
|
|
22
|
+
## 边界(这是别人的账号,这几条不能碰)
|
|
23
|
+
|
|
24
|
+
- ⚠️ **只发它自己那一条「@ 使用者」的新评论**,发完就走。
|
|
25
|
+
- ⚠️ **不要回复任何人的评论** —— 包括有人回复到它那条评论下面的:**也不要回**。
|
|
26
|
+
- ⚠️ **不要参与讨论、不要争辩、不要替使用者答问题**。有人问技术问题,就写进报告里,让使用者自己回。
|
|
27
|
+
- ⚠️ **同一个视频下不要留第二条评论**(判重表记着)。
|
|
28
|
+
- 理由:它发出去的每个字,都算在**使用者本人的账号**上 —— 越界就是替他惹麻烦。
|
|
29
|
+
|
|
30
|
+
## 日报可以只有薄薄两行(看这一轮开头怎么说的)
|
|
31
|
+
|
|
32
|
+
- **薄版**:每条一两行 —— 「值不值得 + 链接」就够,别写摘要。
|
|
33
|
+
- **完整**:就是现在这样(标题 / UP / 为什么值得)。
|
|
34
|
+
- **不写**:开头这么说了,就别生成报告文件,只在真发了评论时记一句。
|
|
35
|
+
|
|
16
36
|
## 一轮要做的事
|
|
17
37
|
|
|
18
38
|
1. **搜一轮**(近 7 天优先):用**设置页里填的「关注关键词」**;没填就用默认这几组 ——
|
|
19
39
|
`DeepSeek Harness`、`DSH 插件`、`dsh 教程`、`meow-memory`、`DeepSeek Harness 桌面端`
|
|
20
|
-
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** ——
|
|
40
|
+
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** —— 已经看过的跳过(判重表就在家目录根下,叫 `seen.md`)
|
|
21
41
|
3. 每条:
|
|
22
42
|
- **读字幕原文**(⚠️ 别看摘要下判断)
|
|
23
|
-
-
|
|
43
|
+
- 没有字幕时:**只有**设置里开了「本地转写」才去转写;没开就跳过、别硬凑。
|
|
44
|
+
转写命令(家目录里的独立环境,别用主 venv):
|
|
45
|
+
· Windows:`<家目录>\.venv-asr\Scripts\python.exe <家目录>\asr.py <BV号>`
|
|
46
|
+
· macOS/Linux:`<家目录>/.venv-asr/bin/python <家目录>/asr.py <BV号>`
|
|
47
|
+
它会把字幕打到标准输出(第一次调用要加载模型,慢几十秒正常)。
|
|
24
48
|
- 顺手翻一眼评论
|
|
25
49
|
4. 写一份 Markdown 报告,每条一段:
|
|
26
50
|
**标题 / UP / 链接 / 播放量 / 为什么值得(两三句)/ 你自己的一句话**,结尾给一句总结
|