dsh-bili-scroll 0.1.0 → 0.1.1
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 +34 -0
- package/client.js +50 -7
- package/index.js +139 -10
- package/lib/asr.py +130 -0
- package/lib/state.mjs +1 -0
- package/lib/uvfetch.mjs +161 -0
- package/package.json +3 -1
- package/prompts/duty.md +12 -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,10 @@
|
|
|
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
|
+
|
|
3
8
|
> DSH 插件:**每天替你刷一轮 B站** —— 它自己搜、**读字幕原文**判断、把值得的写成报告,特别值得的会留一条评论 **@ 你**。
|
|
4
9
|
> **你收到的每一条 @,都是它先筛过一遍的。**
|
|
5
10
|
|
|
@@ -11,8 +16,24 @@
|
|
|
11
16
|
- 开着「自动评论」时,每轮最多发一条评论、**@ 你**一次
|
|
12
17
|
- 可以在**峰谷时段自动跑**(DeepSeek 的优惠时段),省 token
|
|
13
18
|
|
|
19
|
+
## 装之前(一条前提,30 秒)
|
|
20
|
+
|
|
21
|
+
机器上要有 **`Python 3.10+` 或 `uv`** —— **有一个就够**:
|
|
22
|
+
|
|
23
|
+
- **有 `uv` 最好**:单个 ~32 MB 的 exe,**解压就能用、不用管理员、不用进 PATH**,而且它还能自己把 Python 拉下来;
|
|
24
|
+
- 只有 Python 也行(装官方安装器时记得勾 **`Add to PATH`**);
|
|
25
|
+
- ⭐ **两个都没有?也没关系**:点「装依赖」时**插件会自己下一份 `uv` 放到自己的家目录**
|
|
26
|
+
(单文件、约 15MB、**免管理员、不用进 PATH** ✓),之后全程自动 ✓
|
|
27
|
+
· 它会**先直连 GitHub,不通就走国内加速代理**,还配了 PyPI 镜像兜底 ✓
|
|
28
|
+
· 下载完会**核对官方 `sha256`**(对不上就丢弃换下一个源 ✓)—— 因为它下来的东西是要**被执行**的
|
|
29
|
+
· ⚠️ **如果杀软 / Windows Defender 报这个下载**:把插件家目录 `%USERPROFILE%\.dsh\bili-duty\bin\` **加白**即可;
|
|
30
|
+
不想加白也行,自己装一份 uv 或 Python 3.10+ 就绕过去了 ✓
|
|
31
|
+
|
|
32
|
+
> 计划中(v0.1.1):**插件自己把 `uv` 下到自己的家目录** ⇒ 做到"一键准备环境",这条前提就不用用户操心了。
|
|
33
|
+
|
|
14
34
|
## 装
|
|
15
35
|
|
|
36
|
+
|
|
16
37
|
```bash
|
|
17
38
|
dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
18
39
|
```
|
|
@@ -52,6 +73,19 @@ dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
|
52
73
|
- 那一轮的**工作目录 = 你填的工作区** ⇒ 那里的 `AGENTS.md` 会自动加载 ⇒ **它按你的设定说话**,
|
|
53
74
|
而不是用一套通用人格
|
|
54
75
|
|
|
76
|
+
## 如果你是一个 AI 助手,正在帮用户装这个插件
|
|
77
|
+
|
|
78
|
+
按顺序来,别跳步:
|
|
79
|
+
|
|
80
|
+
1. **先确认前提**:机器上有 `Python 3.10+` 或 `uv` 吗?(查 `python --version` / `uv --version`)
|
|
81
|
+
两个都没有 ⇒ **建议装 `uv`**(单文件 exe、免管理员、能自带 Python);
|
|
82
|
+
2. **装插件**:`dsh plugin --profile web add github:c1overzz/dsh-bili-scroll`
|
|
83
|
+
(npm 上的 `dsh-bili-scroll` 同名同内容,审核通过后用 `add dsh-bili-scroll` 更省事)
|
|
84
|
+
3. ⚠️ **必须提醒用户重启一次 dsh** —— bundle 层要重启才组装,不重启插件不会生效;
|
|
85
|
+
4. 重启后请用户进 **设置 → 刷B站**:先点「**装依赖**」(几十秒,进度就显示在页面下面)、再点「**扫码登录**」
|
|
86
|
+
(手机 B 站扫码,**确认时选"记住我 / 长期"**);
|
|
87
|
+
5. **不要**替用户打开「自动评论并 @ 你」—— 那是拿用户的号说话。要用得让用户自己在设置里填「要 @ 谁」。
|
|
88
|
+
|
|
55
89
|
## 依赖与致谢
|
|
56
90
|
|
|
57
91
|
- 内置 [**bilibili-mcp**](https://github.com/adoresever/bilibili-mcp)(MIT,Copyright (c) 2026 AGI_Ananas)——
|
package/client.js
CHANGED
|
@@ -140,6 +140,7 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
const [perRound, setPerRound] = React.useState(null)
|
|
141
141
|
const [mention, setMention] = React.useState(null)
|
|
142
142
|
const [login, setLogin] = React.useState(null)
|
|
143
|
+
const [runNote, setRunNote] = React.useState(null) // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
|
|
143
144
|
const [loginState, setLoginState] = React.useState('')
|
|
144
145
|
const [loginTone, setLoginTone] = React.useState('')
|
|
145
146
|
|
|
@@ -154,7 +155,10 @@ window.__ModuleLoader__.load({
|
|
|
154
155
|
|
|
155
156
|
const win = { ...DEFAULT_WINDOWS, ...(st?.windows || {}) }
|
|
156
157
|
|
|
157
|
-
const say = (text, tone) => {
|
|
158
|
+
const say = (text, tone) => {
|
|
159
|
+
setMsg(text); setMsgTone(tone || '')
|
|
160
|
+
setRunNote(text ? { t: text, tone: tone || '' } : null) // ⭐ 同时镜像到顶部,免得提示掉到页面最底下看不见
|
|
161
|
+
}
|
|
158
162
|
|
|
159
163
|
const patch = async (p, what) => {
|
|
160
164
|
setBusy(JSON.stringify(p))
|
|
@@ -166,6 +170,26 @@ window.__ModuleLoader__.load({
|
|
|
166
170
|
finally { setBusy('') }
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
const onCheck = async () => {
|
|
174
|
+
setBusy('check')
|
|
175
|
+
say('')
|
|
176
|
+
try {
|
|
177
|
+
const r = await jpost('/check', {})
|
|
178
|
+
if (r && r.ok) say('能用!' + (r.steps || []).join('、'))
|
|
179
|
+
else say('还不能用:' + (r && r.error ? r.error : '没拿到结果'))
|
|
180
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const onInstallAsr = async () => {
|
|
184
|
+
setBusy('asr')
|
|
185
|
+
say('')
|
|
186
|
+
try {
|
|
187
|
+
const r = await jpost('/install-asr', {})
|
|
188
|
+
say(r?.started ? '开始装转写依赖 —— 进度显示在下面(要下模型,慢一点正常)' : (r?.hint || '没起来'),
|
|
189
|
+
r?.started ? 'good' : 'warn')
|
|
190
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
191
|
+
}
|
|
192
|
+
|
|
169
193
|
const onInstall = async () => {
|
|
170
194
|
setBusy('install')
|
|
171
195
|
say('')
|
|
@@ -230,10 +254,12 @@ window.__ModuleLoader__.load({
|
|
|
230
254
|
/* ── 今天 ── */
|
|
231
255
|
h('div', { 'data-bd-row': '' },
|
|
232
256
|
h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.ranToday} / ${cfg.perDay} 条`),
|
|
233
|
-
h('span', { 'data-bd-badge': '' },
|
|
257
|
+
h('span', { 'data-bd-badge': '' }, '环境' + (st.venvReady ? '已就绪' : (st.installing ? '正在装' : '未装'))),
|
|
234
258
|
h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
|
|
235
259
|
h('div', { 'data-bd-grow': '' }),
|
|
236
260
|
h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
|
|
261
|
+
runNote ? h('div', { 'data-bd-msg': '', 'data-good': runNote.tone === 'good' ? '' : undefined,
|
|
262
|
+
'data-warn': runNote.tone === 'warn' ? '' : undefined }, runNote.t) : null,
|
|
237
263
|
|
|
238
264
|
/* ── 定时 ── */
|
|
239
265
|
h('div', { 'data-bd-sec': '' },
|
|
@@ -298,10 +324,24 @@ window.__ModuleLoader__.load({
|
|
|
298
324
|
h('div', { 'data-bd-note': '' }, '留空的话它就不发评论了 —— 因为评论存在的意义就是 @ 那一下')) : null,
|
|
299
325
|
h(ToggleRow, {
|
|
300
326
|
label: '本地转写没字幕的视频',
|
|
301
|
-
note: '
|
|
327
|
+
note: '默认关;开了以后,没字幕的视频会在本机转写一遍(不花 API 钱)',
|
|
302
328
|
on: sw('transcribe'),
|
|
303
329
|
onChange: (v) => patch({ transcribe: v }, '本地转写'),
|
|
304
|
-
})
|
|
330
|
+
}),
|
|
331
|
+
sw('transcribe') ? h('div', { 'data-bd-sub': '' },
|
|
332
|
+
h('div', { 'data-bd-row': '' },
|
|
333
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
|
|
334
|
+
st.asrReady ? '重装转写依赖' : '装转写依赖'),
|
|
335
|
+
h('div', { 'data-bd-note': '' },
|
|
336
|
+
!st.asrReady ? '还没装 —— 没装的话转写用不了'
|
|
337
|
+
: (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
|
|
338
|
+
h('div', { 'data-bd-note': '' },
|
|
339
|
+
'点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
|
|
340
|
+
h('br'),
|
|
341
|
+
'(模型默认 small,几百 MB,走国内镜像;装一次就够了)'),
|
|
342
|
+
st.asrTail ? h('div', null,
|
|
343
|
+
h('div', { 'data-bd-note': '' }, st.asrInstalling ? '正在装转写依赖(进度):' : '转写依赖的日志(末尾)—— 只有「没字幕的视频」用得到它:'),
|
|
344
|
+
h('pre', { 'data-bd-pre': '' }, st.asrTail)) : null) : null),
|
|
305
345
|
|
|
306
346
|
/* ── 关注什么 / 在哪儿刷 ── */
|
|
307
347
|
h('div', { 'data-bd-sec': '' },
|
|
@@ -324,12 +364,15 @@ window.__ModuleLoader__.load({
|
|
|
324
364
|
h('div', { 'data-bd-h': '' }, '环境与账号'),
|
|
325
365
|
h('div', { 'data-bd-row': '' },
|
|
326
366
|
h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
|
|
327
|
-
st.venvReady ? '
|
|
367
|
+
st.venvReady ? '重装主依赖' : '装主依赖(建 venv)'),
|
|
368
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'check', onClick: onCheck }, '测一下'),
|
|
328
369
|
h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
|
|
329
|
-
h('div', { 'data-bd-note': '' },
|
|
370
|
+
h('div', { 'data-bd-note': '' },
|
|
371
|
+
!st.venvReady ? (st.installFailed ? '上次没装成 → 看下面日志' : '还没装 → 先点「装主依赖」')
|
|
372
|
+
: (st.loggedIn ? '装好了 → 下一步「扫码登录」' : '环境好了 → 点「扫码登录」'))),
|
|
330
373
|
loginState ? h('div', { 'data-bd-msg': '', 'data-good': loginTone === 'good' ? '' : undefined, 'data-warn': loginTone === 'warn' ? '' : undefined }, loginState) : null,
|
|
331
374
|
st.installTail ? h('div', null,
|
|
332
|
-
h('div', { 'data-bd-note': '' }, st.installing ? '
|
|
375
|
+
h('div', { 'data-bd-note': '' }, st.installing ? '正在装主依赖(进度):' : '主依赖的日志(B站 搜索 / 字幕 / 评论 用得到,末尾):'),
|
|
333
376
|
h('pre', { 'data-bd-pre': '' }, st.installTail)) : null,
|
|
334
377
|
login?.qr ? h('div', { 'data-bd-qr': '' },
|
|
335
378
|
h('img', { src: login.qr, alt: 'B站登录二维码' }),
|
package/index.js
CHANGED
|
@@ -23,6 +23,7 @@ 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站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
|
|
@@ -88,7 +91,7 @@ export function apply(ctx, config) {
|
|
|
88
91
|
* ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
|
|
89
92
|
*/
|
|
90
93
|
const DEFAULTS = {
|
|
91
|
-
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false,
|
|
94
|
+
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
|
|
92
95
|
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '',
|
|
93
96
|
}
|
|
94
97
|
const normalize = (src, base) => {
|
|
@@ -393,7 +396,7 @@ export function apply(ctx, config) {
|
|
|
393
396
|
+ `然后读章程 ${duty},按里面的要求刷一轮 B站,`
|
|
394
397
|
+ `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`
|
|
395
398
|
+ `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
|
|
396
|
-
+ `本地转写是${cfg.transcribe ? '
|
|
399
|
+
+ `本地转写是${cfg.transcribe ? '开着的(没字幕就用家目录里的 asr.py 转,命令见章程)' : '关着的(没字幕就跳过)'};`
|
|
397
400
|
+ `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
|
|
398
401
|
? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
|
|
399
402
|
: '关着的(只写报告,别发评论)'}。`
|
|
@@ -558,6 +561,91 @@ export function apply(ctx, config) {
|
|
|
558
561
|
c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
|
|
559
562
|
})
|
|
560
563
|
|
|
564
|
+
// ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
|
|
565
|
+
const asrDir = join(stateDir, '.venv-asr')
|
|
566
|
+
const asrLogFile = join(stateDir, 'asr-install.log')
|
|
567
|
+
const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
|
|
568
|
+
process.platform === 'win32' ? 'python.exe' : 'python')
|
|
569
|
+
const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
|
|
570
|
+
// ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
|
|
571
|
+
// 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
|
|
572
|
+
const asrTmp = join(stateDir, 'tmp', 'asr')
|
|
573
|
+
try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
|
|
574
|
+
let installingAsr = false
|
|
575
|
+
|
|
576
|
+
/** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
|
|
577
|
+
const resolveUv = async () => {
|
|
578
|
+
const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
579
|
+
if (await haveUv()) return 'uv'
|
|
580
|
+
if (existsSync(bundled)) return bundled
|
|
581
|
+
appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
|
|
582
|
+
const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
|
|
583
|
+
if (got) appendInstall('uv 已就位:' + got + '\n')
|
|
584
|
+
return got
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
|
|
588
|
+
const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
|
|
589
|
+
sink(`\n$ ${cmd} ${args.join(' ')}\n`)
|
|
590
|
+
let c
|
|
591
|
+
try {
|
|
592
|
+
c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
593
|
+
env: env ? { ...process.env, ...env } : process.env })
|
|
594
|
+
} catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
|
|
595
|
+
const w = (b) => sink(String(b))
|
|
596
|
+
c.stdout?.on('data', w)
|
|
597
|
+
c.stderr?.on('data', w)
|
|
598
|
+
c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
|
|
599
|
+
c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
/** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
|
|
603
|
+
const runAsrInstall = () => {
|
|
604
|
+
if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
|
|
605
|
+
installingAsr = true
|
|
606
|
+
appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
|
|
607
|
+
;(async () => {
|
|
608
|
+
try {
|
|
609
|
+
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
610
|
+
const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
|
|
611
|
+
const py = asrPython()
|
|
612
|
+
const uvCmd = await resolveUv()
|
|
613
|
+
if (!existsSync(py)) {
|
|
614
|
+
// --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
|
|
615
|
+
if (uvCmd) await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
|
|
616
|
+
else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
|
|
617
|
+
}
|
|
618
|
+
if (!existsSync(py)) {
|
|
619
|
+
appendAsr('建环境失败 —— 先点上面的「装依赖」,把 uv 或 Python 准备好\n')
|
|
620
|
+
log('转写:建环境失败'); return
|
|
621
|
+
}
|
|
622
|
+
appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
|
|
623
|
+
let code = 1
|
|
624
|
+
// ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
|
|
625
|
+
// 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
|
|
626
|
+
const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
|
|
627
|
+
if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
628
|
+
if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
629
|
+
if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
|
|
630
|
+
// ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
|
|
631
|
+
const model = String(cfg.asrModel || 'small')
|
|
632
|
+
// 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
|
|
633
|
+
const hfHome = join(stateDir, '.hf-cache')
|
|
634
|
+
try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
|
|
635
|
+
appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
|
|
636
|
+
const code2 = await spawnTo(py, ['-c',
|
|
637
|
+
'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("模型就绪")',
|
|
638
|
+
model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
|
|
639
|
+
appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${existsSync(py)})===\n`)
|
|
640
|
+
log(`装转写依赖结束:exit=${code}/${code2}`)
|
|
641
|
+
} catch (e) {
|
|
642
|
+
appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
|
|
643
|
+
log(`装转写依赖崩了:${e?.message ?? e}`)
|
|
644
|
+
} finally { installingAsr = false }
|
|
645
|
+
})()
|
|
646
|
+
return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
|
|
647
|
+
}
|
|
648
|
+
|
|
561
649
|
const runInstall = () => {
|
|
562
650
|
if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
|
|
563
651
|
installing = true
|
|
@@ -567,18 +655,20 @@ export function apply(ctx, config) {
|
|
|
567
655
|
const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
|
|
568
656
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
569
657
|
const venvPy = venvPython()
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
658
|
+
const py = String(cfg.pythonPath || 'python')
|
|
659
|
+
const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
|
|
660
|
+
if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
|
|
661
|
+
const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
|
|
662
|
+
+ '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
|
|
663
|
+
+ '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
|
|
574
664
|
appendInstall(msg + '\n'); log(msg); installing = false; return
|
|
575
665
|
}
|
|
576
666
|
if (!existsSync(venvPy)) {
|
|
577
|
-
if (
|
|
578
|
-
if (!existsSync(venvPy)) await spawnLogged(
|
|
667
|
+
if (uvCmd) await spawnLogged(uvCmd, ['venv', join(stateDir, '.venv')])
|
|
668
|
+
if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
|
|
579
669
|
}
|
|
580
670
|
let code = 1
|
|
581
|
-
if (
|
|
671
|
+
if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
|
|
582
672
|
if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
|
|
583
673
|
if (existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
|
|
584
674
|
appendInstall(`=== 结束(exit ${code},venvReady=${existsSync(venvPy)})===\n`)
|
|
@@ -591,6 +681,27 @@ export function apply(ctx, config) {
|
|
|
591
681
|
return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
|
|
592
682
|
}
|
|
593
683
|
|
|
684
|
+
/** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
|
|
685
|
+
const checkUp = async () => {
|
|
686
|
+
const out = { ok: false, steps: [] }
|
|
687
|
+
if (!existsSync(venvPython())) { out.error = '环境还没装:先点「装主依赖」(几十秒)'; return out }
|
|
688
|
+
if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
|
|
689
|
+
try {
|
|
690
|
+
const who = await call('bili_check_credential', {})
|
|
691
|
+
out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
|
|
692
|
+
out.steps.push('登录有效')
|
|
693
|
+
} catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
|
|
694
|
+
try {
|
|
695
|
+
const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
|
|
696
|
+
const t = typeof s === 'string' ? s : JSON.stringify(s)
|
|
697
|
+
out.sample = t.slice(0, 160)
|
|
698
|
+
out.steps.push('能搜到东西')
|
|
699
|
+
out.ok = t.length > 2
|
|
700
|
+
if (!out.ok) out.error = '搜到了空结果'
|
|
701
|
+
} catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
|
|
702
|
+
return out
|
|
703
|
+
}
|
|
704
|
+
|
|
594
705
|
const status = () => {
|
|
595
706
|
const st = readJson(dutyStateFile, {})
|
|
596
707
|
const today = dayKey()
|
|
@@ -605,7 +716,15 @@ export function apply(ctx, config) {
|
|
|
605
716
|
lastWhy: st.lastWhy || null,
|
|
606
717
|
loggedIn: hasCredential(),
|
|
607
718
|
venvReady: existsSync(venvPython()),
|
|
719
|
+
installFailed: !installing && !existsSync(venvPython()) && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
|
|
608
720
|
installing,
|
|
721
|
+
// ── 本地转写那一套的状态(设置页要显示进度)──
|
|
722
|
+
asrReady: existsSync(asrPython()),
|
|
723
|
+
// 模型下没下(免得界面写"就绪"、其实还得现下)
|
|
724
|
+
asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
|
|
725
|
+
asrInstalling: installingAsr,
|
|
726
|
+
asrLog: asrLogFile,
|
|
727
|
+
asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
|
|
609
728
|
installLog: installLogFile,
|
|
610
729
|
// ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
|
|
611
730
|
installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
|
|
@@ -625,7 +744,7 @@ export function apply(ctx, config) {
|
|
|
625
744
|
if (req.method === 'POST' && path === '/api/bili-duty/config') {
|
|
626
745
|
const body = await readBody(req)
|
|
627
746
|
const patch = {}
|
|
628
|
-
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe',
|
|
747
|
+
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
|
|
629
748
|
'commentPerRound', 'mentionName',
|
|
630
749
|
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace']) {
|
|
631
750
|
if (k in body) patch[k] = body[k]
|
|
@@ -645,6 +764,16 @@ export function apply(ctx, config) {
|
|
|
645
764
|
return sendJson(res, 200, runInstall())
|
|
646
765
|
}
|
|
647
766
|
|
|
767
|
+
// 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
|
|
768
|
+
if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
|
|
769
|
+
return sendJson(res, 200, runAsrInstall())
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 「测一下」:真跑一次,回答"能不能用"
|
|
773
|
+
if (req.method === 'POST' && path === '/api/bili-duty/check') {
|
|
774
|
+
return sendJson(res, 200, await checkUp())
|
|
775
|
+
}
|
|
776
|
+
|
|
648
777
|
// ── 扫码登录(设置页用)──
|
|
649
778
|
if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
|
|
650
779
|
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.1",
|
|
4
4
|
"description": "DSH 插件:每天替你刷一轮 B站 —— 读字幕挑出值得的、写一份日报,值得的就 @ 你",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
"prompts",
|
|
27
27
|
"vendor",
|
|
28
28
|
"README.md",
|
|
29
|
+
"AGENTS.md",
|
|
29
30
|
"LICENSE"
|
|
30
31
|
],
|
|
31
32
|
"engines": {
|
|
33
|
+
"dsh": ">=0.1.7-rc.1",
|
|
32
34
|
"node": ">=22"
|
|
33
35
|
},
|
|
34
36
|
"peerDependencies": {
|
package/prompts/duty.md
CHANGED
|
@@ -13,14 +13,24 @@
|
|
|
13
13
|
⚠️ **不要把日记内容抄进报告或评论** —— 那是他的私事。
|
|
14
14
|
- 两份都没有?那就中性一点,别擅自扮演任何人格。
|
|
15
15
|
|
|
16
|
+
## 先记住两条(省钱,也是纪律)
|
|
17
|
+
|
|
18
|
+
- ⚠️ **看够了就下结论**:不要反复权衡同一条该不该收 —— 拿不准就按"跳过"处理,别来回改主意。
|
|
19
|
+
- ⚠️ **工具失败两次就记为"跳过"**:不要反复重试同一个命令、也不要为了绕过它写一堆临时脚本。
|
|
20
|
+
(这两条省的是使用者的钱:实测里"纠结"那一轮比干净的一轮多花了**近一倍** token。)
|
|
21
|
+
|
|
16
22
|
## 一轮要做的事
|
|
17
23
|
|
|
18
24
|
1. **搜一轮**(近 7 天优先):用**设置页里填的「关注关键词」**;没填就用默认这几组 ——
|
|
19
25
|
`DeepSeek Harness`、`DSH 插件`、`dsh 教程`、`meow-memory`、`DeepSeek Harness 桌面端`
|
|
20
|
-
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** ——
|
|
26
|
+
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** —— 已经看过的跳过(判重表就在家目录根下,叫 `seen.md`)
|
|
21
27
|
3. 每条:
|
|
22
28
|
- **读字幕原文**(⚠️ 别看摘要下判断)
|
|
23
|
-
-
|
|
29
|
+
- 没有字幕时:**只有**设置里开了「本地转写」才去转写;没开就跳过、别硬凑。
|
|
30
|
+
转写命令(家目录里的独立环境,别用主 venv):
|
|
31
|
+
· Windows:`<家目录>\.venv-asr\Scripts\python.exe <家目录>\asr.py <BV号>`
|
|
32
|
+
· macOS/Linux:`<家目录>/.venv-asr/bin/python <家目录>/asr.py <BV号>`
|
|
33
|
+
它会把字幕打到标准输出(第一次调用要加载模型,慢几十秒正常)。
|
|
24
34
|
- 顺手翻一眼评论
|
|
25
35
|
4. 写一份 Markdown 报告,每条一段:
|
|
26
36
|
**标题 / UP / 链接 / 播放量 / 为什么值得(两三句)/ 你自己的一句话**,结尾给一句总结
|