dsh-bili-scroll 0.1.3 → 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 CHANGED
@@ -23,19 +23,26 @@
23
23
 
24
24
  ## ⚠️ 已知环境差异(有用户反馈)
25
25
 
26
- - **桌面版 DSH(NEXT)/ 0.2.x**:本插件跑那一轮用的是 `dsh --profile headless --patch <overlay> <prompt>`,
27
- **只在 npm 0.1.7-rc.x + web profile 上实测过** ✓。桌面版上有人反馈"自动跑会卡住" ——
28
- **先跟用户确认 dsh 版本与 profile**,别硬跑;跑不起来就先只用手动「现在跑一轮」。
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**,别硬跑;跑不起来就先只用手动「现在跑一轮」。
29
36
  - **别让用户自己去装环境** ✗:插件自带一份 uv 与依赖安装能力,先照上面第 1 条处理。
30
37
 
31
38
  ## 仓库结构(改代码时看这里)
32
39
 
33
40
  - `index.js` —— host 半端:11 个工具 + 谷时定时器 + HTTP 接口 + 设置命名空间
34
41
  - `client.js` —— 浏览器半端:设置页(注册在 `settings.section`)
35
- - `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录
42
+ - `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录 / **`hostdeps.mjs`(从宿主树取 `@deepseek-ai/*`,拿不到就退到自己依赖 / 本地等价实现)** / **`verify.mjs`(评论发出后复核)**
36
43
  - `prompts/duty.md` —— 一轮要做的事(判定口径 + 红线,**用户可以改**)
37
44
  - `vendor/bilibili-mcp/` —— 内置的 Python MCP(MIT,AGI_Ananas)
38
- - `selfcheck.mjs` + `tools/` —— 本地预检:`node --import ./tools/register.mjs selfcheck.mjs`
45
+ - `selfcheck.mjs` + `tools/` —— 本地预检:`node selfcheck.mjs`(**现在不用解析钩子了**;`tools/register.mjs` 那条还留着当备份)
39
46
 
40
47
  ## 约定
41
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
- > 计划中(v0.1.1):**插件自己把 `uv` 下到自己的家目录** ⇒ 做到"一键准备环境",这条前提就不用用户操心了。
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-duty # 从 npm
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. **装依赖** —— 设置页点「装依赖(建 venv)」:在插件自己的家目录建一个独立 venv,装 3 个包
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
- - 那一轮的**工作目录 = 你填的工作区** ⇒ 那里的 `AGENTS.md` 会自动加载 ⇒ **它按你的设定说话**,
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
- - **v0.1(当前)**:内置 MCP + 扫码登录 + 章程 + 设置页(峰谷级联开关 / 定量刷 / 自动评论 / 工作区)+ 谷时定时器 + 报告
103
- - **v0.2**:判重表文档化、报告归档、失败重试与日志查看
104
- - **v0.3**:**用 JS 重写那几个接口 ⇒ 不再需要 Python**(零安装)
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
@@ -185,6 +185,21 @@ window.__ModuleLoader__.load({
185
185
  } catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
186
186
  }
187
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
+
188
203
  const copyText = async (text) => {
189
204
  const v = String(text || '')
190
205
  try {
@@ -408,7 +423,7 @@ window.__ModuleLoader__.load({
408
423
  h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
409
424
  st.asrReady ? '重装转写依赖' : '装转写依赖'),
410
425
  h('div', { 'data-bd-note': '' },
411
- !st.asrReady ? '还没装 —— 没装的话转写用不了'
426
+ !st.asrReady ? '还没装 —— 现在开着也没用:没字幕的视频会被跳过(报告里会写"因无字幕跳过 N 条")'
412
427
  : (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
413
428
  h('div', { 'data-bd-note': '' },
414
429
  '点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
@@ -478,6 +493,8 @@ window.__ModuleLoader__.load({
478
493
  report !== null ? '收起报告' : '看今天的报告'),
479
494
  h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
480
495
  h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
496
+ h('button', { 'data-bd-btn': '', disabled: busy === 'verify', onClick: onVerify },
497
+ '复核评论'),
481
498
  h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
482
499
  report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
483
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 z from '@deepseek-ai/schemastery'
18
- import { defineTool } from '@deepseek-ai/dsh-tools'
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
- export const Config = z.object({
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
  /** 峰谷自动刷(总开关) */
@@ -72,6 +81,19 @@ export const Config = z.object({
72
81
  replyPerRound: z.number().default(1).extra('volatile', true),
73
82
  })
74
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,
95
+ })
96
+
75
97
  export function apply(ctx, config) {
76
98
  const pluginDir = dirname(fileURLToPath(import.meta.url))
77
99
  // ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
@@ -114,6 +136,9 @@ export function apply(ctx, config) {
114
136
  try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
115
137
  }
116
138
 
139
+ // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
140
+ for (const n of hostDeps.notes) log(n)
141
+
117
142
  /**
118
143
  * ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
119
144
  * `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
@@ -175,6 +200,9 @@ export function apply(ctx, config) {
175
200
  command: cfg.pythonPath || 'python',
176
201
  args: [script],
177
202
  cwd: stateDir, // 凭证落在家里 ✓
203
+ // ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
204
+ // Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
205
+ env: pyEnv(),
178
206
  log,
179
207
  })
180
208
  return mcp
@@ -458,7 +486,14 @@ export function apply(ctx, config) {
458
486
  ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
459
487
  : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
460
488
  + `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
461
- + `本地转写是${cfg.transcribe ? '开着的(没字幕就用家目录里的 asr.py 转,命令见章程)' : '关着的(没字幕就跳过)'};`
489
+ + `本地转写:${cfg.transcribe
490
+ ? (asrReadyNow()
491
+ ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
492
+ : '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
493
+ : '关着(没字幕的视频就跳过)'};`
494
+ + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
495
+ + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
496
+ + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
462
497
  + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
463
498
  ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
464
499
  : '关着的(只写报告,别发评论)'}。`
@@ -494,6 +529,30 @@ export function apply(ctx, config) {
494
529
  try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
495
530
  }
496
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
+
497
556
  const runDuty = (why = 'window') => {
498
557
  try {
499
558
  const dsh = resolveDsh()
@@ -513,12 +572,20 @@ export function apply(ctx, config) {
513
572
  shell: dsh.shell,
514
573
  windowsHide: true,
515
574
  stdio: ['ignore', 'pipe', 'pipe'],
516
- env: { ...process.env, BILI_DUTY_HOME: stateDir },
575
+ // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
576
+ // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
577
+ env: pyEnv({ BILI_DUTY_HOME: stateDir }),
517
578
  })
518
579
  const pick = (buf) => appendFileSync(logFile, String(buf), 'utf8')
519
580
  child.stdout?.on('data', pick)
520
581
  child.stderr?.on('data', pick)
521
- child.on('exit', (code) => log(`刷B站结束(${why},code=${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
+ })
522
589
  child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
523
590
  return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
524
591
  } catch (e) {
@@ -617,7 +684,7 @@ export function apply(ctx, config) {
617
684
  const spawnLogged = (cmd, args) => new Promise((resolve) => {
618
685
  appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
619
686
  let c
620
- 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() }) }
621
688
  catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
622
689
  const w = (b) => appendInstall(String(b))
623
690
  c.stdout?.on('data', w)
@@ -636,6 +703,26 @@ export function apply(ctx, config) {
636
703
  // 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
637
704
  const asrTmp = join(stateDir, 'tmp', 'asr')
638
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 { /* 建不了也别拦着插件加载 */ }
639
726
  let installingAsr = false
640
727
 
641
728
  /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
@@ -655,7 +742,7 @@ export function apply(ctx, config) {
655
742
  let c
656
743
  try {
657
744
  c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
658
- env: env ? { ...process.env, ...env } : process.env })
745
+ env: pyEnv(env || {}) })
659
746
  } catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
660
747
  const w = (b) => sink(String(b))
661
748
  c.stdout?.on('data', w)
@@ -840,6 +927,10 @@ export function apply(ctx, config) {
840
927
  return sendJson(res, 200, runAsrInstall())
841
928
  }
842
929
 
930
+ // ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
931
+ // (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
932
+ if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
933
+
843
934
  // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
844
935
  if (req.method === 'GET' && path === '/api/bili-duty/report') {
845
936
  const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
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)
@@ -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",
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
@@ -27,6 +27,8 @@
27
27
  **每轮最多回它说的那个条数**,回完把内容记进报告。
28
28
  - ⭐ **发完评论,把「视频 BV 号 + 评论 rpid + 时间 + 正文前 20 字」一起记进判重表的「评论记录」**
29
29
  —— 有了 rpid,下一轮就能**直接定位到那条评论**,不用在几百条评论里翻。
30
+ ⭐ **如果发的是"回复别人"(楼中楼),还要把它回复的那条根评论的 rpid 记在同一行**(写在 rpid 后面)
31
+ —— 宿主会拿这两个号去 B站 复核"到底公开可见没有",缺了 root 就核不了楼层里的那条。
30
32
  - ⭐ **要回评论时,先看判重表的「评论记录」** —— 那里记着它自己在哪几个视频发过评论、发了什么;
31
33
  ⚠️ **查的范围别太窄**:把判重表里**最近 7 天内自己发过的评论都过一遍**(漏掉一条,那个人就永远收不到回复 ✗);
32
34
  实在太多就按时间**从新到旧**挑最近 **5~8 条**。找不到就跳过,别硬找、别硬凑。
@@ -45,7 +47,10 @@
45
47
 
46
48
  1. **搜一轮**(近 7 天优先):用**设置页里填的「关注关键词」**;没填就用默认这几组 ——
47
49
  `DeepSeek Harness`、`DSH 插件`、`dsh 教程`、`meow-memory`、`DeepSeek Harness 桌面端`
48
- 2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** —— 已经看过的跳过(判重表就在家目录根下,叫 `seen.md`)
50
+ 2. 从结果里挑出**值得的**,条数看设置页的「定量刷(每天几条)」;**判重** —— 已经看过的跳过。
51
+ ⭐ **判重表的绝对路径以这一轮开头告诉你的为准**(通常是 `<家目录>/seen.md`,就是插件数据目录里那个)。
52
+ ⚠️ **如果那份判重表读不出来(不存在 / 打不开)**:**不要当成"没记录过"** —— 那会让同一批视频反复推送、
53
+ 重复打扰一批人。正确做法是:**在报告里明写"判重表读不到"**,而且**这一轮不发评论**(宁可少发)。
49
54
  3. 每条:
50
55
  - **读字幕原文**(⚠️ 别看摘要下判断)
51
56
  - 没有字幕时:**只有**设置里开了「本地转写」才去转写;没开就跳过、别硬凑。
@@ -53,6 +58,8 @@
53
58
  · Windows:`<家目录>\.venv-asr\Scripts\python.exe <家目录>\asr.py <BV号>`
54
59
  · macOS/Linux:`<家目录>/.venv-asr/bin/python <家目录>/asr.py <BV号>`
55
60
  它会把字幕打到标准输出(第一次调用要加载模型,慢几十秒正常)。
61
+ ⚠️ **转写没装好(开头会告诉你)、或者你选择了不开转写时**:没字幕的视频只能跳过 ——
62
+ 但**必须在报告里写一行「因无字幕跳过 N 条」**,别让它悄悄少几条(用户看不到"漏了什么"就以为全看过了)。
56
63
  - 顺手翻一眼评论
57
64
  4. 写一份 Markdown 报告,每条一段:
58
65
  **标题 / UP / 链接 / 播放量 / 为什么值得(两三句)/ 你自己的一句话**,结尾给一句总结
@@ -78,7 +85,8 @@
78
85
  ## 产物
79
86
 
80
87
  - 报告写到插件的 `out/<YYYY-MM-DD>.md`
81
- - 跑过的每一条记进判重表;日志留在插件数据目录
88
+ - 跑过的每一条记进判重表(**路径以开头给的为准,写不进去要在报告里明说**);日志留在插件数据目录
89
+ - ⭐ 报告末尾顺带一行:**「因无字幕跳过 N 条」**(N=0 也写)—— 让人一眼知道这轮有没有漏
82
90
 
83
91
  ## 关于你自己
84
92