dsh-bili-scroll 0.1.2 → 0.1.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/AGENTS.md +25 -5
- package/README.md +37 -8
- package/client.js +66 -2
- package/index.js +173 -10
- package/lib/asr.py +8 -0
- package/lib/hostdeps.mjs +141 -0
- package/lib/verify.mjs +186 -0
- package/package.json +4 -1
- package/prompts/duty.md +19 -3
package/AGENTS.md
CHANGED
|
@@ -9,20 +9,40 @@
|
|
|
9
9
|
只有它自己也下不动时(网络到不了 GitHub / 加速代理 / PyPI 镜像),才需要引导用户手动装。
|
|
10
10
|
· 它是**直连 GitHub → 国内加速代理 → PyPI 镜像**依次试的,并且会**核对官方 sha256** ✓
|
|
11
11
|
· ⚠️ 若用户机器上的**杀软拦了这次下载**:让他把 `%USERPROFILE%\.dsh\bili-duty\bin\` 加白,或干脆自己装 uv / Python 3.10+ ✓
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
· ⭐ **万一下载链路都不通**(有用户反馈"桌面版上这条链路经常不通"):**手动放一份 `uv.exe` 到 `<家目录>\bin\` 就行**
|
|
13
|
+
—— 插件会**优先用那里那一份**,跳过下载 ✓
|
|
14
|
+
2. `dsh plugin --profile web add dsh-bili-scroll`(npm,推荐)
|
|
15
|
+
· 备选:`dsh plugin --profile web add github:c1overzz/dsh-bili-scroll`
|
|
16
|
+
· ⚠️ **升级旧版**:如果用户在设置页看到的按钮叫「**重装依赖**」(不是「**重装主依赖**」),
|
|
17
|
+
说明装的还是 **0.1.0** —— 那一版建 Python 环境时缺一个参数,**依赖装不上、二维码也画不出来**。
|
|
18
|
+
让用户重跑一次上面那条 `add` 就是升级(然后再重启一次)。
|
|
14
19
|
3. ⚠️ **提醒用户重启一次 dsh**(必须,bundle 层要重启才组装)。
|
|
15
|
-
4. 让用户进 **设置 → 刷B
|
|
20
|
+
4. 让用户进 **设置 → 刷B站**:点「**装主依赖**」→ 点「**扫码登录**」(手机扫码,选"记住我 / 长期")。
|
|
21
|
+
· 装完可以用设置页的「**测一下**」自检:它真跑一次登录检查 + 一次搜索,直接回答"能不能用"。
|
|
16
22
|
5. **别擅自打开「自动评论并 @ 你」** —— 那是拿用户的号说话;要用得让用户自己填「要 @ 谁」。
|
|
17
23
|
|
|
24
|
+
## ⚠️ 已知环境差异(有用户反馈)
|
|
25
|
+
|
|
26
|
+
- ⭐⭐ **桌面版 DSH(NEXT)/ 0.2.x 装不起来 —— 已知真 bug,0.1.4 起已修**:0.1.3 及以前装到桌面版上,
|
|
27
|
+
设置页里**根本不出现「刷B站」**,DSH 只留一句 `warning: 1 entry did not activate … failed to import` ✓
|
|
28
|
+
(根因:宿主半 import 的 `@deepseek-ai/schemastery` / `dsh-tools` 是 *optional peer*,而桌面版 profile 是
|
|
29
|
+
`autoInstallPeers: false` ⇒ pnpm 永远不装;`dsh-tools` 自己还带 4 个 peer,光装它也救不回来 ✗)。
|
|
30
|
+
⇒ **遇到桌面版用户:先让他升级到 0.1.4+**(修法是:不再裸 import `@deepseek-ai/*` ——
|
|
31
|
+
`lib/hostdeps.mjs` 先找宿主那棵树、再退到自己依赖里那份,`defineTool` 拿不到就用**本地等价实现**;
|
|
32
|
+
`schemastery` 也正式进了 `dependencies` ✓)。升级后还不行,要 `duty.log` 里那几行
|
|
33
|
+
(里面会写"宿主依赖是从哪儿取的" ✓)。
|
|
34
|
+
- **桌面版上"自动跑会卡"**:跑那一轮用的是 `dsh --profile headless --patch <overlay> <prompt>` ——
|
|
35
|
+
有用户反馈桌面版上会卡在那儿。**先确认 dsh 版本与 profile**,别硬跑;跑不起来就先只用手动「现在跑一轮」。
|
|
36
|
+
- **别让用户自己去装环境** ✗:插件自带一份 uv 与依赖安装能力,先照上面第 1 条处理。
|
|
37
|
+
|
|
18
38
|
## 仓库结构(改代码时看这里)
|
|
19
39
|
|
|
20
40
|
- `index.js` —— host 半端:11 个工具 + 谷时定时器 + HTTP 接口 + 设置命名空间
|
|
21
41
|
- `client.js` —— 浏览器半端:设置页(注册在 `settings.section`)
|
|
22
|
-
- `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录
|
|
42
|
+
- `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录 / **`hostdeps.mjs`(从宿主树取 `@deepseek-ai/*`,拿不到就退到自己依赖 / 本地等价实现)** / **`verify.mjs`(评论发出后复核)**
|
|
23
43
|
- `prompts/duty.md` —— 一轮要做的事(判定口径 + 红线,**用户可以改**)
|
|
24
44
|
- `vendor/bilibili-mcp/` —— 内置的 Python MCP(MIT,AGI_Ananas)
|
|
25
|
-
- `selfcheck.mjs` + `tools/` —— 本地预检:`node
|
|
45
|
+
- `selfcheck.mjs` + `tools/` —— 本地预检:`node selfcheck.mjs`(**现在不用解析钩子了**;`tools/register.mjs` 那条还留着当备份)
|
|
26
46
|
|
|
27
47
|
## 约定
|
|
28
48
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
> - 需要 **dsh ≥ `0.1.7-rc.1`**(本机是 `0.1.7-rc.2`)。⚠️ 版本不满足时,**内核会静默跳过这个插件** —— 装了跟没装一样,不会报错。
|
|
5
5
|
> - **空间**:不装「本地转写」约 **84 MB**;装了转写约 **1 GB**(其中 Whisper 模型 538 MB,可在设置里换更小的模型)。
|
|
6
6
|
> - 装完**必须重启一次** dsh(插件在 bundle 层组装)。
|
|
7
|
+
> - ⚠️ **桌面版 DSH(NEXT / 0.2.x)**:**0.1.3 及以前的版本在桌面版上装不起来** —— 插件整条不激活,
|
|
8
|
+
> DSH 只留一句 `warning: 1 entry did not activate … failed to import`(根因是宿主半 import 的两个包
|
|
9
|
+
> 在桌面版 profile 里没被安装,详见下面「装不上」那节的第 5 条)。**0.1.4 起已修** ✓。
|
|
10
|
+
> 另有用户反馈桌面版上「自动跑那一轮会卡住」—— 卡住就先只用设置页的「**现在跑一轮**」手动跑(那条稳)。
|
|
7
11
|
>
|
|
8
12
|
> ⚠️ **装的是旧版吗?** 如果设置页里那个按钮写的是「**重装依赖**」(而不是「**重装主依赖**」),说明你装的是 0.1.0 ——
|
|
9
13
|
> 跑一次 `dsh plugin --profile web add dsh-bili-scroll` 升级就好(那一版建 Python 环境时缺一个参数,
|
|
@@ -33,20 +37,42 @@
|
|
|
33
37
|
· ⚠️ **如果杀软 / Windows Defender 报这个下载**:把插件家目录 `%USERPROFILE%\.dsh\bili-duty\bin\` **加白**即可;
|
|
34
38
|
不想加白也行,自己装一份 uv 或 Python 3.10+ 就绕过去了 ✓
|
|
35
39
|
|
|
36
|
-
>
|
|
40
|
+
> ✅ **已经做到了**(v0.1.1 起):插件会**自己把 `uv` 下到自己的家目录** ⇒ 这条前提不用用户操心、也不用管理员权限。
|
|
41
|
+
> ⚠️ 万一它自己也下不动(有用户反馈桌面版上这条链路经常不通):**手动放一份 `uv.exe` 到 `<家目录>\bin\`** 就行 ——
|
|
42
|
+
> 插件**优先用那里那一份**,会跳过下载。
|
|
37
43
|
|
|
38
44
|
## 装
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
```bash
|
|
42
|
-
dsh plugin --profile web add dsh-bili-
|
|
48
|
+
dsh plugin --profile web add dsh-bili-scroll # 从 npm
|
|
49
|
+
dsh plugin --profile web add github:c1overzz/dsh-bili-scroll # 备选:直接从 GitHub
|
|
43
50
|
```
|
|
51
|
+
(⭐ 最省事的一条:把仓库链接贴给你自己的 dsh,说「**帮我装这个插件**」,它会自己装。)
|
|
44
52
|
|
|
45
53
|
⚠️ 装完**必须重启一次 dsh**(bundle 层要重启才组装)。然后进 **设置 → 刷B站**。
|
|
46
54
|
|
|
55
|
+
## 装不上 / 卡住时,先看这四条(都是用户实测撞过的)
|
|
56
|
+
|
|
57
|
+
1. **桌面版(DSH NEXT / 0.2.x)上"自动跑"会卡住** —— 有用户反馈:它起那一轮用的是
|
|
58
|
+
`dsh --profile headless …`,桌面版上容易卡在那儿。⇒ **先用设置页的「现在跑一轮」手动跑**(那条是稳的);
|
|
59
|
+
能不能自动跑,等你确认过自己那台桌面版的版本与 profile 再说。
|
|
60
|
+
2. **「装依赖」卡住、或者扫码时一直不出二维码** —— 大概率是 0.1.0 那版(见开头那个按钮指纹,跑一次 `add` 就升级了)。
|
|
61
|
+
不想升级也行,救急一句:`<家>\.venv\Scripts\python.exe -m ensurepip --upgrade`,再点一次「重装主依赖」。
|
|
62
|
+
3. **它下不动 `uv`**(桌面版上这条链路经常不通)⇒ 自己放一份 `uv.exe` 到 `<家目录>\bin\`,
|
|
63
|
+
插件会优先用它、跳过下载。
|
|
64
|
+
4. **「本地转写」是两段式的** —— 只打开开关**不够**,还得点它下面那个「**装转写依赖**」。
|
|
65
|
+
没装的时候,没字幕的视频会被**跳过**(报告里现在会写「**因无字幕跳过 N 条**」,你能一眼看出漏了几条)。
|
|
66
|
+
5. ⭐ **桌面版:装完设置页里根本不出现「刷B站」,日志只有一句 `1 entry did not activate … failed to import`** ——
|
|
67
|
+
这是 0.1.3 及以前在桌面版上的一个真 bug:插件宿主半 import 的两个包是 *optional peer*,
|
|
68
|
+
而桌面版 profile(`autoInstallPeers: false`)**不会装它们** ⇒ 插件整条不激活。
|
|
69
|
+
**0.1.4 起已修**(不再裸 import 那两个包)。⇒ 升级一次就好:`dsh plugin --profile desktop add dsh-bili-scroll`。
|
|
70
|
+
⚠️ 我们自己也只在 **npm 0.1.7-rc.x + web profile** 上实测过;桌面版这条路靠用户反馈修好的 ——
|
|
71
|
+
装完还是不行的话,把 `duty.log` 里那几行发来(里面会写清楚"宿主依赖是从哪儿取的")。
|
|
72
|
+
|
|
47
73
|
## 首次使用(两步)
|
|
48
74
|
|
|
49
|
-
1.
|
|
75
|
+
1. **装主依赖** —— 设置页点「装主依赖」:在插件自己的家目录建一个独立 venv,装 3 个包
|
|
50
76
|
(`bilibili-api-python` / `mcp<2` / `qrcode`)。**走国内镜像 + uv,通常几十秒**(进度写 `install.log`,不卡页面)。
|
|
51
77
|
2. **扫码登录** —— 点「扫码登录」,用手机 B站 扫页面上那张码。
|
|
52
78
|
⚠️ **确认时选「记住我 / 长期」那张**(选 24 小时的话,明天就得重扫)。
|
|
@@ -62,7 +88,9 @@ dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
|
62
88
|
| **本地转写没字幕的视频** | **默认关** —— 要在本机装 Whisper、下几百 MB 模型 |
|
|
63
89
|
| **关注关键词** | 一行一个;留空用内置那几组 |
|
|
64
90
|
| **工作区** | 它按这里的 `AGENTS.md` 认「设定」⇒ **你有设定就按你的来** |
|
|
65
|
-
|
|
|
91
|
+
| **回复评论(只回楼中楼)** | **默认关**;开了之后展开「每轮最多回几条」—— 只回**直接回复到它那条评论下面的人**,不参与别人的讨论 |
|
|
92
|
+
| **日报写多细** | `完整 / 薄版 / 不写`;薄版每条一两行(输出比输入贵 200 倍,薄版省的是真钱) |
|
|
93
|
+
| 现在跑一轮 / 装主依赖 / 扫码登录 / 看今天的报告 / 打开报告文件夹 | 版本号就在这一行上面(**点「检查更新」可以看有没有新版**) |
|
|
66
94
|
|
|
67
95
|
## 红线(写死在提示词里,不靠自觉)
|
|
68
96
|
|
|
@@ -74,7 +102,8 @@ dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
|
74
102
|
|
|
75
103
|
- 插件**内部**起一个 Python MCP(下面那个开源项目,已内置),把它的能力注册成 DSH 工具
|
|
76
104
|
- 定时到点 → 起一个 headless dsh 会话,把 `prompts/duty.md`(它一轮要做的事)+ 当天设置交给它
|
|
77
|
-
- 那一轮的**工作目录 =
|
|
105
|
+
- 那一轮的**工作目录 = 插件自己的家目录**(它被沙箱关着,只写得了自己那一亩三分地);
|
|
106
|
+
⭐ 你填的**工作区里的 `AGENTS.md` 由插件读进来、内联进提示词** ⇒ **它按你的设定说话**,
|
|
78
107
|
而不是用一套通用人格
|
|
79
108
|
|
|
80
109
|
## 如果你是一个 AI 助手,正在帮用户装这个插件
|
|
@@ -99,9 +128,9 @@ dsh plugin --profile web add dsh-bili-duty # 从 npm
|
|
|
99
128
|
|
|
100
129
|
## 路线图
|
|
101
130
|
|
|
102
|
-
- **
|
|
103
|
-
- **
|
|
104
|
-
- **v0.
|
|
131
|
+
- **0.1.x(当前 = 0.1.3)**:内置 MCP + 扫码登录 + 章程 + 设置页(峰谷级联开关 / 定量刷 / 自动评论 / 回复评论 / 工作区 / 日报三档)+ 谷时定时器 + 报告 + 看报告与打开文件夹 + 版本徽章 / 检查更新 / 一键升级
|
|
132
|
+
- **0.1.4(在做)**:评论发出后复核(**接口回成功 ≠ 公开可见**)· 每轮记账(token 与花费)· 周末时段 · 数据目录可配 · 「停掉这一轮」按钮
|
|
133
|
+
- **v0.2**:**用 JS 重写那几个接口 ⇒ 不再需要 Python**(零安装);报告归档、失败重试
|
|
105
134
|
|
|
106
135
|
## 许可证
|
|
107
136
|
|
package/client.js
CHANGED
|
@@ -134,11 +134,13 @@ 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)
|
|
137
|
+
const [rl, setRl] = React.useState(null)
|
|
138
|
+
const [newVer, setNewVer] = React.useState('') // 发现的新版本号(空 = 没发现) // 日报形态:full / brief / off
|
|
138
139
|
// ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
|
|
139
140
|
// ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
|
|
140
141
|
const [perDay, setPerDay] = React.useState(null)
|
|
141
142
|
const [perRound, setPerRound] = React.useState(null)
|
|
143
|
+
const [replyPer, setReplyPer] = React.useState(null)
|
|
142
144
|
const [mention, setMention] = React.useState(null)
|
|
143
145
|
const [login, setLogin] = React.useState(null)
|
|
144
146
|
const [runNote, setRunNote] = React.useState(null)
|
|
@@ -153,6 +155,7 @@ window.__ModuleLoader__.load({
|
|
|
153
155
|
if (cfg && rl === null) setRl(cfg.reportLevel || 'full')
|
|
154
156
|
if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
|
|
155
157
|
if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
|
|
158
|
+
if (cfg && replyPer === null) setReplyPer(String(cfg.replyPerRound ?? 1))
|
|
156
159
|
if (cfg && mention === null) setMention(cfg.mentionName || '')
|
|
157
160
|
}, [cfg])
|
|
158
161
|
|
|
@@ -182,6 +185,21 @@ window.__ModuleLoader__.load({
|
|
|
182
185
|
} catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
|
|
183
186
|
}
|
|
184
187
|
|
|
188
|
+
// ⭐ 复核评论:接口回"发送成功"≠ 公开可见(新号/风控会进审核,那时候连 @ 都不会推)
|
|
189
|
+
// 所以让宿主真去拉一次评论列表 / 楼中楼,确认那条到底在不在。
|
|
190
|
+
const onVerify = async () => {
|
|
191
|
+
setBusy('verify')
|
|
192
|
+
try {
|
|
193
|
+
const r = await jpost('/verify', {})
|
|
194
|
+
if (!r || !r.ok) { say('复核没跑成:' + ((r && r.error) || '未知原因'), 'warn'); return }
|
|
195
|
+
const n = (r.checked || []).length
|
|
196
|
+
const vis = (r.checked || []).filter((c) => c.visible === true).length
|
|
197
|
+
if (!n) say(r.text || '没有需要复核的记录', 'good')
|
|
198
|
+
else say(`复核完 ${n} 条:公开可见 ${vis} 条,其余「没查到」(可能还在审核 —— 看不到不等于没发出去)。结果也追加到今天的报告末尾了。`, vis ? 'good' : 'warn')
|
|
199
|
+
if (r.report) setReport(null)
|
|
200
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
const copyText = async (text) => {
|
|
186
204
|
const v = String(text || '')
|
|
187
205
|
try {
|
|
@@ -200,6 +218,30 @@ window.__ModuleLoader__.load({
|
|
|
200
218
|
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
201
219
|
}
|
|
202
220
|
|
|
221
|
+
const onUpgrade = async () => {
|
|
222
|
+
setBusy('upgrade')
|
|
223
|
+
try {
|
|
224
|
+
const r = await jpost('/upgrade', {})
|
|
225
|
+
say((r && r.hint) || '正在升级', 'good')
|
|
226
|
+
say('升级过程写在下面的「主依赖的日志」里 —— 完了记得重启一次 dsh(不重启还是旧版)', 'warn')
|
|
227
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const onCheckUpdate = async () => {
|
|
231
|
+
setBusy('update')
|
|
232
|
+
try {
|
|
233
|
+
const r = await jget('/update')
|
|
234
|
+
if (r && r.ok && r.latest) {
|
|
235
|
+
if (r.hasNew) {
|
|
236
|
+
setNewVer(r.latest)
|
|
237
|
+
say('有新版 v' + r.latest + '(你的是 v' + r.current + ')—— 点右边的「一键升级」,完了重启一次 dsh', 'warn')
|
|
238
|
+
} else say('已经是最新版 v' + r.current, 'good')
|
|
239
|
+
} else {
|
|
240
|
+
say('查不到最新版(可能网络不通)—— 你现在的版本是 v' + ((r && r.current) || '?'))
|
|
241
|
+
}
|
|
242
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
243
|
+
}
|
|
244
|
+
|
|
203
245
|
const onCheck = async () => {
|
|
204
246
|
setBusy('check')
|
|
205
247
|
say('')
|
|
@@ -286,6 +328,7 @@ window.__ModuleLoader__.load({
|
|
|
286
328
|
h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.ranToday} / ${cfg.perDay} 条`),
|
|
287
329
|
h('span', { 'data-bd-badge': '' }, '环境' + (st.venvReady ? '已就绪' : (st.installing ? '正在装' : '未装'))),
|
|
288
330
|
h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
|
|
331
|
+
h('span', { 'data-bd-badge': '' }, 'v' + (st.version || '?')),
|
|
289
332
|
h('div', { 'data-bd-grow': '' }),
|
|
290
333
|
h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
|
|
291
334
|
runNote ? h('div', { 'data-bd-msg': '', 'data-good': runNote.tone === 'good' ? '' : undefined,
|
|
@@ -327,6 +370,23 @@ window.__ModuleLoader__.load({
|
|
|
327
370
|
/* ── 发言与转写 ── */
|
|
328
371
|
h('div', { 'data-bd-sec': '' },
|
|
329
372
|
h('div', { 'data-bd-h': '' }, '它会不会替你说话'),
|
|
373
|
+
h(ToggleRow, {
|
|
374
|
+
label: '回复评论(只回楼中楼)',
|
|
375
|
+
note: '默认关;开了之后它只回「直接回复到你那条评论下面的人」,不参与讨论',
|
|
376
|
+
on: sw('replyToComments'),
|
|
377
|
+
onChange: (v) => patch({ replyToComments: v }, '回复评论'),
|
|
378
|
+
}),
|
|
379
|
+
sw('replyToComments') ? h('div', { 'data-bd-sub': '' },
|
|
380
|
+
h('div', { 'data-bd-row': '' },
|
|
381
|
+
h('div', { 'data-bd-grow': '' },
|
|
382
|
+
h('div', { 'data-bd-label': '' }, '每轮最多回几条'),
|
|
383
|
+
h('div', { 'data-bd-note': '' }, '只回直接回在你那条评论下面的人')),
|
|
384
|
+
h('input', {
|
|
385
|
+
'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 1, max: 10,
|
|
386
|
+
value: replyPer ?? '',
|
|
387
|
+
onChange: (e) => setReplyPer(e.target.value),
|
|
388
|
+
onBlur: () => { const n = Math.max(1, Math.min(Number(replyPer) || 1, 10)); setReplyPer(String(n)); patch({ replyPerRound: n }, '每轮几条回复') },
|
|
389
|
+
}))) : null,
|
|
330
390
|
h(ToggleRow, {
|
|
331
391
|
label: '自动评论并 @ 你',
|
|
332
392
|
note: '默认关;开了之后它会在挑中的视频下面留评论,并 @ 你填的那个人',
|
|
@@ -363,7 +423,7 @@ window.__ModuleLoader__.load({
|
|
|
363
423
|
h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
|
|
364
424
|
st.asrReady ? '重装转写依赖' : '装转写依赖'),
|
|
365
425
|
h('div', { 'data-bd-note': '' },
|
|
366
|
-
!st.asrReady ? '还没装 ——
|
|
426
|
+
!st.asrReady ? '还没装 —— 现在开着也没用:没字幕的视频会被跳过(报告里会写"因无字幕跳过 N 条")'
|
|
367
427
|
: (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
|
|
368
428
|
h('div', { 'data-bd-note': '' },
|
|
369
429
|
'点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
|
|
@@ -404,6 +464,8 @@ window.__ModuleLoader__.load({
|
|
|
404
464
|
h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
|
|
405
465
|
st.venvReady ? '重装主依赖' : '装主依赖(建 venv)'),
|
|
406
466
|
h('button', { 'data-bd-btn': '', disabled: busy === 'check', onClick: onCheck }, '测一下'),
|
|
467
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'update', onClick: onCheckUpdate }, '检查更新'),
|
|
468
|
+
newVer ? h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: busy === 'upgrade', onClick: onUpgrade }, '一键升级到 v' + newVer) : null,
|
|
407
469
|
h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
|
|
408
470
|
h('div', { 'data-bd-note': '' },
|
|
409
471
|
!st.venvReady ? (st.installFailed ? '上次没装成 → 看下面日志' : '还没装 → 先点「装主依赖」')
|
|
@@ -431,6 +493,8 @@ window.__ModuleLoader__.load({
|
|
|
431
493
|
report !== null ? '收起报告' : '看今天的报告'),
|
|
432
494
|
h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
|
|
433
495
|
h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
|
|
496
|
+
h('button', { 'data-bd-btn': '', disabled: busy === 'verify', onClick: onVerify },
|
|
497
|
+
'复核评论'),
|
|
434
498
|
h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
|
|
435
499
|
report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
|
|
436
500
|
)
|
package/index.js
CHANGED
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
* 这样凭证 `bili_credential.json` 落在家里而不是插件包里)/ `duty.md` / `out/` / `duty.log`。
|
|
15
15
|
* ============================================================================
|
|
16
16
|
*/
|
|
17
|
-
import
|
|
18
|
-
|
|
17
|
+
import { loadHostDeps } from './lib/hostdeps.mjs'
|
|
18
|
+
|
|
19
|
+
// ⭐ 这两样从**宿主那棵树**里取(拿不到就退到自己的依赖;再拿不到,defineTool 用本地等价实现)。
|
|
20
|
+
// 起因(2026-10-16 用户递来的完整报告 + 本机复现):它们是 optional peer,
|
|
21
|
+
// 桌面版 profile 是 `autoInstallPeers: false` ⇒ 永远不装 ⇒ **裸 import 会让整条插件不激活** ✗。
|
|
22
|
+
const hostDeps = await loadHostDeps()
|
|
23
|
+
const z = hostDeps.z
|
|
24
|
+
const defineTool = hostDeps.defineTool
|
|
19
25
|
import { spawn } from 'node:child_process'
|
|
20
26
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
21
27
|
import { join, dirname } from 'node:path'
|
|
22
28
|
import { fileURLToPath } from 'node:url'
|
|
23
29
|
import { McpClient } from './lib/mcp.mjs'
|
|
30
|
+
import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
|
|
24
31
|
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, inWindow, dayKey } from './lib/state.mjs'
|
|
25
32
|
import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
|
|
26
33
|
import { ensureUv } from './lib/uvfetch.mjs'
|
|
@@ -34,7 +41,9 @@ export const inject = ['webServer', 'tools', 'settings']
|
|
|
34
41
|
* "Plugin entry … has no volatile fields"。另外两个条件是:
|
|
35
42
|
* **loader entry 的 id 必须等于命名空间名**(我们 patch 里就是 `bili-duty`)+ **本模块导出 Config**。
|
|
36
43
|
*/
|
|
37
|
-
|
|
44
|
+
// ⚠️ schemastery 彻底拿不到时,这里会是 undefined —— **插件照常加载**,只是设置页降级
|
|
45
|
+
// (宁可少一个设置页,也不能因为一个 import 让整条插件不激活 ✗)
|
|
46
|
+
export const Config = !z ? undefined : z.object({
|
|
38
47
|
/** 定量刷:每天最多挑几条 */
|
|
39
48
|
perDay: z.natural().default(5).extra('volatile', true),
|
|
40
49
|
/** 峰谷自动刷(总开关) */
|
|
@@ -66,10 +75,34 @@ export const Config = z.object({
|
|
|
66
75
|
workspace: z.string().default('').extra('volatile', true),
|
|
67
76
|
/** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
|
|
68
77
|
reportLevel: z.string().default('full').extra('volatile', true),
|
|
78
|
+
/** 回复评论:默认关。开了之后只回「直接回复到它那条评论下面的人」 */
|
|
79
|
+
replyToComments: z.boolean().default(false).extra('volatile', true),
|
|
80
|
+
/** 每轮最多回几条 */
|
|
81
|
+
replyPerRound: z.number().default(1).extra('volatile', true),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ⭐ 给所有 Python 子进程统一 UTF-8。
|
|
86
|
+
* 起因(2026-10-16,用户在评论区反馈):Windows 上 Python 默认按 GBK 编解码,
|
|
87
|
+
* 我们的脚本 / 内置 MCP 都会往管道里打中文与 emoji(`✅`、`—` 之类),一进管道就可能
|
|
88
|
+
* 报 UnicodeDecodeError / 乱码或直接中断。**编码这种环境差异,插件该自己钉死,不该让用户碰。**
|
|
89
|
+
*/
|
|
90
|
+
const pyEnv = (extra = {}) => ({
|
|
91
|
+
...process.env,
|
|
92
|
+
PYTHONUTF8: '1',
|
|
93
|
+
PYTHONIOENCODING: 'utf-8',
|
|
94
|
+
...extra,
|
|
69
95
|
})
|
|
70
96
|
|
|
71
97
|
export function apply(ctx, config) {
|
|
72
98
|
const pluginDir = dirname(fileURLToPath(import.meta.url))
|
|
99
|
+
// ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
|
|
100
|
+
let myVer = ''
|
|
101
|
+
const myVersion = () => {
|
|
102
|
+
if (myVer) return myVer
|
|
103
|
+
try { myVer = String(JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).version || '') } catch { myVer = '' }
|
|
104
|
+
return myVer
|
|
105
|
+
}
|
|
73
106
|
const stateDir = stateDirOf(ctx)
|
|
74
107
|
|
|
75
108
|
// ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
|
|
@@ -103,6 +136,9 @@ export function apply(ctx, config) {
|
|
|
103
136
|
try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
|
|
104
137
|
}
|
|
105
138
|
|
|
139
|
+
// 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
|
|
140
|
+
for (const n of hostDeps.notes) log(n)
|
|
141
|
+
|
|
106
142
|
/**
|
|
107
143
|
* ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
|
|
108
144
|
* `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
|
|
@@ -115,7 +151,7 @@ export function apply(ctx, config) {
|
|
|
115
151
|
*/
|
|
116
152
|
const DEFAULTS = {
|
|
117
153
|
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',
|
|
154
|
+
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
|
|
119
155
|
}
|
|
120
156
|
const normalize = (src, base) => {
|
|
121
157
|
const out = { ...base }
|
|
@@ -164,6 +200,9 @@ export function apply(ctx, config) {
|
|
|
164
200
|
command: cfg.pythonPath || 'python',
|
|
165
201
|
args: [script],
|
|
166
202
|
cwd: stateDir, // 凭证落在家里 ✓
|
|
203
|
+
// ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
|
|
204
|
+
// Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
|
|
205
|
+
env: pyEnv(),
|
|
167
206
|
log,
|
|
168
207
|
})
|
|
169
208
|
return mcp
|
|
@@ -376,6 +415,30 @@ export function apply(ctx, config) {
|
|
|
376
415
|
return st
|
|
377
416
|
}
|
|
378
417
|
|
|
418
|
+
// ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
|
|
419
|
+
// ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
|
|
420
|
+
let upgrading = false
|
|
421
|
+
const runUpgrade = () => {
|
|
422
|
+
if (upgrading) return { ok: true, busy: true, log: installLogFile, hint: '正在升级,别重复点' }
|
|
423
|
+
upgrading = true
|
|
424
|
+
appendInstall('\n=== ' + new Date().toISOString() + ' 开始升级(add dsh-bili-scroll)===\n')
|
|
425
|
+
;(async () => {
|
|
426
|
+
try {
|
|
427
|
+
const dsh = resolveDsh()
|
|
428
|
+
// ⭐ 照抄那一轮的形状:resolveDsh 返回 { command, args, shell }
|
|
429
|
+
// (写错就退化成裸 'dsh',而宿主 PATH 里常常没有它 ⇒ 静默失败)
|
|
430
|
+
let cmd = dsh.command || 'dsh'
|
|
431
|
+
let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', 'dsh-bili-scroll']
|
|
432
|
+
if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
|
|
433
|
+
appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
|
|
434
|
+
const code = await spawnTo(cmd, args, { sink: appendInstall })
|
|
435
|
+
if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
|
|
436
|
+
else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
|
|
437
|
+
} catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
|
|
438
|
+
})()
|
|
439
|
+
return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
|
|
440
|
+
}
|
|
441
|
+
|
|
379
442
|
const resolveDsh = () => {
|
|
380
443
|
const explicit = String(cfg.dshBin || '').trim()
|
|
381
444
|
if (explicit && existsSync(explicit)) return { command: process.execPath, args: [explicit], shell: false }
|
|
@@ -423,10 +486,20 @@ export function apply(ctx, config) {
|
|
|
423
486
|
? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
|
|
424
487
|
: `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
|
|
425
488
|
+ `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
|
|
426
|
-
+
|
|
489
|
+
+ `本地转写:${cfg.transcribe
|
|
490
|
+
? (asrReadyNow()
|
|
491
|
+
? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
|
|
492
|
+
: '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
|
|
493
|
+
: '关着(没字幕的视频就跳过)'};`
|
|
494
|
+
+ `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
|
|
495
|
+
+ `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
|
|
496
|
+
+ `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
|
|
427
497
|
+ `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
|
|
428
498
|
? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
|
|
429
499
|
: '关着的(只写报告,别发评论)'}。`
|
|
500
|
+
+ (cfg.replyToComments
|
|
501
|
+
? `回复评论:开着(每轮最多回 ${Number(cfg.replyPerRound) || 1} 条)—— 只回「直接回复到你那条评论下面的人」,回完记进报告;找不到你那条评论就跳过,别硬找。`
|
|
502
|
+
: `回复评论:关着(一个字都不要回任何人 —— 有人回复你那条评论也不要理)。`)
|
|
430
503
|
+ `最后用一句话汇报你挑到了什么。现在几点了自己看一眼系统时间。`
|
|
431
504
|
}
|
|
432
505
|
|
|
@@ -456,6 +529,30 @@ export function apply(ctx, config) {
|
|
|
456
529
|
try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
|
|
457
530
|
}
|
|
458
531
|
|
|
532
|
+
/**
|
|
533
|
+
* ⭐ 评论发出后复核(2026-10-16 加的行 —— 起因是用户在评论区反馈
|
|
534
|
+
* 「**回执成功不等于公开可见,新号会被压审**」):
|
|
535
|
+
* 那一轮的 `bili_reply` 回"发送成功"只代表**接口收了**;新号 / 风控会让它进审核,
|
|
536
|
+
* 那时候连 @ 通知都不会推 ⇒ 插件以为发了、下一轮又不再发 ⇒ **一条都没到人**。
|
|
537
|
+
* 所以由**宿主**(有网络、有凭证、不受沙箱限制)去拉一次评论列表 / 楼中楼来核。
|
|
538
|
+
* ⚠️ 措辞守则:看不到写「**没查到**」,**绝不写"没发出去"**(翻页/热度排序都会漏)。
|
|
539
|
+
*/
|
|
540
|
+
let verifying = false
|
|
541
|
+
const runVerify = async (why = 'manual') => {
|
|
542
|
+
if (verifying) return { ok: false, error: '上一次复核还没跑完,等它完事' }
|
|
543
|
+
verifying = true
|
|
544
|
+
try {
|
|
545
|
+
log(`评论复核开始(${why})`)
|
|
546
|
+
const r = await verifyRecent(stateDir, { log })
|
|
547
|
+
const f = appendVerifyToReport(stateDir, r.text)
|
|
548
|
+
log(`评论复核结束(${why}):查了 ${r.checked.length} 条${f ? `,已写进 ${f}` : ''}`)
|
|
549
|
+
return { ok: true, why, checked: r.checked, skipped: r.skipped, text: r.text, report: f || '' }
|
|
550
|
+
} catch (e) {
|
|
551
|
+
log(`评论复核失败(${why}):${e?.message ?? e}`)
|
|
552
|
+
return { ok: false, why, error: String(e?.message ?? e) }
|
|
553
|
+
} finally { verifying = false }
|
|
554
|
+
}
|
|
555
|
+
|
|
459
556
|
const runDuty = (why = 'window') => {
|
|
460
557
|
try {
|
|
461
558
|
const dsh = resolveDsh()
|
|
@@ -475,12 +572,20 @@ export function apply(ctx, config) {
|
|
|
475
572
|
shell: dsh.shell,
|
|
476
573
|
windowsHide: true,
|
|
477
574
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
478
|
-
|
|
575
|
+
// ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
|
|
576
|
+
// 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
|
|
577
|
+
env: pyEnv({ BILI_DUTY_HOME: stateDir }),
|
|
479
578
|
})
|
|
480
579
|
const pick = (buf) => appendFileSync(logFile, String(buf), 'utf8')
|
|
481
580
|
child.stdout?.on('data', pick)
|
|
482
581
|
child.stderr?.on('data', pick)
|
|
483
|
-
child.on('exit', (code) =>
|
|
582
|
+
child.on('exit', (code) => {
|
|
583
|
+
log(`刷B站结束(${why},code=${code})`)
|
|
584
|
+
// ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
|
|
585
|
+
// 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
|
|
586
|
+
// 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
|
|
587
|
+
setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
|
|
588
|
+
})
|
|
484
589
|
child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
|
|
485
590
|
return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
|
|
486
591
|
} catch (e) {
|
|
@@ -579,7 +684,7 @@ export function apply(ctx, config) {
|
|
|
579
684
|
const spawnLogged = (cmd, args) => new Promise((resolve) => {
|
|
580
685
|
appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
|
|
581
686
|
let c
|
|
582
|
-
try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) }
|
|
687
|
+
try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: pyEnv() }) }
|
|
583
688
|
catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
|
|
584
689
|
const w = (b) => appendInstall(String(b))
|
|
585
690
|
c.stdout?.on('data', w)
|
|
@@ -598,6 +703,26 @@ export function apply(ctx, config) {
|
|
|
598
703
|
// 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
|
|
599
704
|
const asrTmp = join(stateDir, 'tmp', 'asr')
|
|
600
705
|
try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
|
|
706
|
+
|
|
707
|
+
// ⭐ 判重表也由**宿主**预先建好(两个原因):
|
|
708
|
+
// ① 那一轮被沙箱关着,"新建文件"会被拒(2026-10-14 那条规矩);
|
|
709
|
+
// ② 它读不出判重表 ⇒ 以为"没记录过" ⇒ 同一批视频反复推送(用户实测反馈 2026-10-16)。
|
|
710
|
+
const seenFile = join(stateDir, 'seen.md')
|
|
711
|
+
try {
|
|
712
|
+
if (!existsSync(seenFile)) {
|
|
713
|
+
writeFileSync(seenFile, [
|
|
714
|
+
'# 刷B站 · 判重表(宿主预建,那一轮只往后面追加)',
|
|
715
|
+
'',
|
|
716
|
+
'## 评论记录(BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字)',
|
|
717
|
+
'',
|
|
718
|
+
'| BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字 |',
|
|
719
|
+
'| --- | --- | --- | --- | --- |',
|
|
720
|
+
'',
|
|
721
|
+
'## 看过的视频',
|
|
722
|
+
'',
|
|
723
|
+
].join('\n'), 'utf8')
|
|
724
|
+
}
|
|
725
|
+
} catch { /* 建不了也别拦着插件加载 */ }
|
|
601
726
|
let installingAsr = false
|
|
602
727
|
|
|
603
728
|
/** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
|
|
@@ -617,7 +742,7 @@ export function apply(ctx, config) {
|
|
|
617
742
|
let c
|
|
618
743
|
try {
|
|
619
744
|
c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
620
|
-
env: env
|
|
745
|
+
env: pyEnv(env || {}) })
|
|
621
746
|
} catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
|
|
622
747
|
const w = (b) => sink(String(b))
|
|
623
748
|
c.stdout?.on('data', w)
|
|
@@ -729,6 +854,8 @@ export function apply(ctx, config) {
|
|
|
729
854
|
return out
|
|
730
855
|
}
|
|
731
856
|
|
|
857
|
+
let updateCache = null // { at, latest } —— 检查更新的结果缓存
|
|
858
|
+
|
|
732
859
|
const status = () => {
|
|
733
860
|
const st = readJson(dutyStateFile, {})
|
|
734
861
|
const today = dayKey()
|
|
@@ -743,6 +870,7 @@ export function apply(ctx, config) {
|
|
|
743
870
|
lastWhy: st.lastWhy || null,
|
|
744
871
|
loggedIn: hasCredential(),
|
|
745
872
|
venvReady: venvReadyNow(),
|
|
873
|
+
version: myVersion(),
|
|
746
874
|
installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
|
|
747
875
|
installing,
|
|
748
876
|
// ── 本地转写那一套的状态(设置页要显示进度)──
|
|
@@ -773,11 +901,13 @@ export function apply(ctx, config) {
|
|
|
773
901
|
const patch = {}
|
|
774
902
|
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
|
|
775
903
|
'commentPerRound', 'mentionName',
|
|
776
|
-
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel']) {
|
|
904
|
+
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound']) {
|
|
777
905
|
if (k in body) patch[k] = body[k]
|
|
778
906
|
}
|
|
779
907
|
if ('perDay' in patch) patch.perDay = Math.max(0, Math.min(Number(patch.perDay) || 0, 50))
|
|
780
908
|
if ('reportLevel' in patch) patch.reportLevel = ['full', 'brief', 'off'].includes(String(patch.reportLevel)) ? String(patch.reportLevel) : 'full'
|
|
909
|
+
if ('replyToComments' in patch) patch.replyToComments = Boolean(patch.replyToComments)
|
|
910
|
+
if ('replyPerRound' in patch) patch.replyPerRound = Math.max(1, Math.min(Number(patch.replyPerRound) || 1, 10))
|
|
781
911
|
saveCfg(patch)
|
|
782
912
|
log(`设置已更新:${JSON.stringify(patch)}`)
|
|
783
913
|
return sendJson(res, 200, { ok: true, config: cfg })
|
|
@@ -797,6 +927,10 @@ export function apply(ctx, config) {
|
|
|
797
927
|
return sendJson(res, 200, runAsrInstall())
|
|
798
928
|
}
|
|
799
929
|
|
|
930
|
+
// ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
|
|
931
|
+
// (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
|
|
932
|
+
if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
|
|
933
|
+
|
|
800
934
|
// 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
|
|
801
935
|
if (req.method === 'GET' && path === '/api/bili-duty/report') {
|
|
802
936
|
const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
|
|
@@ -837,6 +971,35 @@ export function apply(ctx, config) {
|
|
|
837
971
|
return sendJson(res, 200, { ok: false, error: lastErr, dir })
|
|
838
972
|
}
|
|
839
973
|
|
|
974
|
+
// 「一键升级」:点了它自己跑 add(⚠️ 之后要重启才生效)
|
|
975
|
+
if (req.method === 'POST' && path === '/api/bili-duty/upgrade') return sendJson(res, 200, runUpgrade())
|
|
976
|
+
|
|
977
|
+
// 「检查更新」:⚠️ 只在用户点按钮时才联网(不偷偷发请求 ✓),结果缓存一天 ✓
|
|
978
|
+
if (req.method === 'GET' && path === '/api/bili-duty/update') {
|
|
979
|
+
const out = { ok: true, current: myVersion(), latest: '', hasNew: false, page: 'https://www.npmjs.com/package/dsh-bili-scroll' }
|
|
980
|
+
try {
|
|
981
|
+
if (updateCache && Date.now() - updateCache.at < 24 * 3600 * 1000) out.latest = updateCache.latest
|
|
982
|
+
else {
|
|
983
|
+
const ctrl = new AbortController()
|
|
984
|
+
const timer = setTimeout(() => ctrl.abort(), 8000)
|
|
985
|
+
const r = await fetch('https://registry.npmjs.org/dsh-bili-scroll/latest', { signal: ctrl.signal })
|
|
986
|
+
clearTimeout(timer)
|
|
987
|
+
const j = await r.json()
|
|
988
|
+
out.latest = String(j.version || '')
|
|
989
|
+
updateCache = { at: Date.now(), latest: out.latest }
|
|
990
|
+
}
|
|
991
|
+
} catch (e) { out.ok = false; out.error = String(e?.message ?? e); return sendJson(res, 200, out) }
|
|
992
|
+
// ⭐ 要比谁更新,不能只比不相等 —— 否则本地版本高于 npm 时会误报有新版
|
|
993
|
+
const vnum = (v) => String(v || '').split('.').map((x) => parseInt(x, 10) || 0)
|
|
994
|
+
const cmp = (a, b) => {
|
|
995
|
+
const x = vnum(a), y = vnum(b)
|
|
996
|
+
for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) }
|
|
997
|
+
return 0
|
|
998
|
+
}
|
|
999
|
+
out.hasNew = Boolean(out.latest) && cmp(out.latest, out.current) > 0
|
|
1000
|
+
return sendJson(res, 200, out)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
840
1003
|
// 「测一下」:真跑一次,回答"能不能用"
|
|
841
1004
|
if (req.method === 'POST' && path === '/api/bili-duty/check') {
|
|
842
1005
|
return sendJson(res, 200, await checkUp())
|
package/lib/asr.py
CHANGED
|
@@ -20,6 +20,14 @@ import re
|
|
|
20
20
|
import sys
|
|
21
21
|
import tempfile
|
|
22
22
|
|
|
23
|
+
# ⭐ Windows 上 Python 默认按 GBK 编/解码标准输出 —— 中文或 emoji 打进管道会直接炸
|
|
24
|
+
# (有用户反馈装依赖 / 跑转写时报 UnicodeDecodeError 或乱码)⇒ 这里钉死 UTF-8
|
|
25
|
+
try:
|
|
26
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
27
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
23
31
|
# ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
|
|
24
32
|
# yt-dlp 默认往 %TEMP% 写、会被直接拒掉(2026-10-14 在主人机器上实证:
|
|
25
33
|
# yt_dlp DownloadError: unable to open for writing: [Errno 13] Permission denied)
|
package/lib/hostdeps.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hostdeps.mjs —— 从宿主那棵树里取 `@deepseek-ai/*`(拿不到就退到"自己的依赖")
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 🔴 为什么要有它(2026-10-16 一位用户递来的完整报告,本机也复现了):
|
|
5
|
+
* `index.js` 顶部裸 import `@deepseek-ai/schemastery` 和 `@deepseek-ai/dsh-tools`,
|
|
6
|
+
* 而它们在 package.json 里是 **optional peer** ⇒ 桌面版 profile(`autoInstallPeers: false`)
|
|
7
|
+
* **永远不装它们** ⇒ 插件整条不激活,DSH 只留一句
|
|
8
|
+
* `warning: 1 entry did not activate … failed to import` ✓
|
|
9
|
+
* (web profile 上之所以一直没事,是因为 profile 树里恰好有别的插件把它们 hoist 上来了 —— 这是运气 ✗)
|
|
10
|
+
* ⚠️ `dsh-tools` 自己还有 7 个顶层 import、其中 4 个是 peer ⇒ 光把它装上也救不回来 ✗。
|
|
11
|
+
*
|
|
12
|
+
* ⭐ 策略(两条一起走):
|
|
13
|
+
* ① **优先从宿主那棵树里解析**(跟宿主用同一份,行为最一致);
|
|
14
|
+
* ② 解析不到就 **bare import**(靠我们自己 package.json 的 `dependencies` 装一份保底);
|
|
15
|
+
* ③ `schemastery` 彻底拿不到 ⇒ 返回 null,**插件照常加载**(只是没有 Config、设置页降级)——
|
|
16
|
+
* **绝不因为一个 import 就让整条插件不激活** ✗✓。
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
20
|
+
import { join, dirname } from 'node:path'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
23
|
+
|
|
24
|
+
/** 插件自己那棵树(`dependencies` 装出来的那份)—— 用模块位置算,别依赖 cwd */
|
|
25
|
+
const SELF_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'node_modules', '@deepseek-ai')
|
|
26
|
+
|
|
27
|
+
/** 可能藏着宿主 `@deepseek-ai` 包的地方(不写死某一台机器,按可能性排序) */
|
|
28
|
+
export function candidateRoots() {
|
|
29
|
+
const out = []
|
|
30
|
+
const push = (p) => { if (p && !out.includes(p)) out.push(p) }
|
|
31
|
+
push(process.env.DSH_HOST_MODULES)
|
|
32
|
+
|
|
33
|
+
const appdata = process.env.APPDATA
|
|
34
|
+
if (appdata) {
|
|
35
|
+
const npm = join(appdata, 'npm', 'node_modules')
|
|
36
|
+
push(join(npm, '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai'))
|
|
37
|
+
push(join(npm, '@deepseek-ai'))
|
|
38
|
+
// 桌面版可能叫别的包名(dsh-desktop / dsh-next…)⇒ 逐个看它们的 node_modules
|
|
39
|
+
try {
|
|
40
|
+
for (const n of readdirSync(join(npm, '@deepseek-ai'))) {
|
|
41
|
+
push(join(npm, '@deepseek-ai', n, 'node_modules', '@deepseek-ai'))
|
|
42
|
+
}
|
|
43
|
+
} catch { /* 没有就算了 */ }
|
|
44
|
+
}
|
|
45
|
+
push('/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai')
|
|
46
|
+
push('/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai')
|
|
47
|
+
push('/usr/local/lib/node_modules/@deepseek-ai')
|
|
48
|
+
push('/usr/lib/node_modules/@deepseek-ai')
|
|
49
|
+
|
|
50
|
+
// profile 树:有些机器上这些包是被别的插件 hoist 到 profile 根上的
|
|
51
|
+
try {
|
|
52
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
53
|
+
for (const p of readdirSync(join(dshHome, 'profiles'))) {
|
|
54
|
+
push(join(dshHome, 'profiles', p, 'node_modules', '@deepseek-ai'))
|
|
55
|
+
}
|
|
56
|
+
} catch { /* 没有就算了 */ }
|
|
57
|
+
|
|
58
|
+
// 插件自己那份(package.json 的 dependencies 装出来的)—— 放最后,前面优先"跟宿主同一份"
|
|
59
|
+
push(SELF_MODULES)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 按包的 exports['.'] 找入口文件,返回 file:// URL */
|
|
64
|
+
function entryUrlFor(root, name) {
|
|
65
|
+
try {
|
|
66
|
+
const dir = join(root, name)
|
|
67
|
+
const pkgFile = join(dir, 'package.json')
|
|
68
|
+
if (!existsSync(pkgFile)) return null
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
|
|
70
|
+
const dot = pkg.exports?.['.']
|
|
71
|
+
const rel = typeof dot === 'string'
|
|
72
|
+
? dot
|
|
73
|
+
: (dot?.import || dot?.default || pkg.module || pkg.main)
|
|
74
|
+
if (!rel) return null
|
|
75
|
+
const file = join(dir, String(rel).replace(/^\.\//, ''))
|
|
76
|
+
return existsSync(file) ? pathToFileURL(file).href : null
|
|
77
|
+
} catch { return null }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 试从宿主树里 import 一个包;拿不到返回 null(失败原因写进 notes,方便远程排查) */
|
|
81
|
+
async function fromHostTree(name, log) {
|
|
82
|
+
const fails = []
|
|
83
|
+
for (const root of candidateRoots()) {
|
|
84
|
+
if (root === SELF_MODULES) continue // 自己那份交给下面的 bare import 处理
|
|
85
|
+
const url = entryUrlFor(root, name)
|
|
86
|
+
if (!url) continue
|
|
87
|
+
try {
|
|
88
|
+
const mod = await import(url)
|
|
89
|
+
log(`${name}:从宿主树取到(${url.replace(/^file:\/\/\//, '')})`)
|
|
90
|
+
return mod
|
|
91
|
+
} catch (e) { fails.push(`${e?.code || 'ERR'}:${root}`) }
|
|
92
|
+
}
|
|
93
|
+
if (fails.length) log(`${name}:宿主树里都不行(${fails.slice(0, 3).join(' ')})`)
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 兜底:这是**我们自己的等价实现**,只在拿不到宿主的 dsh-tools 时用 */
|
|
98
|
+
function localDefineTool(options, log) {
|
|
99
|
+
// 宿主的 defineTool 会做 spec→JSONSchema 转换 + 参数校验;我们的 parameters / output.schema
|
|
100
|
+
// **本来就是标准 JSON Schema**(2026-10-14 那次 additionalProperties 报错就是被它校验出来的),
|
|
101
|
+
// 所以这里只补一条同样的校验,别的原样返回。
|
|
102
|
+
const check = (schema, where) => {
|
|
103
|
+
if (!schema || typeof schema !== 'object') return
|
|
104
|
+
if (schema.type === 'object' && schema.additionalProperties === undefined) {
|
|
105
|
+
throw new Error(`defineTool(${options.name}): ${where} 是 object 却没写 additionalProperties`)
|
|
106
|
+
}
|
|
107
|
+
for (const [k, v] of Object.entries(schema.properties || {})) check(v, `${where}.properties.${k}`)
|
|
108
|
+
}
|
|
109
|
+
check(options.parameters, 'parameters')
|
|
110
|
+
check(options.output?.schema, 'output.schema')
|
|
111
|
+
log?.(`dsh-tools 拿不到 —— 用本地等价实现顶上了(工具 ${options.name})`)
|
|
112
|
+
return options
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 取回宿主半端要用的一点点东西
|
|
117
|
+
* @returns {Promise<{z:any, defineTool:Function|null, notes:string[]}>}
|
|
118
|
+
*/
|
|
119
|
+
export async function loadHostDeps() {
|
|
120
|
+
const notes = []
|
|
121
|
+
const log = (m) => notes.push(m)
|
|
122
|
+
|
|
123
|
+
// ① schemastery(Config 要用;宿主设置层要用**它自己那份**读 volatile 字段)
|
|
124
|
+
let zMod = await fromHostTree('schemastery', log)
|
|
125
|
+
if (!zMod) {
|
|
126
|
+
try { zMod = await import('@deepseek-ai/schemastery'); log('schemastery:宿主树里没有,用自己依赖里那份') }
|
|
127
|
+
catch (e) { log(`⚠️ schemastery 彻底拿不到(${e?.code || ''} ${String(e?.message || '').slice(0, 120)})—— 这一份插件没有 Config,设置页会降级(插件本体照常跑)`) }
|
|
128
|
+
}
|
|
129
|
+
const z = zMod?.default ?? zMod ?? null
|
|
130
|
+
|
|
131
|
+
// ② dsh-tools 的 defineTool(拿不到就用本地等价实现 —— 这是本次桌面版修复的关键一步)
|
|
132
|
+
let defineTool = null
|
|
133
|
+
let dtMod = await fromHostTree('dsh-tools', log)
|
|
134
|
+
if (!dtMod) {
|
|
135
|
+
try { dtMod = await import('@deepseek-ai/dsh-tools'); log('dsh-tools:宿主树里没有,用自己依赖里那份') }
|
|
136
|
+
catch (e) { log(`dsh-tools 拿不到(${e?.code || ''})—— 用本地等价实现顶上`); dtMod = null }
|
|
137
|
+
}
|
|
138
|
+
defineTool = dtMod?.defineTool || ((options) => localDefineTool(options, log))
|
|
139
|
+
|
|
140
|
+
return { z, defineTool, notes }
|
|
141
|
+
}
|
package/lib/verify.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/verify.mjs —— 「评论发出后复核」
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么要有它(2026-10-16,用户 `你的deepseek` 在评论区反馈 + 我们 10/10 亲踩):
|
|
5
|
+
* **接口回「发送成功」≠ 公开可见** —— 新号 / 风控会让评论进审核,你自己看不见,
|
|
6
|
+
* 而插件以为发出去了、还把它记进判重表 ⇒ 下一轮不再发 ⇒ **一条都没到人**。
|
|
7
|
+
* 还有更坏的一层:**不公开时连通知都不会推**,于是"@ 了但没人收到"。
|
|
8
|
+
*
|
|
9
|
+
* 复核靠两条只读接口(2026-10-16 实测可行):
|
|
10
|
+
* ① **顶层评论**:`/x/v2/reply/main?type=1&oid=<aid>&mode=2&next=0&ps=20`(按时间)
|
|
11
|
+
* —— 刚发出来的通常在**第一页**;找得到 ⇒ 公开可见(还能顺便读到赞数)。
|
|
12
|
+
* ② **楼中楼**:`/x/v2/reply/reply?type=1&oid=<aid>&root=<root>&ps=20`
|
|
13
|
+
* —— 直接按 root 拉那一串,找我们的 rpid。
|
|
14
|
+
* ⚠️ **看不到 ≠ 没发出**(翻页 / 热度排序都会漏)⇒ 结论一律用"**没查到**"这种说法,
|
|
15
|
+
* 不写成"没发出去"(这是 2026-10-15 那条教训的同一把尺子)。
|
|
16
|
+
* ✗ `/x/v2/reply/detail` 走不通(回 `-400 请求错误`),别无谓地换它。
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { dayKey } from './state.mjs'
|
|
22
|
+
|
|
23
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36'
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
25
|
+
|
|
26
|
+
/** 从判重表里抠出「我们发过的评论」记录:容忍 markdown 表格 / 列表 / 随手写的行 */
|
|
27
|
+
export function parseSeen(text) {
|
|
28
|
+
const rows = []
|
|
29
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
30
|
+
const bv = /BV[0-9A-Za-z]{10}/.exec(line)
|
|
31
|
+
if (!bv) continue
|
|
32
|
+
const nums = [...line.matchAll(/\b(\d{9,})\b/g)].map((m) => Number(m[1]))
|
|
33
|
+
if (!nums.length) continue
|
|
34
|
+
// 约定:第一个长数字 = rpid;如果后面还有第二个,且明显是另一个 id,就当 root(楼中楼)
|
|
35
|
+
const rpid = nums[0]
|
|
36
|
+
const root = nums.length > 1 ? nums[1] : 0
|
|
37
|
+
rows.push({ bv: bv[0], rpid, root, line: line.trim().slice(0, 160) })
|
|
38
|
+
}
|
|
39
|
+
return rows
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadCredential(stateDir) {
|
|
43
|
+
const f = join(stateDir, 'bili_credential.json')
|
|
44
|
+
if (!existsSync(f)) return null
|
|
45
|
+
try {
|
|
46
|
+
const c = JSON.parse(readFileSync(f, 'utf8'))
|
|
47
|
+
if (!c?.sessdata) return null
|
|
48
|
+
return c
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function headersFor(cred) {
|
|
53
|
+
return {
|
|
54
|
+
'User-Agent': UA,
|
|
55
|
+
Referer: 'https://www.bilibili.com/',
|
|
56
|
+
Cookie: `SESSDATA=${cred.sessdata}; bili_jct=${cred.bili_jct}; buvid3=${cred.buvid3}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function jget(url, headers) {
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetch(url, { headers })
|
|
63
|
+
const t = await r.text()
|
|
64
|
+
try { return JSON.parse(t) } catch { return { code: -999, message: 'not json' } }
|
|
65
|
+
} catch (e) { return { code: -998, message: String(e?.message ?? e) } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 我们自己的 mid(从凭证里读;读不到就退化成"看名字对不对") */
|
|
69
|
+
function ourMid(cred) {
|
|
70
|
+
const v = String(cred?.dedeuserid || cred?.DedeUserID || '')
|
|
71
|
+
return /^\d+$/.test(v) ? Number(v) : 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 复核一条:先试顶层(按时间第一页),再试楼中楼(如果记了 root)
|
|
76
|
+
* @returns {{rpid:number, bv:string, visible:boolean|null, likes?:number, how:string, note:string}}
|
|
77
|
+
*/
|
|
78
|
+
export async function verifyOne(record, { cred, aidCache = new Map(), headers }) {
|
|
79
|
+
const out = { rpid: record.rpid, bv: record.bv, visible: null, how: '', note: '' }
|
|
80
|
+
if (!aidCache.has(record.bv)) {
|
|
81
|
+
const v = await jget(`https://api.bilibili.com/x/web-interface/view?bvid=${record.bv}`, headers)
|
|
82
|
+
aidCache.set(record.bv, v?.code === 0 ? { aid: v.data.aid, mid: v.data.owner?.mid, title: v.data.title } : null)
|
|
83
|
+
await sleep(300)
|
|
84
|
+
}
|
|
85
|
+
const meta = aidCache.get(record.bv)
|
|
86
|
+
if (!meta) { out.how = '取视频信息失败'; out.note = '这条跳过(视频可能已删/不可见)'; return out }
|
|
87
|
+
const aid = meta.aid
|
|
88
|
+
const mine = ourMid(cred)
|
|
89
|
+
|
|
90
|
+
// ① 顶层:按时间第一页(刚发的就在这儿)
|
|
91
|
+
if (!record.root) {
|
|
92
|
+
const d = await jget(`https://api.bilibili.com/x/v2/reply/main?type=1&oid=${aid}&mode=2&next=0&ps=20`, headers)
|
|
93
|
+
const list = d?.data?.replies || []
|
|
94
|
+
const hit = list.find((r) => r.rpid === record.rpid) || (mine ? list.find((r) => r.mid === mine) : null)
|
|
95
|
+
if (hit && hit.rpid === record.rpid) {
|
|
96
|
+
out.visible = true; out.likes = hit.like; out.how = '顶层·最新一页'
|
|
97
|
+
out.note = hit.invisible ? '(但接口标了 invisible)' : ''
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
out.how = '顶层·最新一页'
|
|
101
|
+
out.note = '最新一页里没找到 —— 可能还在审核,也可能只是翻页看不到(**不等于没发出去**)'
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ② 楼中楼:按 root 拉那一串
|
|
106
|
+
const d = await jget(`https://api.bilibili.com/x/v2/reply/reply?type=1&oid=${aid}&root=${record.root}&ps=20&pn=1`, headers)
|
|
107
|
+
const subs = d?.data?.replies || []
|
|
108
|
+
const hit = subs.find((r) => r.rpid === record.rpid)
|
|
109
|
+
if (hit) {
|
|
110
|
+
out.visible = true; out.likes = hit.like; out.how = `楼中楼·root=${record.root}`
|
|
111
|
+
out.note = hit.invisible ? '(但接口标了 invisible)' : ''
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
out.how = `楼中楼·root=${record.root}`
|
|
115
|
+
out.note = subs.length
|
|
116
|
+
? `那一串里有 ${subs.length} 条,但没有我们这条 —— 可能还在审核(**不等于没发出去**)`
|
|
117
|
+
: '那一串暂时拉不到内容 —— 可能还在审核(**不等于没发出去**)'
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 复核最近的一批(默认最多 6 条、只挑没复核过的)
|
|
123
|
+
* @returns {Promise<{checked:Array, skipped:number, text:string}>}
|
|
124
|
+
*/
|
|
125
|
+
export async function verifyRecent(stateDir, { max = 6, log = () => {} } = {}) {
|
|
126
|
+
const seenFile = join(stateDir, 'seen.md')
|
|
127
|
+
if (!existsSync(seenFile)) return { checked: [], skipped: 0, text: '⚠️ 判重表还没生成,没什么可复核的' }
|
|
128
|
+
const cred = loadCredential(stateDir)
|
|
129
|
+
if (!cred) return { checked: [], skipped: 0, text: '⚠️ 还没扫码登录,没法复核(复核要用你自己的登录态)' }
|
|
130
|
+
|
|
131
|
+
const rows = parseSeen(readFileSync(seenFile, 'utf8'))
|
|
132
|
+
if (!rows.length) return { checked: [], skipped: 0, text: '判重表里还没有「我们发过的评论」记录' }
|
|
133
|
+
|
|
134
|
+
// 状态账本:已经确认真见过的,7 天内不重复查
|
|
135
|
+
const stateFile = join(stateDir, 'verify-state.json')
|
|
136
|
+
let state = {}
|
|
137
|
+
try { state = JSON.parse(readFileSync(stateFile, 'utf8')) } catch { /* 头一次跑 */ }
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
const fresh = []
|
|
140
|
+
let skipped = 0
|
|
141
|
+
for (const r of rows.slice().reverse()) { // 从最新的往回看
|
|
142
|
+
const prev = state[String(r.rpid)]
|
|
143
|
+
if (prev?.visible === true && now - (prev.at || 0) < 7 * 864e5) { skipped++; continue }
|
|
144
|
+
fresh.push(r)
|
|
145
|
+
if (fresh.length >= max) break
|
|
146
|
+
}
|
|
147
|
+
if (!fresh.length) return { checked: [], skipped, text: `都复核过了(跳过的旧记录 ${skipped} 条)` }
|
|
148
|
+
|
|
149
|
+
const headers = headersFor(cred)
|
|
150
|
+
const aidCache = new Map()
|
|
151
|
+
const checked = []
|
|
152
|
+
for (const r of fresh) {
|
|
153
|
+
const res = await verifyOne(r, { cred, aidCache, headers })
|
|
154
|
+
checked.push(res)
|
|
155
|
+
state[String(r.rpid)] = { visible: res.visible, at: now, how: res.how, bv: r.bv, likes: res.likes ?? null }
|
|
156
|
+
log(`复核 ${r.bv} rpid=${r.rpid} → ${res.visible === true ? '公开可见' : '没查到'}(${res.how})`)
|
|
157
|
+
await sleep(700)
|
|
158
|
+
}
|
|
159
|
+
try { writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8') } catch { /* 写不了就算了,复核不该因此失败 */ }
|
|
160
|
+
|
|
161
|
+
const lines = ['', '## 评论复核(宿主自动跑的,不是那一轮自己说的)', '']
|
|
162
|
+
for (const c of checked) {
|
|
163
|
+
const t = c.visible === true ? `公开可见(赞 ${c.likes ?? 0})` : '没查到'
|
|
164
|
+
lines.push(`- \`${c.bv}\` rpid **${c.rpid}** —— ${t} · 依据:${c.how}${c.note ? ' · ' + c.note : ''}`)
|
|
165
|
+
}
|
|
166
|
+
if (skipped) lines.push(`- (另外 ${skipped} 条最近已复核过,没重复查)`)
|
|
167
|
+
const text = lines.join('\n')
|
|
168
|
+
return { checked, skipped, text }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 把复核结果追加到当天的报告末尾(报告可能还不存在 —— 那就别硬造一份) */
|
|
172
|
+
export function appendVerifyToReport(stateDir, text) {
|
|
173
|
+
try {
|
|
174
|
+
const f = join(stateDir, 'out', `${dayKey()}.md`)
|
|
175
|
+
if (!existsSync(f)) return null
|
|
176
|
+
writeFileSync(f, readFileSync(f, 'utf8').replace(/\s*$/, '') + '\n' + text + '\n', 'utf8')
|
|
177
|
+
return f
|
|
178
|
+
} catch { return null }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 单独跑:node lib/verify.mjs <stateDir>(方便手工验 / 也方便我测) ──
|
|
182
|
+
if (process.argv[1] && /verify\.mjs$/.test(process.argv[1])) {
|
|
183
|
+
const dir = process.argv[2] || join(process.env.DSH_HOME || join(process.env.USERPROFILE || '.', '.dsh'), 'bili-duty')
|
|
184
|
+
const r = await verifyRecent(dir, { log: (m) => console.log(' ' + m) })
|
|
185
|
+
console.log(r.text)
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-scroll",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "DSH 插件:每天替你刷一轮 B站 —— 读字幕挑出值得的、写一份日报,值得的就 @ 你",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
"@deepseek-ai/dsh-tools": "*",
|
|
42
42
|
"@deepseek-ai/schemastery": "*"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
46
|
+
},
|
|
44
47
|
"peerDependenciesMeta": {
|
|
45
48
|
"@deepseek-ai/dsh-llm": {
|
|
46
49
|
"optional": true
|
package/prompts/duty.md
CHANGED
|
@@ -22,7 +22,17 @@
|
|
|
22
22
|
## 边界(这是别人的账号,这几条不能碰)
|
|
23
23
|
|
|
24
24
|
- ⚠️ **只发它自己那一条「@ 使用者」的新评论**,发完就走。
|
|
25
|
-
- ⚠️
|
|
25
|
+
- ⚠️ **默认不要回复任何人的评论**(包括有人回复到它那条评论下面的)。
|
|
26
|
+
⭐ **只有开头告诉你「回复评论:开着」时**才可以回 —— 而且只回「**直接回复到它那条评论下面的人**」,
|
|
27
|
+
**每轮最多回它说的那个条数**,回完把内容记进报告。
|
|
28
|
+
- ⭐ **发完评论,把「视频 BV 号 + 评论 rpid + 时间 + 正文前 20 字」一起记进判重表的「评论记录」**
|
|
29
|
+
—— 有了 rpid,下一轮就能**直接定位到那条评论**,不用在几百条评论里翻。
|
|
30
|
+
⭐ **如果发的是"回复别人"(楼中楼),还要把它回复的那条根评论的 rpid 记在同一行**(写在 rpid 后面)
|
|
31
|
+
—— 宿主会拿这两个号去 B站 复核"到底公开可见没有",缺了 root 就核不了楼层里的那条。
|
|
32
|
+
- ⭐ **要回评论时,先看判重表的「评论记录」** —— 那里记着它自己在哪几个视频发过评论、发了什么;
|
|
33
|
+
⚠️ **查的范围别太窄**:把判重表里**最近 7 天内自己发过的评论都过一遍**(漏掉一条,那个人就永远收不到回复 ✗);
|
|
34
|
+
实在太多就按时间**从新到旧**挑最近 **5~8 条**。找不到就跳过,别硬找、别硬凑。
|
|
35
|
+
- ⚠️ 真要回的时候:不承诺功能、不争论、不替使用者做决定,不确定就说不确定。
|
|
26
36
|
- ⚠️ **不要参与讨论、不要争辩、不要替使用者答问题**。有人问技术问题,就写进报告里,让使用者自己回。
|
|
27
37
|
- ⚠️ **同一个视频下不要留第二条评论**(判重表记着)。
|
|
28
38
|
- 理由:它发出去的每个字,都算在**使用者本人的账号**上 —— 越界就是替他惹麻烦。
|
|
@@ -37,7 +47,10 @@
|
|
|
37
47
|
|
|
38
48
|
1. **搜一轮**(近 7 天优先):用**设置页里填的「关注关键词」**;没填就用默认这几组 ——
|
|
39
49
|
`DeepSeek Harness`、`DSH 插件`、`dsh 教程`、`meow-memory`、`DeepSeek Harness 桌面端`
|
|
40
|
-
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** ——
|
|
50
|
+
2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** —— 已经看过的跳过。
|
|
51
|
+
⭐ **判重表的绝对路径以这一轮开头告诉你的为准**(通常是 `<家目录>/seen.md`,就是插件数据目录里那个)。
|
|
52
|
+
⚠️ **如果那份判重表读不出来(不存在 / 打不开)**:**不要当成"没记录过"** —— 那会让同一批视频反复推送、
|
|
53
|
+
重复打扰一批人。正确做法是:**在报告里明写"判重表读不到"**,而且**这一轮不发评论**(宁可少发)。
|
|
41
54
|
3. 每条:
|
|
42
55
|
- **读字幕原文**(⚠️ 别看摘要下判断)
|
|
43
56
|
- 没有字幕时:**只有**设置里开了「本地转写」才去转写;没开就跳过、别硬凑。
|
|
@@ -45,6 +58,8 @@
|
|
|
45
58
|
· Windows:`<家目录>\.venv-asr\Scripts\python.exe <家目录>\asr.py <BV号>`
|
|
46
59
|
· macOS/Linux:`<家目录>/.venv-asr/bin/python <家目录>/asr.py <BV号>`
|
|
47
60
|
它会把字幕打到标准输出(第一次调用要加载模型,慢几十秒正常)。
|
|
61
|
+
⚠️ **转写没装好(开头会告诉你)、或者你选择了不开转写时**:没字幕的视频只能跳过 ——
|
|
62
|
+
但**必须在报告里写一行「因无字幕跳过 N 条」**,别让它悄悄少几条(用户看不到"漏了什么"就以为全看过了)。
|
|
48
63
|
- 顺手翻一眼评论
|
|
49
64
|
4. 写一份 Markdown 报告,每条一段:
|
|
50
65
|
**标题 / UP / 链接 / 播放量 / 为什么值得(两三句)/ 你自己的一句话**,结尾给一句总结
|
|
@@ -70,7 +85,8 @@
|
|
|
70
85
|
## 产物
|
|
71
86
|
|
|
72
87
|
- 报告写到插件的 `out/<YYYY-MM-DD>.md`
|
|
73
|
-
-
|
|
88
|
+
- 跑过的每一条记进判重表(**路径以开头给的为准,写不进去要在报告里明说**);日志留在插件数据目录
|
|
89
|
+
- ⭐ 报告末尾顺带一行:**「因无字幕跳过 N 条」**(N=0 也写)—— 让人一眼知道这轮有没有漏
|
|
74
90
|
|
|
75
91
|
## 关于你自己
|
|
76
92
|
|