dsh-bili-scroll 0.1.6 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,1296 +1,2348 @@
1
- /**
2
- * dsh-bili-scroll —— host 半端
3
- * ============================================================================
4
- * 它是什么:每天替你刷一轮 B站 的插件版。
5
- * 1. **插件内部起那份 Python MCP**(`vendor/bilibili-mcp/mcp_server.py`),把它的能力
6
- * 注册成 dsh 工具(搜 / 字幕 / 评论 / 发评论 @ 人 / 登录检查)—— 用户不用再手改 profile。
7
- * 2. **谷时定时器**:中午 12:00–14:00、夜间 00:30–08:30(`lib/state.mjs` 里的 `WINDOWS`),
8
- * 开了哪个窗口才在哪个窗口自动跑;「定量刷」的数字是**每天总量上限**。
9
- * 3. **跑一轮** = 起一个 `dsh --profile headless <prompt>`(跟老方案同一条路,已被验证过),
10
- * 把 `duty.md` 交给它,报告写进 `<家>/out/<日期>.md`。
11
- * 4. **设置页**(`client.js`)通过 `/api/bili-duty/*` 读写配置、看状态、扫码登录。
12
- *
13
- * 家里(`$DSH_HOME/bili-duty/`)都有什么:`config.json` / `mcp_server.py`(从 vendor 铺过来,
14
- * 这样凭证 `bili_credential.json` 落在家里而不是插件包里)/ `duty.md` / `out/` / `duty.log`。
15
- * ============================================================================
16
- */
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
25
- import { spawn } from 'node:child_process'
26
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
- import { join, dirname } from 'node:path'
28
- import { fileURLToPath } from 'node:url'
29
- import { McpClient } from './lib/mcp.mjs'
30
- import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
31
- import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
32
- import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
33
- import { ensureUv } from './lib/uvfetch.mjs'
34
-
35
- export const name = 'bili-scroll'
36
- export const inject = ['webServer', 'tools', 'settings']
37
-
38
- /**
39
- * 🔴 每个字段都要 `.extra('volatile', true)`:DSH 的 settings.update() 只认 volatile 字段
40
- * (`volatileForm(schema)`,见 dsh-settings),没有这个标记写设置时会抛
41
- * "Plugin entry … has no volatile fields"。另外两个条件是:
42
- * **loader entry 的 id 必须等于命名空间名**(我们 patch 里就是 `bili-scroll`)+ **本模块导出 Config**。
43
- */
44
- // ⚠️ schemastery 彻底拿不到时,这里会是 undefined —— **插件照常加载**,只是设置页降级
45
- // (宁可少一个设置页,也不能因为一个 import 让整条插件不激活 ✗)
46
- export const Config = !z ? undefined : z.object({
47
- /** 定量刷:每天最多挑几条 */
48
- perDay: z.natural().default(5).extra('volatile', true),
49
- /** 峰谷自动刷(总开关) */
50
- peakValley: z.boolean().default(false).extra('volatile', true),
51
- /** 峰谷子开关:中午 / 晚上 */
52
- noon: z.boolean().default(true).extra('volatile', true),
53
- night: z.boolean().default(true).extra('volatile', true),
54
- /** 自动评论 @ 使用者(默认关 —— 会拿用户的号说话,必须他自己开) */
55
- autoComment: z.boolean().default(false).extra('volatile', true),
56
- /** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
57
- transcribe: z.boolean().default(false).extra('volatile', true),
58
- /** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
59
- asrModel: z.string().default('small').extra('volatile', true),
60
- /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人);**有效范围 0–20,口径见 clampPatch**(0 = 不发评论)
61
- * ⚠️ 这里故意**不加 `.max(20)`**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ **整条插件不激活** ✗
62
- * (跟 schemastery 拿不到时的降级同一个道理)—— 范围一律由 clampPatch 在**写**和**读**两头钳。 */
63
- commentPerRound: z.natural().default(1).extra('volatile', true),
64
- /** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
65
- mentionName: z.string().default('').extra('volatile', true),
66
- /** 关注关键词(空了用章程里默认那几组) */
67
- keywords: z.array(z.string()).default([]).extra('volatile', true),
68
- /** Python:指向 venv 里的那个最好 */
69
- pythonPath: z.string().default('python').extra('volatile', true),
70
- /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
71
- dshBin: z.string().default('').extra('volatile', true),
72
- /** headless 那一轮用哪个 profile */
73
- headlessProfile: z.string().default('headless').extra('volatile', true),
74
- /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
75
- overlayMcp: z.boolean().default(true).extra('volatile', true),
76
- /** 跑 headless 时的工作目录 */
77
- workspace: z.string().default('').extra('volatile', true),
78
- /** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
79
- reportLevel: z.string().default('full').extra('volatile', true),
80
- /** 回复评论:默认关。开了之后只回「直接回复到它那条评论下面的人」 */
81
- replyToComments: z.boolean().default(false).extra('volatile', true),
82
- /** 每轮最多回几条 */
83
- replyPerRound: z.number().default(1).extra('volatile', true),
84
- })
85
-
86
- /**
87
- * ⭐ 给所有 Python 子进程统一 UTF-8。
88
- * 起因(2026-10-16,用户在评论区反馈):Windows 上 Python 默认按 GBK 编解码,
89
- * 我们的脚本 / 内置 MCP 都会往管道里打中文与 emoji(`✅`、`—` 之类),一进管道就可能
90
- * 报 UnicodeDecodeError / 乱码或直接中断。**编码这种环境差异,插件该自己钉死,不该让用户碰。**
91
- */
92
- const pyEnv = (extra = {}) => ({
93
- ...process.env,
94
- PYTHONUTF8: '1',
95
- PYTHONIOENCODING: 'utf-8',
96
- ...extra,
97
- })
98
-
99
- export function apply(ctx, config) {
100
- const pluginDir = dirname(fileURLToPath(import.meta.url))
101
- // ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
102
- let myVer = ''
103
- const myVersion = () => {
104
- if (myVer) return myVer
105
- try { myVer = String(JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).version || '') } catch { myVer = '' }
106
- return myVer
107
- }
108
- const stateDir = stateDirOf(ctx)
109
-
110
- // ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
111
- // (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
112
- const sitePkgs = (venvRoot) => {
113
- const win = join(venvRoot, 'Lib', 'site-packages')
114
- if (existsSync(win)) return win
115
- try {
116
- const lib = join(venvRoot, 'lib')
117
- for (const d of readdirSync(lib)) {
118
- if (!d.startsWith('python')) continue
119
- const p = join(lib, d, 'site-packages')
120
- if (existsSync(p)) return p
121
- }
122
- } catch { /* 没有就算了 */ }
123
- return win
124
- }
125
- const hasPkg = (venvRoot, prefix) => {
126
- try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
127
- }
128
- const venvReadyNow = () => existsSync(venvPython()) && hasPkg(join(stateDir, '.venv'), 'bilibili_api')
129
- const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
130
- const cfgFile = join(stateDir, 'config.json')
131
- const logFile = join(stateDir, 'duty.log')
132
- const outDir = join(stateDir, 'out')
133
- mkdirSync(outDir, { recursive: true })
134
-
135
- const log = (msg) => {
136
- const line = `${new Date().toISOString()} ${msg}`
137
- try { appendFileSync(logFile, line + '\n', 'utf8') } catch { /* 日志写不进去也不致命 */ }
138
- try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
139
- }
140
-
141
- // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
142
- for (const n of hostDeps.notes) log(n)
143
-
144
- /**
145
- * ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
146
- * `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
147
- * 是个**对象**、`config.keywords` 也不是数组(日志里打成 `[object Object]`)。对象是"真值",
148
- * 于是 `if (!cfg.peakValley) return` 这类守卫全部失效,一路走到 `cfg.keywords.filter(...)`
149
- * 抛 TypeError —— 而那是在**定时器回调**里,没人接 ⇒ **整个 dsh 进程被带走** ✗✗。
150
- * ⇒ 两条规矩:
151
- * ① 逐字段按默认值的类型挑一遍(拿不准就用默认值),再叠自己的 `config.json`;
152
- * ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
153
- */
154
- const DEFAULTS = {
155
- perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
156
- keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
157
- }
158
- const normalize = (src, base) => {
159
- const out = { ...base }
160
- for (const k of Object.keys(base)) {
161
- const v = src?.[k]
162
- if (v === undefined || v === null) continue
163
- if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
164
- else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
165
- else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
166
- else if (typeof base[k] === 'string') { if (typeof v === 'string') out[k] = v }
167
- }
168
- return out
169
- }
170
- /* ── 配置从哪来:DSH 自带的「插件设置」页 > 插件自己的 config.json > 默认值 ──
171
- * 老代码是 `normalize(readJson(cfgFile, {}), normalize(config, DEFAULTS))` —— config.json 一刀切盖住宿主,
172
- * 后果:用户在 DSH 自带的插件设置页里改了值,插件照旧读 config.json 的旧值 ⇒ **静默不生效** ✗。
173
- * 判据用 describe() 给的 `user` 层(= 用户在原生设置页**显式设过**的键):
174
- * 设过的键 ⇒ 听宿主的;没设过 ⇒ 还是 config.json 说了算(老用户零回归 ✓)。
175
- * 拿不到设置服务就完全退回老行为。
176
- */
177
- /* ⭐ 「设置里能写什么范围」收在一处 —— HTTP 接口、bili_config 工具、**读配置**三处都走它,
178
- * 所以 perDay 0–50 / commentPerRound 0–20 这些数字**只有这一份**(schema 里不再写一遍)。
179
- * (2026-10-16 审查修:原来只有 HTTP 路径钳制,工具路径连 perDay 都能写任意值;
180
- * commentPerRound 两条路径都没钳,直接 POST 能写 9999)
181
- * (2026-10-17 复查补:DSH 原生设置页那条路我们钳不到**写** —— 用户能在那儿直接填 9999 ⇒
182
- * loadCfg 读回来也过一遍,写进去和读出来一个口径) */
183
- const clampPatch = (patch) => {
184
- const p = { ...patch }
185
- if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
186
- if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
187
- if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
188
- if ('replyPerRound' in p) p.replyPerRound = Math.max(1, Math.min(Number(p.replyPerRound) || 1, 10))
189
- if ('commentPerRound' in p) p.commentPerRound = Math.max(0, Math.min(Number(p.commentPerRound) || 0, 20))
190
- return p
191
- }
192
-
193
- const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
194
- const savedCfg = () => normalize(readJson(cfgFile, {}), { ...DEFAULTS })
195
- const CFG_SIG = ['perDay', 'mentionName', 'replyToComments', 'overlayMcp'] // 靠这几个键认出"哪一行是我们"
196
- let settingsRow = { at: 0, row: null }
197
- /**
198
- * 我们那一行设置表单(拿不到就返回 null ⇒ 一切按 config.json 来)。
199
- * ⚠️ DSH 自带的「插件设置」页**不需要注册** —— 老代码那句 `ctx.settings.register(...)` 是个不存在的方法,
200
- * 每次都抛 TypeError、被 catch 吞掉(2026-10-17 读宿主源码 @deepseek-ai/dsh-settings 才明白):
201
- * describe() 自己扫 profile 里**带 volatile 字段**的条目列出来(lib/index.js:413-452),
202
- * 命名空间 ns **等于 loader 条目的 id**(我们 cordis.patch.yml 里是 `bili-scroll`,不是老注释写的 bili-duty)。
203
- * 另外两条同样要紧(都是读宿主源码得来的,别再想当然):
204
- * ① `apply(ctx, config)` 收到的 config 就是 fiber.config 本体,volatile 字段一改宿主**就地**改它
205
- * (cordis-plugin-loader/src/config/entry.ts:`this.fiber._config = …` + `_commitVolatile()`)⇒
206
- * 启动时快照的 cfg 永远看不到设置页的改动,用之前得 refreshCfg()。
207
- * ② describe() 每行给三层:value(生效值)/ base(继承层)/ user(用户在设置页显式设过的那层)。
208
- */
209
- const settingsDesc = (fresh = false) => {
210
- const t = Date.now()
211
- if (!fresh && t - settingsRow.at < 3000) return settingsRow.row // 挡 UI 轮询:describe() 要扫所有条目
212
- let row = null
213
- try {
214
- const rows = ctx.settings?.describe?.() || []
215
- row = rows.find((r) => r?.value && CFG_SIG.every((k) => k in r.value)) || null
216
- } catch (e) { log(`读 DSH 设置表单失败(不影响跑):${e?.message ?? e}`) }
217
- settingsRow = { at: t, row }
218
- return row
219
- }
220
- const loadCfg = () => {
221
- const saved = savedCfg()
222
- const row = settingsDesc()
223
- // ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
224
- if (!row) return clampPatch(saved)
225
- const user = row.user || {}
226
- const out = { ...hostCfg() }
227
- for (const k of Object.keys(DEFAULTS)) if (user[k] === undefined) out[k] = saved[k]
228
- return clampPatch(out)
229
- }
230
- const cfg = loadCfg()
231
- // ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
232
- const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
233
- const saveCfg = (patch) => { Object.assign(cfg, normalize(patch, cfg)); writeJson(cfgFile, cfg) }
234
- /**
235
- * ⭐ 同时写一份到宿主:不写的话会出现死结 —— 某个键一旦在原生设置页设过就由宿主说了算,
236
- * 插件页再改那个键就成了"改了没反应" ✗。写失败不算致命(config.json 那份还在),把话带回给用户。
237
- */
238
- const saveCfgHost = async (patch) => {
239
- const attempt = async (fresh) => {
240
- const row = settingsDesc(fresh)
241
- if (!row?.ns || typeof ctx.settings?.update !== 'function') {
242
- throw Object.assign(new Error('DSH 这边没有可写的设置表单(值只落在插件自己的 config.json 里)'), { noForm: true })
243
- }
244
- await ctx.settings.update(row.ns, patch, row.revision)
245
- return row.ns
246
- }
247
- try { return { ok: true, ns: await attempt(false) } }
248
- catch (e) {
249
- if (!e?.noForm) { // revision 过期(刚有别人改过)⇒ 重取一次再写
250
- try { return { ok: true, ns: await attempt(true), retried: true } } catch (e2) { e = e2 }
251
- }
252
- log(`写 DSH 设置失败:${e?.message ?? e}`)
253
- return { ok: false, error: String(e?.message ?? e) }
254
- }
255
- }
256
- /**
257
- * ⭐ 落配置的**唯一入口**:插件自己那份 config.json + DSH 原生设置页那份,钳完范围一起写。
258
- * (2026-10-17 复查修:`saveCfg` + `await saveCfgHost` 这对义务原来在三个调用点各抄一遍 ——
259
- * 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
260
- */
261
- const persistCfg = async (patch) => {
262
- const clean = clampPatch(patch)
263
- saveCfg(clean)
264
- const host = await saveCfgHost(clean)
265
- return { clean, host }
266
- }
267
-
268
- ensureHomeFiles(pluginDir, stateDir, log)
269
-
270
- // ⭐ 值班章程单独记账(跟 mcp_server.py 不一样:用户改过就得留着 —— 见 state.mjs 的 ensureCharter)
271
- ensureCharter(pluginDir, stateDir, log)
272
-
273
- /* ─────────────── MCP(内部起那个 Python MCP) ─────────────── */
274
-
275
- let mcp = null
276
- let mcpKey = ''
277
- const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
278
-
279
- const ensureMcp = async () => {
280
- if (mcp?.running && mcpKey === mcpKeyNow()) return mcp
281
- if (mcp) { mcp.stop(); mcp = null }
282
- const script = join(stateDir, 'mcp_server.py')
283
- if (!existsSync(script)) throw new Error(`家里没有 mcp_server.py(应有 ${script})—— 装上插件后重启一次 dsh 会自动铺好`)
284
- mcpKey = mcpKeyNow()
285
- mcp = new McpClient({
286
- command: cfg.pythonPath || 'python',
287
- args: [script],
288
- cwd: stateDir, // 凭证落在家里 ✓
289
- // ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
290
- // Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
291
- env: pyEnv(),
292
- log,
293
- })
294
- return mcp
295
- }
296
-
297
- const call = async (toolName, args, opts) => {
298
- const client = await ensureMcp()
299
- return client.call(toolName, args, opts)
300
- }
301
-
302
- const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
303
-
304
- /* ─────────────── 工具 ─────────────── */
305
-
306
- const textOutput = {
307
- schema: { type: 'object', properties: {}, additionalProperties: true },
308
- render: (_args, value) => [{
309
- type: 'text',
310
- text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
311
- }],
312
- }
313
-
314
- /**
315
- * 工具返回值一律过一遍这里:**宿主只吃"无损 JSON"**,塞进去任何函数/循环引用/类实例都会报
316
- * `value is not lossless JSON`(2026-10-14 第一次挂上就撞了一次)。
317
- */
318
- const safe = (v) => {
319
- if (v === undefined || v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
320
- try { return JSON.parse(JSON.stringify(v)) } catch { return String(v) }
321
- }
322
-
323
- const tool = (spec) => ctx.tools.register(defineTool({
324
- ...spec,
325
- output: textOutput,
326
- async execute(args, exec) { return safe(await spec.execute(args, exec)) },
327
- }))
328
-
329
- tool({
330
- name: 'bili_whoami',
331
- description: '看 B站 那边登录的是谁(凭证是否有效、昵称、mid)。第一次用要先在「刷B站」设置页里扫码登录。',
332
- parameters: {},
333
- async execute() {
334
- if (!hasCredential()) {
335
- return { ok: false, loggedIn: false, hint: '还没登录:去 dsh 设置页的「刷B站」里点「扫码登录」(凭证会存在插件自己的家目录里)' }
336
- }
337
- const r = await call('bili_check_credential', {})
338
- return { ok: true, ...(typeof r === 'object' ? r : { raw: r }) }
339
- },
340
- })
341
-
342
- tool({
343
- name: 'bili_search',
344
- description: '搜 B站 视频(可排序)。返回标题 / BV号 / 播放量 / UP / 时长 等。\n'
345
- + '⚠️ 刷一轮的用法:搜近 7 天的、按播放或最新都行,然后**读字幕原文**再判断(别看摘要下结论)。',
346
- parameters: {
347
- keyword: { type: 'string', description: '搜索关键词' },
348
- num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
349
- order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
350
- },
351
- async execute(args) {
352
- return call('bili_search', {
353
- keyword: String(args.keyword ?? ''),
354
- num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
355
- order: String(args.order || 'totalrank'),
356
- })
357
- },
358
- })
359
-
360
- tool({
361
- name: 'bili_subtitle',
362
- description: '取一个视频的字幕(AI 字幕)全文。**判断内容就靠它** —— 没有字幕时,'
363
- + '若设置里开了「本地转写」再想别的办法,否则直接跳过这条。',
364
- parameters: { bvid: { type: 'string', description: 'BV号' } },
365
- async execute(args) { return call('bili_subtitle', { bvid: String(args.bvid ?? '') }) },
366
- })
367
-
368
- tool({
369
- name: 'bili_comments',
370
- description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。',
371
- parameters: {
372
- bvid: { type: 'string', description: 'BV号' },
373
- num: { type: 'number', description: '取几条(默认 30)' },
374
- },
375
- async execute(args) {
376
- return call('bili_comments', { bvid: String(args.bvid ?? ''), num: Math.max(1, Number(args.num) || 30) })
377
- },
378
- })
379
-
380
- tool({
381
- name: 'bili_reply',
382
- description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
383
- + '🔴 安全闸:设置页的「自动评论并 @ 你」**默认是关的**;关着的时候这个工具会拒绝 —— '
384
- + '除非本人当场让你发(那时传 force: true)。',
385
- parameters: {
386
- bvid: { type: 'string', description: 'BV号' },
387
- text: { type: 'string', description: '评论正文(短、干净、纯文本 @;别写敏感词)' },
388
- rpid: { type: 'number', description: '要回复的评论 id(0/不填 = 发新评论)' },
389
- force: { type: 'boolean', description: '本人当场让你发的,传 true 越过"自动评论关着"的闸' },
390
- },
391
- async execute(args) {
392
- if ((!cfg.autoComment || !cfg.mentionName) && !args.force) {
393
- return {
394
- ok: false,
395
- blocked: true,
396
- hint: '要发评论得同时满足两条:设置里开着「自动评论并 @ 你」、并且填了「要 @ 谁」。或者本人当场让你发时传 force: true。',
397
- }
398
- }
399
- return call('bili_reply', {
400
- bvid: String(args.bvid ?? ''),
401
- text: String(args.text ?? ''),
402
- rpid: Number(args.rpid) || 0,
403
- })
404
- },
405
- })
406
-
407
- tool({
408
- name: 'bili_status',
409
- description: '看它今天的状态:跑了没跑、挑了几条、额度剩多少、报告在哪、登录过没有、下次窗口什么时候开。',
410
- parameters: {},
411
- async execute() {
412
- refreshCfg()
413
- const st = readJson(join(stateDir, 'state.json'), {})
414
- const now = new Date()
415
- return {
416
- ok: true,
417
- today: dayKey(now),
418
- ranToday: st.day === dayKey(now) ? Number(st.count || 0) : 0,
419
- pickedToday: countPickedToday(now),
420
- perDay: cfg.perDay,
421
- windows: Object.fromEntries(Object.entries(WINDOWS).map(([k, w]) => [k, {
422
- label: w.label, start: w.start, end: w.end,
423
- switchOn: Boolean(cfg[k]),
424
- opening: inWindow(now, w),
425
- }])),
426
- peakValley: cfg.peakValley,
427
- autoComment: cfg.autoComment,
428
- transcribe: cfg.transcribe,
429
- loggedIn: hasCredential(),
430
- home: stateDir,
431
- report: join(outDir, `${dayKey(now)}.md`),
432
- }
433
- },
434
- })
435
-
436
- tool({
437
- name: 'bili_run',
438
- description: '**立刻跑一轮**(不等窗口):起一个 headless 会话,把 duty.md 交给它去刷、写报告。'
439
- + '适合"我现在就想看一份"的场合。',
440
- parameters: {},
441
- async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
442
- })
443
-
444
- tool({
445
- name: 'bili_login_start',
446
- description: '开始 B站 扫码登录:出二维码(画成 PNG 存在插件家目录里,返回路径)和一个 key。\n'
447
- + '用法:拿到 PNG 后用 `present` 把它推给本人(手机 B站 → 我的 → 扫一扫),再用 `bili_login_poll` 轮询。\n'
448
- + '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
449
- parameters: {},
450
- async execute() {
451
- const { key, url } = await loginStart()
452
- const png = await renderQrPng(cfg.pythonPath, stateDir, url)
453
- return {
454
- ok: true, key, url, png,
455
- hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去设置里点「建 venv 装依赖」(venv 里要有 qrcode)',
456
- }
457
- },
458
- })
459
-
460
- tool({
461
- name: 'bili_login_poll',
462
- description: '查扫码结果:waiting 还没扫 / scanned 扫了待确认 / ok 成功 / expired 过期。'
463
- + '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
464
- parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
465
- async execute(args) {
466
- const r = await loginPoll(String(args.key ?? ''))
467
- if (r.state === 'ok') {
468
- const file = saveCredential(stateDir, r.credential)
469
- log(`登录成功,凭证已写入 ${file}`)
470
- return { ok: true, state: 'ok', file }
471
- }
472
- return { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message }
473
- },
474
- })
475
-
476
- tool({
477
- name: 'bili_setup',
478
- description: '给刷B站装运行环境:在插件家目录建一个**独立 venv**、装上那个 Python MCP 的依赖,装完自动把 `pythonPath` 指过去。\n'
479
- + '**第一次用之前跑一次**。⭐ 它**起完就返回、不等装完**(几分钟的活),进度看家目录里的 `install.log`。',
480
- parameters: {},
481
- async execute() { return runInstall() },
482
- })
483
-
484
- tool({
485
- name: 'bili_config',
486
- description: '读 / 改「刷B站」的设置。不给 `patch` 就是读;给了就改(只认 schema 里的字段)。\n'
487
- + '例:`{"perDay":5,"peakValley":true,"noon":true,"night":true,"autoComment":false,"transcribe":false,"keywords":["DeepSeek Harness"]}`',
488
- parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
489
- async execute(args) {
490
- let host = null
491
- if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
492
- // ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
493
- host = (await persistCfg(args.patch)).host
494
- }
495
- return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow(), host }
496
- },
497
- })
498
-
499
- /* ─────────────── 跑一轮(跟老方案同一条路:dsh --profile headless <prompt>) ─────────────── */
500
-
501
- const dutyStateFile = join(stateDir, 'state.json')
502
- const bumpState = (patch) => {
503
- const st = readJson(dutyStateFile, {})
504
- const today = dayKey()
505
- if (st.day !== today) { st.day = today; st.count = 0; st.picked = 0; st.windows = {} }
506
- writeJson(dutyStateFile, { ...st, ...patch })
507
- return st
508
- }
509
-
510
- /* ⭐ 「定量刷」承诺的是**每天挑几条**(2026-10-16 审查修 H1:原来 st.count 从没自增过,
511
- * tick 那道 `count >= perDay` 的闸形同虚设 ⇒ noon + night 两个窗口各跑满一轮,
512
- * 后一轮还会把前一轮的报告覆盖掉)。
513
- * 数哪儿?数**今天的报告**里那些 `### BV号` 标题行(章程写死了这个格式)。
514
- * ⚠️ 为什么不数「报告里出现的所有 BV」(2026-10-17 复查修的):报告里还会出现**没挑中**的 BV ——
515
- * 「因无字幕跳过 N 条」那行、评论区里别人问起的视频、"判重表读不到"的说明 —— 全算进去的话,
516
- * perDay 默认 5 ⇒ 一轮里出现 5 个 BV 就把当天额度吃光 ⇒ **傍晚那轮直接不跑** ✗。
517
- * ⚠️ 围栏 ``` 里的内容一律不看:那一轮没写报告时 stubReportIfMissing 会把 duty.log 尾部贴进报告,
518
- * 那里面的 BV 是"看过的",不是"挑出来的"。
519
- * ⚠️ 数不出来(章程那条格式没照做)就当 0 条 —— 宁可少算(顶多多跑一轮、额度也写在提示词里),
520
- * 也别把"提到过"算成"挑中"(那会让使用者当天再也拿不到内容)✓。 */
521
- const PICK_RE = /^#{2,3}[ \t]*(BV[0-9A-Za-z]{10})/gm
522
- const reportFileOf = (now = new Date()) => join(outDir, `${dayKey(now)}.md`)
523
- const countPicks = (file) => {
524
- try {
525
- const text = readFileSync(file, 'utf8').replace(/```[\s\S]*?```/g, '')
526
- const ids = new Set()
527
- for (const m of text.matchAll(PICK_RE)) ids.add(m[1])
528
- return ids.size
529
- } catch { return 0 }
530
- }
531
- // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
532
- const countPickedToday = (now = new Date()) => {
533
- const st = readJson(dutyStateFile, {})
534
- const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
535
- return Math.max(fromState, countPicks(reportFileOf(now)))
536
- }
537
- const roundsToday = (now = new Date()) => {
538
- const st = readJson(dutyStateFile, {})
539
- return st.day === dayKey(now) ? Number(st.count || 0) : 0
540
- }
541
-
542
- // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
543
- // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
544
- let upgrading = false
545
- const runUpgrade = () => {
546
- if (upgrading) return { ok: true, busy: true, log: installLogFile, hint: '正在升级,别重复点' }
547
- upgrading = true
548
- appendInstall('\n=== ' + new Date().toISOString() + ' 开始升级(add dsh-bili-scroll)===\n')
549
- ;(async () => {
550
- try {
551
- const dsh = resolveDsh()
552
- // ⭐ 照抄那一轮的形状:resolveDsh 返回 { command, args, shell }
553
- // (写错就退化成裸 'dsh',而宿主 PATH 里常常没有它 ⇒ 静默失败)
554
- let cmd = dsh.command || 'dsh'
555
- let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', 'dsh-bili-scroll']
556
- if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
557
- appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
558
- const code = await spawnTo(cmd, args, { sink: appendInstall })
559
- if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
560
- else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
561
- } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
562
- })()
563
- return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
564
- }
565
-
566
- const resolveDsh = () => {
567
- const explicit = String(cfg.dshBin || '').trim()
568
- if (explicit && existsSync(explicit)) return { command: process.execPath, args: [explicit], shell: false }
569
- // 常见的全局安装位置(Windows 的 npm 前缀 / POSIX 的 lib)
570
- const guesses = [
571
- join(process.env.APPDATA || '', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
572
- '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
573
- '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
574
- ].filter(Boolean)
575
- // ⭐ 2026-10-16(issue #5 / #6 / #7):**桌面版是 Electron 安装包,不是 npm -g 装的** ⇒ 上面几条全找不到
576
- // ⇒ 老逻辑退到 `dsh`(PATH 里没有这个命令)⇒ 那一轮必败、而且只留下一句 exit 1 ✗
577
- // 桌面版把 dsh 打在应用里 ⇒ 按 `process.resourcesPath` 猜几个位置(找到就用;找不到就**明说**)
578
- if (process.resourcesPath) {
579
- for (const rel of [
580
- ['app.asar.unpacked', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
581
- ['app', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
582
- ['node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
583
- ]) {
584
- const p = join(process.resourcesPath, ...rel)
585
- if (existsSync(p)) return { command: process.execPath, args: [p], shell: false }
586
- }
587
- }
588
- for (const g of guesses) if (existsSync(g)) return { command: process.execPath, args: [g], shell: false }
589
- // ⚠️ 走到这儿就是真找不到 —— 一定要出声(静默失败比报错更坏)
590
- log('⚠️ 找不到 dsh:桌面版多半不是 npm 全局安装的 ⇒ 请在设置里把「dsh 路径」指到 <应用目录>/…/@deepseek-ai/dsh/lib/bin.js,或改用 npm 装的 dsh')
591
- return { command: 'dsh', args: [], shell: true }
592
- }
593
-
594
- const promptFor = () => {
595
- // ⭐ 章程取哪一份:家里那份优先(ensureCharter 保证铺好;他改过的话就是他那份,升级的事由 ensureCharter 记账处理)
596
- const duty = charterPath(pluginDir, stateDir)
597
- const kws = Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : []
598
- // ⭐ 开场先让它读**工作区**的 AGENTS.md —— 这一轮的工作目录就是使用者的工作区,
599
- // AGENTS.md 本来就会自动注入;这里再点一句,保证它真按使用者的设定来(别人的设定同理)。
600
- // ⭐ 那一轮的工作目录是**插件家**(沙箱只让它写自己工作目录里的东西 ✗),
601
- // 所以"使用者的设定"由**我们自己读进来**喂给它 —— 它在宿主进程里跑,没有沙箱限制 ✓
602
- let ownerDoc = ''
603
- try {
604
- // ⚠️ 2026-10-16 用户反馈("只要给人设就会走偏"):留空时我们以前退到 `process.cwd()`,
605
- // 也就是 **dsh 进程自己的工作目录** —— 那对多数人根本不是他的工作区 ✗
606
- // ⇒ 读错人设、甚至把别的目录的设定读进来 ✓
607
- // ⭐ 现在的规矩:**留空 = 不读设定**(宁可按中性说话,也别猜错 ✓);要按人设就自己填工作区 ✓。
608
- const wsDir = String(cfg.workspace || '').trim()
609
- const agents = wsDir ? join(wsDir, 'AGENTS.md') : ''
610
- if (agents && existsSync(agents)) {
611
- const text = readFileSync(agents, 'utf8')
612
- ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
613
- + text.slice(0, 6000)
614
- + '\n===== 设定结束 =====\n'
615
- }
616
- } catch { /* 读不到就算了,别因此跑不成 */ }
617
- // ⭐ 「每天最多挑 N 条」是**全天**的账:上一轮已经挑过的要扣掉,这一轮只给剩下的额度
618
- const quota = Math.max(0, Number(cfg.perDay) || 0)
619
- const already = countPickedToday()
620
- const left = Math.max(0, quota - already)
621
- return `你是这个账号的 B站 助手。`
622
- + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
623
- + ownerDoc
624
- + (ownerDoc
625
- ? ''
626
- : '⭐ 这一轮**没有**拿到使用者的设定(他没填「工作区」,或那个目录里没有 `AGENTS.md`)⇒ **按中性说话**:别扮演任何人、也别自己编一套设定 ✓。')
627
- + `然后读章程 ${duty},按里面的要求刷一轮 B站,`
628
- + (cfg.reportLevel === 'brief'
629
- ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
630
- : cfg.reportLevel === 'off'
631
- ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
632
- : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
633
- + (cfg.reportLevel === 'off' ? '' : `⚠️ 今天那份报告里可能已经有上一轮写的东西 —— 有就**追加到末尾**,别覆盖(配额按全天算)。`)
634
- + (cfg.reportLevel === 'off' ? '' : '⭐ 报告里**每挑中一条**,第一行写成 `### BV号`(例:`### BV1xxxxxxx`)—— 宿主**就数这些标题行**算今天挑了几条:漏写当成没挑中,给只是提到 / 跳过的视频加上标题则当成挑中。')
635
- + (already > 0
636
- ? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
637
- : `今天最多挑 ${quota} 条;`)
638
- + `关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
639
- + `本地转写:${cfg.transcribe
640
- ? (asrReadyNow()
641
- ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
642
- : '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
643
- : '关着(没字幕的视频就跳过)'};`
644
- + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
645
- + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
646
- + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
647
- + `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
648
- + ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
649
- + `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
650
- + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
651
- ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
652
- : '关着的(只写报告,别发评论)'}。`
653
- + (cfg.replyToComments
654
- ? `回复评论:开着(每轮最多回 ${Number(cfg.replyPerRound) || 1} 条)—— 只回「直接回复到你那条评论下面的人」,回完记进报告;找不到你那条评论就跳过,别硬找。`
655
- : `回复评论:关着(一个字都不要回任何人 —— 有人回复你那条评论也不要理)。`)
656
- + `最后用一句话汇报你挑到了什么。现在几点了自己看一眼系统时间。`
657
- }
658
-
659
- /**
660
- * 给 headless 那一轮写一份 overlay —— 把 B站 MCP 挂上去。
661
- * 🔴 为什么需要它:**某个 profile 能拿到什么工具,取决于它自己那层的 patch**
662
- * (headless 里那两条 B站 MCP 就是当初手写进去的)。分发给别人时不能要求人家改 profile,
663
- * 所以插件自己生成一份、跑的时候 `--patch` 带上(dsh 支持可重复的 `--patch`)。
664
- */
665
- /**
666
- * ⭐ 值班轮能看到哪些 MCP 工具(2026-10-17 审查里的 H3)
667
- * 起因:overlay 把整台 mcp_server.py 挂成 stdio server ⇒ 那一轮的模型手里握着**原生全套**工具
668
- * (bili_reply / bili_crawl / bili_send_opus / bili_upload_video / bili_login…)——
669
- * 宿主这边 autoComment / mentionName 两道闸只拦得住我们自己的 bili_reply 工具,拦不住它 ✗。
670
- * 做法:白名单写进 overlay 的 env(mcp-client 会把它并进子进程环境),
671
- * mcp_server.py 那头**不在白名单里的工具根本不注册** —— 连名字都不给它看见 ✓。
672
- * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
673
- */
674
- const mcpWhitelist = () => {
675
- const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
676
- if (cfg.autoComment || cfg.replyToComments) list.push('bili_reply') // 只有真要发评论才给这只
677
- return list
678
- }
679
-
680
- const ensureOverlay = () => {
681
- if (!cfg.overlayMcp) return null
682
- const file = join(stateDir, 'headless-overlay.yml')
683
- const script = join(stateDir, 'mcp_server.py')
684
- const posix = (p) => String(p).replace(/\\/g, '/')
685
- const yml = [
686
- '- insert:',
687
- ' - id: mcp-bili-duty',
688
- " name: '@deepseek-ai/dsh-mcp-client'",
689
- ' config:',
690
- ' serverName: bili-duty',
691
- ' transport: stdio',
692
- ` command: '${posix(cfg.pythonPath || 'python')}'`,
693
- ' args:',
694
- ` - '${posix(script)}'`,
695
- ' env:',
696
- ` BILI_MCP_TOOLS: '${mcpWhitelist().join(',')}'`,
697
- '',
698
- ].join('\n')
699
- try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
700
- }
701
-
702
- /**
703
- * ⭐ 评论发出后复核(2026-10-16 加的行 —— 起因是用户在评论区反馈
704
- * 「**回执成功不等于公开可见,新号会被压审**」):
705
- * 那一轮的 `bili_reply` 回"发送成功"只代表**接口收了**;新号 / 风控会让它进审核,
706
- * 那时候连 @ 通知都不会推 ⇒ 插件以为发了、下一轮又不再发 ⇒ **一条都没到人**。
707
- * 所以由**宿主**(有网络、有凭证、不受沙箱限制)去拉一次评论列表 / 楼中楼来核。
708
- * ⚠️ 措辞守则:看不到写「**没查到**」,**绝不写"没发出去"**(翻页/热度排序都会漏)。
709
- */
710
- let verifying = false
711
- const runVerify = async (why = 'manual') => {
712
- if (verifying) return { ok: false, error: '上一次复核还没跑完,等它完事' }
713
- verifying = true
714
- try {
715
- log(`评论复核开始(${why})`)
716
- const r = await verifyRecent(stateDir, { log })
717
- const f = appendVerifyToReport(stateDir, r.text)
718
- log(`评论复核结束(${why}):查了 ${r.checked.length} 条${f ? `,已写进 ${f}` : ''}`)
719
- return { ok: true, why, checked: r.checked, skipped: r.skipped, text: r.text, report: f || '' }
720
- } catch (e) {
721
- log(`评论复核失败(${why}):${e?.message ?? e}`)
722
- return { ok: false, why, error: String(e?.message ?? e) }
723
- } finally { verifying = false }
724
- }
725
-
726
- /**
727
- * ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
728
- * 起因(2026-10-16 用户实测反馈):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
729
- * 最后 `out/` 是空的、判重表也没建 ⇒ **使用者完全看不到发生了什么**,也不知道白烧了钱 ✗。
730
- * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补 ✓),不覆盖已有的报告 ✓。
731
- */
732
- const stubReportIfMissing = (code) => {
733
- try {
734
- if (String(cfg.reportLevel || 'full') === 'off') return
735
- const f = join(outDir, `${dayKey()}.md`)
736
- if (existsSync(f)) return
737
- let tail = ''
738
- try { tail = readFileSync(logFile, 'utf8').slice(-1500) } catch { /* 读不到就算了 */ }
739
- writeFileSync(f, [
740
- `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
741
- '',
742
- `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
743
- '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
744
- '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
745
- '',
746
- '```',
747
- String(tail).trim(),
748
- '```',
749
- '',
750
- ].join('\n'), 'utf8')
751
- log('这一轮没写报告 ⇒ 宿主补了一份占位报告')
752
- } catch (e) { log(`补占位报告失败:${e?.message ?? e}`) }
753
- }
754
-
755
- const runDuty = (why = 'window') => {
756
- try {
757
- refreshCfg() // ⭐ 设置页刚改过的值要立刻生效(config 是就地改的,快照会过期)
758
- const dsh = resolveDsh()
759
- const overlay = ensureOverlay()
760
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
761
- if (overlay) args.push('--patch', overlay)
762
- args.push(promptFor())
763
- // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
764
- // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
765
- // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
766
- // 它写不了家目录里的 out/ 报告、整轮卡在"我是被沙箱关着的"上面打转 ✗;
767
- // 现在改成"设定由插件读进来、内联进提示词" ✓,cwd 回到插件家 ✓)
768
- const cwd = stateDir
769
- log(`刷B站开跑(${why}):${dsh.command} ${args.slice(0, 3).join(' ')} … cwd=${cwd}`)
770
- const child = spawn(dsh.command, args, {
771
- cwd,
772
- shell: dsh.shell,
773
- windowsHide: true,
774
- stdio: ['ignore', 'pipe', 'pipe'],
775
- // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
776
- // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
777
- // ⭐ ELECTRON_RUN_AS_NODE:**桌面版(Electron)里 spawn 出去的 node 必须带这个**,
778
- // 否则它当成"再开一个 Electron 应用"、什么都不干就以 0 退出(issue #5:0.17 秒、code 0、什么都没做)
779
- env: pyEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
780
- })
781
- // ⚠️ 2026-10-16 issue #1(用户 `miaoxingyunduo` 贴了崩溃日志):`duty.log` 被别的进程占着时
782
- // (`EBUSY`) `appendFileSync` 会抛异常 —— 而这里是 **stream 的 data 回调**,没人接
783
- // ⇒ **未捕获异常 ⇒ 整个 DSH 宿主被杀**(所有会话一起没)。跟 10-14 定时器那次是同一类 ✗
784
- // ⇒ ⭐ 规矩:**插件里任何写日志/写文件的地方都包好 try/catch**,再错也只该"这一轮没跑成" ✓
785
- const pick = (buf) => {
786
- try { appendFileSync(logFile, String(buf), 'utf8') } catch { /* 写不进去就丢,绝不能抛 */ }
787
- }
788
- child.stdout?.on('data', pick)
789
- child.stderr?.on('data', pick)
790
- child.on('exit', (code) => {
791
- log(`刷B站结束(${why},code=${code})`)
792
- stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
793
- rotateLogIfBig() // ⭐ 日志别无声地长(三天 797KB ⇒ 一年约 100MB)
794
- // ⭐ 这一轮挑了几条:从今天的报告里数(上一轮挑的还在同一份文件里 ⇒ 数出来就是全天的账)
795
- try {
796
- const st = readJson(dutyStateFile, {})
797
- const prev = st.day === dayKey() ? Number(st.picked || 0) : 0
798
- const picked = countPickedToday() // 报告是事实来源、state 只是兜底(跟闸门同一个算法)
799
- if (picked > prev) { bumpState({ picked }); log(`今天挑到 ${picked} 条(上限 ${Number(cfg.perDay) || 0} 条)`) }
800
- } catch (e) { log(`数挑了几条时出错(不影响这一轮):${e?.message ?? e}`) }
801
- // ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
802
- // 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
803
- // 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
804
- const verifyTimer = setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
805
- verifyTimer.unref?.() // 别为了一次复核把宿主进程的退出拖住 90 秒
806
- })
807
- child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
808
- return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
809
- } catch (e) {
810
- log(`刷B站开跑失败(${why}):${e?.stack ?? e}`)
811
- return { started: false, why, error: String(e?.message ?? e) }
812
- }
813
- }
814
-
815
- /* ─────────────── 谷时定时器 ───────────────
816
- * 🔴 2026-10-14 事故(使用者那边表现为"DSH 突然崩了、网页 Failed to fetch (gateway/internal)"):
817
- * 第一版是 `setInterval(tick, 60_000)`,回调里抛的 TypeError **没人接**,直接把整个宿主进程带走
818
- * (`dsh: fatal uncaught exception`)。⇒ **定时器回调必须自己兜住所有异常**:
819
- * 插件再错也只该"这一轮没跑成",绝不该杀宿主。
820
- */
821
- const tick = () => {
822
- try {
823
- refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
824
- if (!cfg.peakValley) return
825
- const now = new Date()
826
- const st = readJson(dutyStateFile, {})
827
- const today = dayKey(now)
828
- // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
829
- if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
830
- // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
831
- if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
832
- for (const k of ['noon', 'night']) {
833
- if (!cfg[k]) continue
834
- if (!inWindow(now, WINDOWS[k])) continue
835
- const key = `${today}:${k}`
836
- if (st.windows?.[key]) continue
837
- bumpState({ windows: { ...(st.windows || {}), [key]: true }, count: roundsToday(now) + 1, lastRunAt: now.toISOString(), lastWhy: k })
838
- runDuty(`window:${k}`)
839
- return
840
- }
841
- } catch (e) {
842
- log(`定时器这一跳出错(已兜住,不影响宿主):${e?.stack ?? e}`)
843
- }
844
- }
845
-
846
- try {
847
- const timer = setInterval(tick, 60_000)
848
- timer.unref?.() // 别因为这个定时器让进程不肯退出
849
- ctx.effect(() => () => clearInterval(timer), 'bili-duty: 谷时定时器')
850
- } catch (e) { log(`定时器没起来:${e?.message ?? e}`) }
851
-
852
- /* ─────────────── HTTP(设置页用) ─────────────── */
853
-
854
- const sendJson = (res, code, obj) => {
855
- res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
856
- res.end(JSON.stringify(obj))
857
- }
858
- const readBody = (req) => new Promise((resolve) => {
859
- let data = ''
860
- let done = false
861
- // ⚠️ 超过 1MB 得**先把 Promise 收掉**再掐断:只 destroy 的话它永远不 settle,
862
- // 这个请求就挂在那儿(不回响应、也不释放)
863
- const finish = (v) => { if (!done) { done = true; resolve(v) } }
864
- req.on('data', (c) => { data += c; if (data.length > 1_000_000) { data = ''; finish({}); req.destroy() } })
865
- req.on('end', () => { try { finish(JSON.parse(data || '{}')) } catch { finish({}) } })
866
- req.on('error', () => finish({}))
867
- })
868
-
869
- const venvPython = () => process.platform === 'win32'
870
- ? join(stateDir, '.venv', 'Scripts', 'python.exe')
871
- : join(stateDir, '.venv', 'bin', 'python')
872
-
873
- /**
874
- * 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
875
- *
876
- * 🔴 为什么必须这样(2026-10-14 事故):第一版是 `async` 全程 await,
877
- * 而"建 venv + pip install"在使用者这条网络上要**几分钟** ⇒ 那个工具调用一直挂着,
878
- * **整个会话僵住、连"暂停"都卡,最后只能重启 dsh** ✗。
879
- * 现在的做法:spawn 出去、输出追加进 `install.log`,函数立刻返回;
880
- * 想知道装完没 —— 读日志,或看 `bili_duty_status` 里的 `installing`。
881
- * 依赖走**国内镜像**(使用者那条网络 pip 直连只有 ~24KB/s ✗;uv + 清华源是几十秒 ✓)。
882
- */
883
- let installing = false
884
- const installLogFile = join(stateDir, 'install.log')
885
- const appendInstall = (s) => { try { appendFileSync(installLogFile, s, 'utf8') } catch { /* 写不进去就算了 */ } }
886
-
887
- let uvOk = null
888
- const haveUv = async () => {
889
- if (uvOk) return true
890
- const ok = await new Promise((resolve) => {
891
- try {
892
- const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
893
- c.on('exit', (code) => resolve(code === 0))
894
- c.on('error', () => resolve(false))
895
- } catch { resolve(false) }
896
- })
897
- // ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
898
- // 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
899
- if (ok) uvOk = true
900
- return ok
901
- }
902
-
903
- /** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
904
- const probeCmd = (cmd) => new Promise((resolve) => {
905
- try {
906
- const c = spawn(cmd, ['--version'], { windowsHide: true, stdio: 'ignore' })
907
- c.on('exit', (code) => resolve(code === 0))
908
- c.on('error', () => resolve(false))
909
- } catch { resolve(false) }
910
- })
911
-
912
- const spawnLogged = (cmd, args) => new Promise((resolve) => {
913
- appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
914
- let c
915
- try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: pyEnv() }) }
916
- catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
917
- const w = (b) => appendInstall(String(b))
918
- c.stdout?.on('data', w)
919
- c.stderr?.on('data', w)
920
- c.on('exit', (code) => { appendInstall(`\n--- exit ${code} ---\n`); resolve(code) })
921
- c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
922
- })
923
-
924
- // ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
925
- const asrDir = join(stateDir, '.venv-asr')
926
- const asrLogFile = join(stateDir, 'asr-install.log')
927
- const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
928
- process.platform === 'win32' ? 'python.exe' : 'python')
929
- const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
930
- // ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
931
- // 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
932
- const asrTmp = join(stateDir, 'tmp', 'asr')
933
- try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
934
-
935
- // ⭐ 判重表也由**宿主**预先建好(两个原因):
936
- // ① 那一轮被沙箱关着,"新建文件"会被拒(2026-10-14 那条规矩);
937
- // ② 它读不出判重表 ⇒ 以为"没记录过" ⇒ 同一批视频反复推送(用户实测反馈 2026-10-16)。
938
- const seenFile = join(stateDir, 'seen.md')
939
- try {
940
- if (!existsSync(seenFile)) {
941
- writeFileSync(seenFile, [
942
- '# 刷B站 · 判重表(宿主预建,那一轮只往后面追加)',
943
- '',
944
- '## 评论记录(BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字)',
945
- '',
946
- '| BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字 |',
947
- '| --- | --- | --- | --- | --- |',
948
- '',
949
- '## 看过的视频',
950
- '',
951
- ].join('\n'), 'utf8')
952
- }
953
- } catch { /* 建不了也别拦着插件加载 */ }
954
-
955
- // ⭐ 归档目录:那一轮**被沙箱关着**,只写得了"启动前就已存在"的目录 ⇒ 宿主预建 ✓
956
- // (2026-10-16 主人要的"它看过什么"清单:报告是给人看的,归档是给检索用的 ✓)
957
- const digestDir = join(stateDir, 'digest')
958
- try { mkdirSync(digestDir, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
959
-
960
- /**
961
- * ⭐ 日志轮转(2026-10-16 量出来的:`duty.log` 三天就 797 KB、没有轮转 ⇒ 一年约 100 MB ✗)
962
- * 超过 2MB 就滚成 `duty.log.1`(只留一份),别让它在 C 盘上无声地长。
963
- */
964
- const rotateLogIfBig = (limit = 2 * 1024 * 1024) => {
965
- try {
966
- if (!existsSync(logFile) || statSync(logFile).size < limit) return
967
- const bak = logFile + '.1'
968
- try { rmSync(bak, { force: true }) } catch { /* 删不掉就算了 */ }
969
- renameSync(logFile, bak)
970
- appendFileSync(logFile, `${new Date().toISOString()} 日志超过 2MB,旧的滚到 duty.log.1\n`, 'utf8')
971
- } catch { /* 轮转失败不该影响跑一轮 */ }
972
- }
973
- let installingAsr = false
974
-
975
- /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
976
- const resolveUv = async () => {
977
- const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
978
- if (await haveUv()) return 'uv'
979
- if (existsSync(bundled)) return bundled
980
- appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
981
- const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
982
- if (got) appendInstall('uv 已就位:' + got + '\n')
983
- return got
984
- }
985
-
986
- /** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
987
- const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
988
- sink(`\n$ ${cmd} ${args.join(' ')}\n`)
989
- let c
990
- try {
991
- c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
992
- env: pyEnv(env || {}) })
993
- } catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
994
- const w = (b) => sink(String(b))
995
- c.stdout?.on('data', w)
996
- c.stderr?.on('data', w)
997
- c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
998
- c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
999
- })
1000
-
1001
- /** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
1002
- const runAsrInstall = () => {
1003
- if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
1004
- installingAsr = true
1005
- appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
1006
- ;(async () => {
1007
- try {
1008
- const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1009
- const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
1010
- const py = asrPython()
1011
- const uvCmd = await resolveUv()
1012
- if (!existsSync(py)) {
1013
- // --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
1014
- if (uvCmd) await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
1015
- else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
1016
- }
1017
- if (!existsSync(py)) {
1018
- appendAsr('建环境失败 —— 先点上面的「装依赖」,把 uv 或 Python 准备好\n')
1019
- log('转写:建环境失败'); return
1020
- }
1021
- appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
1022
- let code = 1
1023
- // ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
1024
- // 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
1025
- const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
1026
- if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
1027
- if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
1028
- if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
1029
- // ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
1030
- const model = String(cfg.asrModel || 'small')
1031
- // 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
1032
- const hfHome = join(stateDir, '.hf-cache')
1033
- try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
1034
- appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
1035
- const code2 = await spawnTo(py, ['-c',
1036
- '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("模型就绪")',
1037
- model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
1038
- appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
1039
- log(`装转写依赖结束:exit=${code}/${code2}`)
1040
- } catch (e) {
1041
- appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
1042
- log(`装转写依赖崩了:${e?.message ?? e}`)
1043
- } finally { installingAsr = false }
1044
- })()
1045
- return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
1046
- }
1047
-
1048
- const runInstall = () => {
1049
- if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
1050
- installing = true
1051
- appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
1052
- ;(async () => {
1053
- try {
1054
- const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
1055
- const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1056
- const venvPy = venvPython()
1057
- const py = String(cfg.pythonPath || 'python')
1058
- const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
1059
- if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
1060
- const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
1061
- + '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
1062
- + '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
1063
- appendInstall(msg + '\n'); log(msg); installing = false; return
1064
- }
1065
- if (!existsSync(venvPy)) {
1066
- if (uvCmd) await spawnLogged(uvCmd, ['venv', join(stateDir, '.venv')])
1067
- if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
1068
- }
1069
- let code = 1
1070
- if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
1071
- if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
1072
- if (code === 0 && existsSync(venvPy)) {
1073
- await persistCfg({ pythonPath: venvPy }) // ⭐ 不然设置页里显式设过 pythonPath 的话,这里就白写了
1074
- appendInstall(`已把 pythonPath 设为 ${venvPy}\n`)
1075
- }
1076
- appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
1077
- log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
1078
- } catch (e) {
1079
- appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
1080
- log(`装依赖崩了:${e?.message ?? e}`)
1081
- } finally { installing = false }
1082
- })()
1083
- return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
1084
- }
1085
-
1086
- /** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
1087
- const checkUp = async () => {
1088
- const out = { ok: false, steps: [] }
1089
- if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
1090
- if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
1091
- try {
1092
- const who = await call('bili_check_credential', {})
1093
- out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
1094
- out.steps.push('登录有效')
1095
- } catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
1096
- try {
1097
- const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
1098
- const t = typeof s === 'string' ? s : JSON.stringify(s)
1099
- out.sample = t.slice(0, 160)
1100
- out.steps.push('能搜到东西')
1101
- out.ok = t.length > 2
1102
- if (!out.ok) out.error = '搜到了空结果'
1103
- } catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
1104
- return out
1105
- }
1106
-
1107
- let updateCache = null // { at, latest } —— 检查更新的结果缓存
1108
-
1109
- const status = () => {
1110
- refreshCfg()
1111
- const st = readJson(dutyStateFile, {})
1112
- const today = dayKey()
1113
- return {
1114
- ok: true,
1115
- home: stateDir,
1116
- config: cfg,
1117
- windows: WINDOWS,
1118
- today,
1119
- ranToday: st.day === today ? Number(st.count || 0) : 0, // 今天跑了几轮
1120
- pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
1121
- lastRunAt: st.lastRunAt || null,
1122
- lastWhy: st.lastWhy || null,
1123
- loggedIn: hasCredential(),
1124
- venvReady: venvReadyNow(),
1125
- version: myVersion(),
1126
- installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
1127
- installing,
1128
- // ── 本地转写那一套的状态(设置页要显示进度)──
1129
- asrReady: asrReadyNow(),
1130
- // 模型下没下(免得界面写"就绪"、其实还得现下)
1131
- asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
1132
- asrInstalling: installingAsr,
1133
- asrLog: asrLogFile,
1134
- asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1135
- installLog: installLogFile,
1136
- // ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
1137
- installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1138
- report: join(outDir, `${today}.md`),
1139
- log: logFile,
1140
- }
1141
- }
1142
-
1143
- const handle = async (req, res, url) => {
1144
- refreshCfg()
1145
- const path = url.pathname
1146
- if (req.method === 'GET' && path === '/api/bili-duty/status') return sendJson(res, 200, status())
1147
-
1148
- if (req.method === 'GET' && path === '/api/bili-duty/config') {
1149
- return sendJson(res, 200, { ok: true, config: cfg, windows: WINDOWS, home: stateDir })
1150
- }
1151
-
1152
- if (req.method === 'POST' && path === '/api/bili-duty/config') {
1153
- const body = await readBody(req)
1154
- const patch = {}
1155
- for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
1156
- 'commentPerRound', 'mentionName',
1157
- 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound']) {
1158
- if (k in body) patch[k] = body[k]
1159
- }
1160
- // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
1161
- const { clean, host } = await persistCfg(patch)
1162
- log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
1163
- return sendJson(res, 200, { ok: true, config: cfg, host })
1164
- }
1165
-
1166
- if (req.method === 'POST' && path === '/api/bili-duty/run') {
1167
- return sendJson(res, 200, { ok: true, ...runDuty('manual') })
1168
- }
1169
-
1170
- // 建 venv + 装依赖(**不等它**:几分钟的活,进度写 install.log)
1171
- if (req.method === 'POST' && path === '/api/bili-duty/install') {
1172
- return sendJson(res, 200, runInstall())
1173
- }
1174
-
1175
- // 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
1176
- if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
1177
- return sendJson(res, 200, runAsrInstall())
1178
- }
1179
-
1180
- // ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
1181
- // (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
1182
- if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
1183
-
1184
- // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
1185
- if (req.method === 'GET' && path === '/api/bili-duty/report') {
1186
- const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
1187
- const file = join(outDir, day + '.md')
1188
- let text = ''
1189
- let exists = false
1190
- try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
1191
- return sendJson(res, 200, { ok: true, day, exists, file, text })
1192
- }
1193
-
1194
- // 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
1195
- if (req.method === 'POST' && path === '/api/bili-duty/open') {
1196
- const body = await readBody(req)
1197
- const dir = String(body?.what || 'out') === 'home' ? stateDir : outDir
1198
- if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
1199
- // ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
1200
- // try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
1201
- // ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
1202
- // 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
1203
- const attempts = [
1204
- ['cmd.exe', ['/c', 'start', '', dir]],
1205
- [join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
1206
- ['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
1207
- ]
1208
- let lastErr = ''
1209
- for (const [cmd, args] of attempts) {
1210
- const err = await new Promise((resolve) => {
1211
- let done = false
1212
- let child
1213
- try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
1214
- child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
1215
- setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
1216
- })
1217
- if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
1218
- lastErr = err
1219
- log('打开文件夹失败(' + cmd + '):' + err)
1220
- }
1221
- return sendJson(res, 200, { ok: false, error: lastErr, dir })
1222
- }
1223
-
1224
- // 「一键升级」:点了它自己跑 add(⚠️ 之后要重启才生效)
1225
- if (req.method === 'POST' && path === '/api/bili-duty/upgrade') return sendJson(res, 200, runUpgrade())
1226
-
1227
- // 「检查更新」:⚠️ 只在用户点按钮时才联网(不偷偷发请求 ✓),结果缓存一天 ✓
1228
- if (req.method === 'GET' && path === '/api/bili-duty/update') {
1229
- const out = { ok: true, current: myVersion(), latest: '', hasNew: false, page: 'https://www.npmjs.com/package/dsh-bili-scroll' }
1230
- try {
1231
- if (updateCache && Date.now() - updateCache.at < 24 * 3600 * 1000) out.latest = updateCache.latest
1232
- else {
1233
- const ctrl = new AbortController()
1234
- const timer = setTimeout(() => ctrl.abort(), 8000)
1235
- const r = await fetch('https://registry.npmjs.org/dsh-bili-scroll/latest', { signal: ctrl.signal })
1236
- clearTimeout(timer)
1237
- const j = await r.json()
1238
- out.latest = String(j.version || '')
1239
- updateCache = { at: Date.now(), latest: out.latest }
1240
- }
1241
- } catch (e) { out.ok = false; out.error = String(e?.message ?? e); return sendJson(res, 200, out) }
1242
- // ⭐ 要比谁更新,不能只比不相等 —— 否则本地版本高于 npm 时会误报有新版
1243
- const vnum = (v) => String(v || '').split('.').map((x) => parseInt(x, 10) || 0)
1244
- const cmp = (a, b) => {
1245
- const x = vnum(a), y = vnum(b)
1246
- for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) }
1247
- return 0
1248
- }
1249
- out.hasNew = Boolean(out.latest) && cmp(out.latest, out.current) > 0
1250
- return sendJson(res, 200, out)
1251
- }
1252
-
1253
- // 「测一下」:真跑一次,回答"能不能用"
1254
- if (req.method === 'POST' && path === '/api/bili-duty/check') {
1255
- return sendJson(res, 200, await checkUp())
1256
- }
1257
-
1258
- // ── 扫码登录(设置页用)──
1259
- if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
1260
- const { key, url } = await loginStart()
1261
- const png = await renderQrPng(cfg.pythonPath, stateDir, url)
1262
- let qr = null
1263
- if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
1264
- log(`出码:${url}${qr ? '(已画成 PNG)' : '(没画出 PNG —— venv 里大概还没有 qrcode)'}`)
1265
- return sendJson(res, 200, { ok: true, key, url, qr })
1266
- }
1267
-
1268
- if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
1269
- const body = await readBody(req)
1270
- const r = await loginPoll(String(body.key || ''))
1271
- if (r.state === 'ok') {
1272
- const file = saveCredential(stateDir, r.credential)
1273
- log(`登录成功,凭证已写入 ${file}`)
1274
- return sendJson(res, 200, { ok: true, state: 'ok', file })
1275
- }
1276
- return sendJson(res, 200, { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message })
1277
- }
1278
-
1279
- return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
1280
- }
1281
-
1282
- try {
1283
- ctx.effect(() => ctx.webServer.register({
1284
- kind: 'prefix',
1285
- path: '/api/bili-duty',
1286
- handler: async (req, res) => {
1287
- try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
1288
- sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
1289
- }
1290
- },
1291
- }), 'bili-duty: /api/bili-duty 路由')
1292
- } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
1293
-
1294
- ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
1295
- log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
1296
- }
1
+ /**
2
+ * dsh-bili-scroll —— host 半端
3
+ * ============================================================================
4
+ * 它是什么:每天替你刷一轮 B站 的插件版。
5
+ * 1. **插件内部起那份 Python MCP**(`vendor/bilibili-mcp/mcp_server.py`),把它的能力
6
+ * 注册成 dsh 工具(搜 / 字幕 / 评论 / 发评论 @ 人 / 登录检查)—— 用户不用再手改 profile。
7
+ * 2. **谷时定时器**:中午 12:00–14:00、夜间 00:30–08:30(`lib/state.mjs` 里的 `WINDOWS`),
8
+ * 开了哪个窗口才在哪个窗口自动跑;「定量刷」的数字是**每天总量上限**。
9
+ * 3. **跑一轮** = 起一个 `dsh --profile headless <prompt>`(跟老方案同一条路,已被验证过),
10
+ * 把 `duty.md` 交给它,报告写进 `<家>/out/<日期>.md`。
11
+ * 4. **设置页**(`client.js`)通过 `/api/bili-duty/*` 读写配置、看状态、扫码登录。
12
+ *
13
+ * 家里(`$DSH_HOME/bili-duty/`)都有什么:`config.json` / `mcp_server.py`(从 vendor 铺过来,
14
+ * 这样凭证 `bili_credential.json` 落在家里而不是插件包里)/ `duty.md` / `out/` / `duty.log`。
15
+ * ============================================================================
16
+ */
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
25
+ import { spawn } from 'node:child_process'
26
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
+ import { join, dirname } from 'node:path'
28
+ import { createRequire } from 'node:module'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { McpClient } from './lib/mcp.mjs'
31
+ import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
32
+ import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
33
+ import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
34
+ import { resolveUid } from './lib/uid.mjs'
35
+ import { loadCred as dmCred, listSessions as dmList, fetchMsgs as dmFetch } from './lib/dm.mjs'
36
+ import { ensureUv } from './lib/uvfetch.mjs'
37
+
38
+ export const name = 'bili-scroll'
39
+ export const inject = ['webServer', 'tools', 'settings']
40
+
41
+ /**
42
+ * 🔴 每个字段都要 `.extra('volatile', true)`:DSH 的 settings.update() 只认 volatile 字段
43
+ * (`volatileForm(schema)`,见 dsh-settings),没有这个标记写设置时会抛
44
+ * "Plugin entry … has no volatile fields"。另外两个条件是:
45
+ * **loader entry 的 id 必须等于命名空间名**(我们 patch 里就是 `bili-scroll`)+ **本模块导出 Config**。
46
+ */
47
+ // ⚠️ schemastery 彻底拿不到时,这里会是 undefined —— **插件照常加载**,只是设置页降级
48
+ // (宁可少一个设置页,也不能因为一个 import 让整条插件不激活 ✗)
49
+ export const Config = !z ? undefined : z.object({
50
+ /** 定量刷:每天最多挑几条 */
51
+ perDay: z.natural().default(5).extra('volatile', true),
52
+ /** 峰谷自动刷(总开关) */
53
+ peakValley: z.boolean().default(false).extra('volatile', true),
54
+ /** 峰谷子开关:中午 / 晚上 */
55
+ noon: z.boolean().default(true).extra('volatile', true),
56
+ night: z.boolean().default(true).extra('volatile', true),
57
+ /** 自动评论 @ 使用者(默认关 —— 会拿用户的号说话,必须他自己开) */
58
+ autoComment: z.boolean().default(false).extra('volatile', true),
59
+ /** ⭐ 私信投递:off=只发评论(现状)· only=只私信(完全不公开评论)· both=评论 + 私信 */
60
+ dmMode: z.string().default('only').extra('volatile', true),
61
+ /** ⭐ 私信发给谁的 UID(数字)。要在 dmMode 不是 off 时填 */
62
+ dmUid: z.natural().default(0).extra('volatile', true),
63
+ /** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
64
+ transcribe: z.boolean().default(false).extra('volatile', true),
65
+ /** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
66
+ asrModel: z.string().default('small').extra('volatile', true),
67
+ /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人);**有效范围 0–20,口径见 clampPatch**(0 = 不发评论)
68
+ * ⚠️ 这里故意**不加 `.max(20)`**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ **整条插件不激活** ✗
69
+ * (跟 schemastery 拿不到时的降级同一个道理)—— 范围一律由 clampPatch 在**写**和**读**两头钳。 */
70
+ commentPerRound: z.natural().default(1).extra('volatile', true),
71
+ /** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
72
+ mentionName: z.string().default('').extra('volatile', true),
73
+ /** 关注关键词(空了用章程里默认那几组) */
74
+ keywords: z.array(z.string()).default([]).extra('volatile', true),
75
+ /** Python:指向 venv 里的那个最好 */
76
+ pythonPath: z.string().default('python').extra('volatile', true),
77
+ /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
78
+ dshBin: z.string().default('').extra('volatile', true),
79
+ /** headless 那一轮用哪个 profile */
80
+ headlessProfile: z.string().default('headless').extra('volatile', true),
81
+ /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
82
+ overlayMcp: z.boolean().default(true).extra('volatile', true),
83
+ /** 跑 headless 时的工作目录 */
84
+ /** ⭐ 人设提示词:使用者想让它怎么说话(比"填工作区"对新手友好得多)· 留空 = 按中性说话 */
85
+ personaPrompt: z.string().default('').extra('volatile', true),
86
+ /** 私信监听:只认主人本人发来的私信,有新消息就回一句 · 2.0 起默认开(查私信不花 token,只有真有人发消息才回一句) */
87
+ dmWatch: z.boolean().default(true).extra('volatile', true),
88
+ /** 私信监听间隔(分钟,最小 3) */
89
+ dmWatchMinutes: z.natural().default(1).extra('volatile', true),
90
+ /** 私信回复每天最多几条(防刷) */
91
+ dmReplyPerDay: z.natural().default(0).extra('volatile', true),
92
+ /** 只回这个 UID(0 = 用「私信发给谁」那个) */
93
+ dmOwnerUid: z.natural().default(0).extra('volatile', true),
94
+ workspace: z.string().default('').extra('volatile', true),
95
+ /** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
96
+ reportLevel: z.string().default('full').extra('volatile', true),
97
+ /** 回复评论:默认关。开了之后只回「直接回复到它那条评论下面的人」 */
98
+ replyToComments: z.boolean().default(false).extra('volatile', true),
99
+ /** 每轮最多回几条 */
100
+ replyPerRound: z.number().default(1).extra('volatile', true),
101
+ /** 自动收藏:0 = 不收藏;>0 = 目标收藏夹 fid(设置页下拉选)。动作在宿主侧完成,模型拿不到这只手 */
102
+ favFolderId: z.number().default(0).extra('volatile', true),
103
+ })
104
+
105
+ /**
106
+ * ⭐ 给所有 Python 子进程统一 UTF-8。
107
+ * 起因(2026-10-16,用户在评论区反馈):Windows 上 Python 默认按 GBK 编解码,
108
+ * 我们的脚本 / 内置 MCP 都会往管道里打中文与 emoji(`✅`、`—` 之类),一进管道就可能
109
+ * 报 UnicodeDecodeError / 乱码或直接中断。**编码这种环境差异,插件该自己钉死,不该让用户碰。**
110
+ */
111
+ const pyEnv = (extra = {}) => ({
112
+ ...process.env,
113
+ PYTHONUTF8: '1',
114
+ PYTHONIOENCODING: 'utf-8',
115
+ ...extra,
116
+ })
117
+
118
+ export function apply(ctx, config) {
119
+ const pluginDir = dirname(fileURLToPath(import.meta.url))
120
+ // ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
121
+ let myVer = ''
122
+ const myVersion = () => {
123
+ if (myVer) return myVer
124
+ try { myVer = String(JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).version || '') } catch { myVer = '' }
125
+ return myVer
126
+ }
127
+ const stateDir = stateDirOf(ctx)
128
+ /**
129
+ * ⚠️ 2026-10-19(用户 `天文星球` 的截图):装转写依赖时 uv 直接 exit 2 ——
130
+ * `error: Could not acquire lock / failed to open file C:\Users\AP\AppData\Local\uv\cache\.lock: 拒绝访问 (os error 5)`
131
+ * ⇒ **uv 默认把缓存放在 `%LOCALAPPDATA%\uv\cache`**,那地方一旦没权限(企业策略 / 杀软 / 别的用户建的)就彻底装不动 ✗
132
+ * ⇒ 把 uv 的缓存 / 工具 / 解释器目录**全部钉在插件家里**(那儿一定可写)✓ ——
133
+ * 跟 `HF_HOME` 那次是同一类:"环境差异由插件自己消掉,别让用户碰"。
134
+ */
135
+ const uvEnv = (extra = {}) => pyEnv({
136
+ UV_CACHE_DIR: join(stateDir, 'uv-cache'),
137
+ UV_TOOL_DIR: join(stateDir, 'uv-tools'),
138
+ UV_PYTHON_INSTALL_DIR: join(stateDir, 'uv-python'),
139
+ ...extra,
140
+ })
141
+
142
+ // ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
143
+ // (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
144
+ const sitePkgs = (venvRoot) => {
145
+ const win = join(venvRoot, 'Lib', 'site-packages')
146
+ if (existsSync(win)) return win
147
+ try {
148
+ const lib = join(venvRoot, 'lib')
149
+ for (const d of readdirSync(lib)) {
150
+ if (!d.startsWith('python')) continue
151
+ const p = join(lib, d, 'site-packages')
152
+ if (existsSync(p)) return p
153
+ }
154
+ } catch { /* 没有就算了 */ }
155
+ return win
156
+ }
157
+ const hasPkg = (venvRoot, prefix) => {
158
+ try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
159
+ }
160
+ // ⚠️ 2026-10-18 用户实测:徽章显示「环境已就绪」、B站也登录了,可一跑就 `ModuleNotFoundError: No module named 'mcp'`
161
+ // ⇒ 原判据**只查了 bilibili_api**(0.1.2 修"假就绪"时留下的口子)⇒ 缺 `mcp` / `qrcode` 也照样说就绪 ✗
162
+ // ⇒ 三个都得有:bilibili_api(查数据)· mcp(跑那一轮的 MCP 服务器)· qrcode(画登录二维码)
163
+ const VENV_DEPS = ['bilibili_api', 'mcp', 'qrcode']
164
+ const missingVenvDeps = () => VENV_DEPS.filter((n) => !hasPkg(join(stateDir, '.venv'), n))
165
+ const venvReadyNow = () => existsSync(venvPython()) && missingVenvDeps().length === 0
166
+ const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
167
+ const cfgFile = join(stateDir, 'config.json')
168
+ /**
169
+ * 本地优先(2026-10-18,两个用户实证):DSH 0.2.1 某些启动方式下 ctx.settings.update 会抛
170
+ * profile reload requires the root Include entry(dsh-app-boot 的 reconcileProfilePatches)
171
+ * ⇒ 写不进宿主 ⇒ 而 loadCfg 的规则是"宿主有值就以宿主为准" ⇒ 用户刚点的开关几秒后被压回原状,
172
+ * 表现成"开一个,另一个像被关掉"(其实是每个开关都在自己亮一下就被复位)。
173
+ * ⇒ 记一份"本地刚存、宿主没同步成功"的键,loadCfg 时这些键以本地为准;宿主同步成功就把它们删掉。
174
+ */
175
+ const overrideFile = join(stateDir, 'local-override.json')
176
+ const readOverride = () => {
177
+ try { const j = readJson(overrideFile, {}); return j && typeof j === 'object' ? j : {} } catch { return {} }
178
+ }
179
+ const markOverride = (keys, on) => {
180
+ try {
181
+ const cur = readOverride()
182
+ for (const k of keys) { if (on) cur[k] = Date.now(); else delete cur[k] }
183
+ writeFileSync(overrideFile, JSON.stringify(cur, null, 2), 'utf8')
184
+ } catch (e) { log('记 local-override 失败:' + (e && e.message ? e.message : e)) }
185
+ }
186
+ const logFile = join(stateDir, 'duty.log')
187
+ const outDir = join(stateDir, 'out')
188
+
189
+ /** out/*.md 里每条视频的「档位」和「为什么值得」—— 给「它刷过的视频」列表补信息用(2026-10-21) */
190
+ function reportMeta() {
191
+ const map = new Map()
192
+ let files = []
193
+ try {
194
+ files = readdirSync(outDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse().slice(0, 60)
195
+ } catch { return map }
196
+ for (const f of files) {
197
+ let text = ""
198
+ try { text = readFileSync(join(outDir, f), "utf8") } catch { continue }
199
+ // 按 `### BVxxx` 切块
200
+ const blocks = text.split(/\n(?=###\s*BV)/)
201
+ for (const b of blocks) {
202
+ const m = b.match(/^###\s*(BV[0-9A-Za-z]+)/)
203
+ if (!m) continue
204
+ const bvid = m[1]
205
+ const tierM = b.match(/\*\*档位\*\*\s*[::]\s*(值得看|可以看|不值当)/)
206
+ const whyM = b.match(/\*\*为什么值得\*\*\s*[::]\s*([\s\S]*?)(?=\n\s*\*\*|$)/)
207
+ map.set(bvid, {
208
+ tier: tierM ? tierM[1] : "",
209
+ why: whyM ? whyM[1].replace(/\s+/g, " ").trim().slice(0, 160) : "",
210
+ })
211
+ }
212
+ }
213
+ return map
214
+ }
215
+ mkdirSync(outDir, { recursive: true })
216
+
217
+ const log = (msg) => {
218
+ const line = `${new Date().toISOString()} ${msg}`
219
+ try { appendFileSync(logFile, line + '\n', 'utf8') } catch { /* 日志写不进去也不致命 */ }
220
+ try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
221
+ }
222
+
223
+ // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
224
+ for (const n of hostDeps.notes) log(n)
225
+
226
+ /**
227
+ * ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
228
+ * `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
229
+ * 是个**对象**、`config.keywords` 也不是数组(日志里打成 `[object Object]`)。对象是"真值",
230
+ * 于是 `if (!cfg.peakValley) return` 这类守卫全部失效,一路走到 `cfg.keywords.filter(...)`
231
+ * 抛 TypeError —— 而那是在**定时器回调**里,没人接 ⇒ **整个 dsh 进程被带走** ✗✗。
232
+ * ⇒ 两条规矩:
233
+ * ① 逐字段按默认值的类型挑一遍(拿不准就用默认值),再叠自己的 `config.json`;
234
+ * ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
235
+ */
236
+ const DEFAULTS = {
237
+ dmWatch: true, dmWatchMinutes: 1, dmReplyPerDay: 0, dmOwnerUid: 0,
238
+ personaPrompt: '',
239
+ perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
240
+ dmMode: 'only', dmUid: 0,
241
+ keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1, favFolderId: 0,
242
+ }
243
+ const normalize = (src, base) => {
244
+ const out = { ...base }
245
+ for (const k of Object.keys(base)) {
246
+ const v = src?.[k]
247
+ if (v === undefined || v === null) continue
248
+ if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
249
+ else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
250
+ else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
251
+ else if (typeof base[k] === 'string') { if (typeof v === 'string') out[k] = v }
252
+ }
253
+ return out
254
+ }
255
+ /* ── 配置从哪来:DSH 自带的「插件设置」页 > 插件自己的 config.json > 默认值 ──
256
+ * 老代码是 `normalize(readJson(cfgFile, {}), normalize(config, DEFAULTS))` —— config.json 一刀切盖住宿主,
257
+ * 后果:用户在 DSH 自带的插件设置页里改了值,插件照旧读 config.json 的旧值 ⇒ **静默不生效** ✗。
258
+ * 判据用 describe() 给的 `user` 层(= 用户在原生设置页**显式设过**的键):
259
+ * 设过的键 ⇒ 听宿主的;没设过 ⇒ 还是 config.json 说了算(老用户零回归 ✓)。
260
+ * 拿不到设置服务就完全退回老行为。
261
+ */
262
+ /* ⭐ 「设置里能写什么范围」收在一处 —— HTTP 接口、bili_config 工具、**读配置**三处都走它,
263
+ * 所以 perDay 0–50 / commentPerRound 0–20 这些数字**只有这一份**(schema 里不再写一遍)。
264
+ * (2026-10-16 审查修:原来只有 HTTP 路径钳制,工具路径连 perDay 都能写任意值;
265
+ * commentPerRound 两条路径都没钳,直接 POST 能写 9999)
266
+ * (2026-10-17 复查补:DSH 原生设置页那条路我们钳不到**写** —— 用户能在那儿直接填 9999 ⇒
267
+ * loadCfg 读回来也过一遍,写进去和读出来一个口径) */
268
+ const clampPatch = (patch) => {
269
+ const p = { ...patch }
270
+ if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
271
+ if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
272
+ if ('personaPrompt' in p) p.personaPrompt = String(p.personaPrompt || '').slice(0, 8000)
273
+ if ('dmWatch' in p) p.dmWatch = Boolean(p.dmWatch)
274
+ if ('dmWatchMinutes' in p) p.dmWatchMinutes = Math.max(1, Math.min(120, Number(p.dmWatchMinutes) || 1))
275
+ if ('dmReplyPerDay' in p) p.dmReplyPerDay = Math.max(0, Math.min(20, Number(p.dmReplyPerDay) || 0))
276
+ if ('dmOwnerUid' in p) p.dmOwnerUid = Math.max(0, Number(p.dmOwnerUid) || 0)
277
+ if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
278
+ if ('replyPerRound' in p) p.replyPerRound = Math.max(1, Math.min(Number(p.replyPerRound) || 1, 10))
279
+ if ('favFolderId' in p) p.favFolderId = Math.max(0, Math.floor(Number(p.favFolderId) || 0))
280
+ if ('commentPerRound' in p) p.commentPerRound = Math.max(0, Math.min(Number(p.commentPerRound) || 0, 20))
281
+ return p
282
+ }
283
+
284
+ const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
285
+ const savedCfg = () => normalize(readJson(cfgFile, {}), { ...DEFAULTS })
286
+ /**
287
+ * ⭐⭐ 0.1.11(2026-10-19 · 第三批用户报"开关还是回弹"之后改的)
288
+ * 0.1.9 / 0.1.10 一直想"跟宿主对账":宿主那边有值就以宿主为准 —— 但 DSH 0.2.1 的某些启动方式下
289
+ * `ctx.settings.update` 写不进去(`profile reload requires the root Include entry`),
290
+ * 于是用户在插件面板点的每一个开关,5 秒轮询后都被宿主旧值压回 ⇒ 表现成"开一个,另一个像被关掉"。
291
+ * ⇒ 现在改成记一份"**用户在插件面板里存过的键**"(`config.json` 的 `_set`):
292
+ * · 存过 ⇒ **插件这份说了算**(宿主写不进去也不会被压回)
293
+ * · 没存过、DSH 原生设置里有值 ⇒ 听宿主的(原生页仍是正当入口)
294
+ * · 都没有 ⇒ 默认值
295
+ * ⚠️ 老版本没记过 `_set` ⇒ 第一次跑时按"和默认值不一样"反推一遍,免得升级上来的老用户又被弹回。
296
+ */
297
+ // ⚠️ 2026-10-19 事故(用户设置页停在"读设置中…"):这两行以前漏了声明 ——
298
+ // ESM 是严格模式,`hostDiff = diff` 直接抛 ReferenceError ⇒ loadCfg 挂 ⇒ /status 500
299
+ // ⇒ 设置页永远读不到配置。**所有"正在用但没声明"的变量都是这种死法**,别再犯。
300
+ let hostDiff = [] // 插件这份和 DSH 原生设置那份不一样、且以插件为准的键
301
+ let lastDiffSig = '' // 上一次记过日志的组合(同一种组合只写一行)
302
+ let touchedKeys = (() => {
303
+ const raw = readJson(cfgFile, {})
304
+ if (Array.isArray(raw._set)) return new Set(raw._set)
305
+ return new Set(Object.keys(DEFAULTS).filter((k) => k in raw && JSON.stringify(raw[k]) !== JSON.stringify(DEFAULTS[k])))
306
+ })()
307
+ const CFG_SIG = ['perDay', 'mentionName', 'replyToComments', 'overlayMcp'] // 靠这几个键认出"哪一行是我们"
308
+ let settingsRow = { at: 0, row: null }
309
+ /**
310
+ * 我们那一行设置表单(拿不到就返回 null ⇒ 一切按 config.json 来)。
311
+ * ⚠️ DSH 自带的「插件设置」页**不需要注册** —— 老代码那句 `ctx.settings.register(...)` 是个不存在的方法,
312
+ * 每次都抛 TypeError、被 catch 吞掉(2026-10-17 读宿主源码 @deepseek-ai/dsh-settings 才明白):
313
+ * describe() 自己扫 profile 里**带 volatile 字段**的条目列出来(lib/index.js:413-452),
314
+ * 命名空间 ns **等于 loader 条目的 id**(我们 cordis.patch.yml 里是 `bili-scroll`,不是老注释写的 bili-duty)。
315
+ * 另外两条同样要紧(都是读宿主源码得来的,别再想当然):
316
+ * ① `apply(ctx, config)` 收到的 config 就是 fiber.config 本体,volatile 字段一改宿主**就地**改它
317
+ * (cordis-plugin-loader/src/config/entry.ts:`this.fiber._config = …` + `_commitVolatile()`)⇒
318
+ * 启动时快照的 cfg 永远看不到设置页的改动,用之前得 refreshCfg()。
319
+ * ② describe() 每行给三层:value(生效值)/ base(继承层)/ user(用户在设置页显式设过的那层)。
320
+ */
321
+ const settingsDesc = (fresh = false) => {
322
+ const t = Date.now()
323
+ if (!fresh && t - settingsRow.at < 3000) return settingsRow.row // 挡 UI 轮询:describe() 要扫所有条目
324
+ let row = null
325
+ try {
326
+ const rows = ctx.settings?.describe?.() || []
327
+ row = rows.find((r) => r?.value && CFG_SIG.every((k) => k in r.value)) || null
328
+ } catch (e) { log(`读 DSH 设置表单失败(不影响跑):${e?.message ?? e}`) }
329
+ settingsRow = { at: t, row }
330
+ return row
331
+ }
332
+ const loadCfg = () => {
333
+ const saved = savedCfg()
334
+ const row = settingsDesc()
335
+ // ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
336
+ if (!row) return clampPatch(saved)
337
+ const user = row.user || {}
338
+ // ⭐ 2026-10-19(另一位用户的 dsh 给出的更彻底的一招):**别用闭包里那份 `config` 当宿主基准** ——
339
+ // 桌面版里它是挂载时的快照,之后写进 `cordis.patch.yml` 的值不会热刷新进来 ✗
340
+ // ⇒ 用 `describe()` 这一行里的 `value`(当前 patch 解析出来的现值)当基准,拿不到才退回 `config` ✓
341
+ const out = normalize(row.value && typeof row.value === 'object' ? row.value : config, { ...DEFAULTS })
342
+ // ⚠️ 2026-10-18(用户实证 + 他的 dsh 定位):`pythonPath` 是**装依赖时自动写的**,
343
+ // 但它一旦在 DSH 原生设置里有过值(默认 "python"),就会被宿主覆盖回来 ⇒
344
+ // 「重装依赖」日志写着"已把 pythonPath 设为 venv",实际生效的还是系统那个空壳 python
345
+ // ⇒ 画二维码 import 不到 qrcode ⇒ **永远不出码** ✗
346
+ // ⇒ 这个键不让宿主对账(它由插件自己算出来,不该被界面上的旧值压住)
347
+ const ov = readOverride() // 本地刚存、宿主没同步成功的键 ⇒ 以本地为准
348
+ const touched = new Set([
349
+ ...touchedKeys, // 用户在我们面板里保存过的键(0.1.11 的判据)
350
+ ...Object.keys(ov),
351
+ 'pythonPath', // 插件自己算出来的路径
352
+ // ⭐ 2026-10-19(一位用户的 dsh 分析出来的):桌面版 0.2.x 里,写进 cordis.patch.yml 的设置
353
+ // 不会热刷新到正在运行的实例 ⇒ 我们从 ctx 读到的那份 config 一直是挂载时的旧默认值
354
+ // ⇒ 只要某个键"config.json 里的值和默认值不一样",就说明有人显式设过它,必须以本地为准,
355
+ // 否则在桌面版上会被那份旧默认值压回(用户看到的就是"开关自己弹回去")
356
+ ...Object.keys(DEFAULTS).filter((k) => JSON.stringify(saved[k]) !== JSON.stringify(DEFAULTS[k])),
357
+ ])
358
+ // ⭐ 2026-10-19:把"插件这份和 DSH 原生设置那份不一样、且以插件为准"的键记下来 ——
359
+ // 只记不吵(每台机器每种组合只写一行日志),`/status` 里也能看到,排查时一眼够用
360
+ const diff = []
361
+ for (const k of Object.keys(DEFAULTS)) {
362
+ if (touched.has(k)) { out[k] = saved[k]; continue } // 我们这边管 ⇒ 不再被宿主压回
363
+ if (user[k] !== undefined) { out[k] = user[k]; continue } // 用户在 DSH 原生页改的 ⇒ 听他的
364
+ out[k] = saved[k]
365
+ }
366
+ for (const k of touched) {
367
+ if (k in DEFAULTS && user[k] !== undefined && JSON.stringify(user[k]) !== JSON.stringify(out[k])) diff.push(k)
368
+ }
369
+ hostDiff = diff
370
+ const sig = diff.slice().sort().join(',')
371
+ if (sig && sig !== lastDiffSig) {
372
+ lastDiffSig = sig
373
+ log(`这些键以插件面板为准(DSH 原生设置里那份不一样):${sig}`)
374
+ }
375
+ return clampPatch(out)
376
+ }
377
+ const cfg = loadCfg()
378
+ // ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
379
+ const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
380
+ const saveCfg = (patch) => {
381
+ Object.assign(cfg, normalize(patch, cfg))
382
+ // ⭐ 记下"这些键由插件面板说了算"(0.1.11:loadCfg 拿它决定谁赢)
383
+ for (const k of Object.keys(patch || {})) if (k in DEFAULTS) touchedKeys.add(k)
384
+ writeJson(cfgFile, { ...cfg, _set: [...touchedKeys] })
385
+ }
386
+ /**
387
+ * ⭐ 同时写一份到宿主:不写的话会出现死结 —— 某个键一旦在原生设置页设过就由宿主说了算,
388
+ * 插件页再改那个键就成了"改了没反应" ✗。写失败不算致命(config.json 那份还在),把话带回给用户。
389
+ */
390
+ const saveCfgHost = async (patch) => {
391
+ const attempt = async (fresh) => {
392
+ const row = settingsDesc(fresh)
393
+ if (!row?.ns || typeof ctx.settings?.update !== 'function') {
394
+ throw Object.assign(new Error('DSH 这边没有可写的设置表单(值只落在插件自己的 config.json 里)'), { noForm: true })
395
+ }
396
+ await ctx.settings.update(row.ns, patch, row.revision)
397
+ // ⭐ READBACK(2026-10-18):update() 不报错 ≠ 值真的生效 ✗
398
+ // 三四位用户装了 0.1.9 后开关照样弹回,而界面**没有**出现"没同步"提示
399
+ // ⇒ 说明 host.ok 判成了成功、值却没落进生效层 ⇒ 必须**回读用户层**再确认一遍 ✓
400
+ const after = settingsDesc(true)
401
+ const user = after?.user || {}
402
+ const bad = Object.keys(patch).filter((k) => {
403
+ const want = patch[k]
404
+ const got = user[k]
405
+ if (Array.isArray(want)) return JSON.stringify(want) !== JSON.stringify(got)
406
+ if (want !== null && typeof want === "object") return true // 配置里没有嵌套对象,出现就按没写进去处理
407
+ return String(got) !== String(want)
408
+ })
409
+ if (bad.length) throw Object.assign(new Error("写入没生效(读回来还是旧值):" + bad.join(", ")), { stale: true })
410
+ return row.ns
411
+ }
412
+ try { return { ok: true, ns: await attempt(false) } }
413
+ catch (e) {
414
+ if (!e?.noForm) { // revision 过期 / 回读不一致 ⇒ 重取一次再写
415
+ try { return { ok: true, ns: await attempt(true), retried: true } } catch (e2) { e = e2 }
416
+ }
417
+ log(`写 DSH 设置失败:${e?.message ?? e}`)
418
+ return { ok: false, error: String(e?.message ?? e) }
419
+ }
420
+ }
421
+ /**
422
+ * ⭐ 落配置的**唯一入口**:插件自己那份 config.json + DSH 原生设置页那份,钳完范围一起写。
423
+ * (2026-10-17 复查修:`saveCfg` + `await saveCfgHost` 这对义务原来在三个调用点各抄一遍 ——
424
+ * 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
425
+ */
426
+ const persistCfg = async (patch) => {
427
+ const clean = clampPatch(patch)
428
+ saveCfg(clean)
429
+ const host = await saveCfgHost(clean)
430
+ // 宿主写成功 ⇒ 恢复对账;失败 ⇒ 记成"本地优先",别让界面把它们压回去
431
+ // ⭐ 无条件本地优先(2026-10-18 第二次修):
432
+ // 三四位用户装了 0.1.9、界面没有"没同步"提示、开关照样弹回
433
+ // ⇒ 往 DSH 写这一步既没报错、回读也看不出差别,但**生效值仍是旧的**
434
+ // (多半是 profile 层压过了 user 层)✗
435
+ // ⇒ 不再指望 DSH 那边:只要是从**插件面板**保存的键,读配置时一律以插件自己那份为准 ✓
436
+ // ⚠️ 代价:同名的项若在 DSH 原生设置页改,以插件面板为准(我们的设置页才是"正式的家")。
437
+ markOverride(Object.keys(clean), true)
438
+ if (!host.ok) log('⚠️ 这几项没能同步到 DSH 原生设置,改为以插件自己的值为准:' + Object.keys(clean).join(', ') + '(' + host.error + ')')
439
+ return { clean, host }
440
+ }
441
+
442
+ // ⭐ 2026-10-21 一次性迁移:2.0 把「私信双向」改成默认开、间隔 1 分钟、去掉每日上限,
443
+ // 但老用户的 config.json 里存着旧默认值(false / 5 / 3)⇒ 会盖掉新默认。
444
+ // 判据:三项**都还停在旧默认**才动(说明是真没碰过),碰过任何一项就尊重用户。
445
+ const MIG_200 = () => {
446
+ const marker = join(stateDir, '.mig-2.0.0')
447
+ try {
448
+ if (existsSync(marker)) return
449
+ const saved = readJson(join(stateDir, 'config.json'), {}) || {}
450
+ const oldAll = saved.dmWatch === false
451
+ && Number(saved.dmWatchMinutes) === 5
452
+ && Number(saved.dmReplyPerDay) === 3
453
+ if (oldAll) {
454
+ persistCfg({ dmWatch: true, dmWatchMinutes: 1, dmReplyPerDay: 0 })
455
+ .then(() => log('已把「私信双向」迁到 2.0 的默认:开着、每 1 分钟查一次、不限条数'))
456
+ .catch((e2) => log('迁移私信默认值失败(不影响使用):' + String(e2?.message ?? e2)))
457
+ }
458
+ writeFileSync(marker, String(Date.now()), 'utf8')
459
+ } catch (e2) { log('迁移检查出错(不影响使用):' + String(e2?.message ?? e2)) }
460
+ }
461
+
462
+ ensureHomeFiles(pluginDir, stateDir, log)
463
+
464
+ // ⭐ 值班章程单独记账(跟 mcp_server.py 不一样:用户改过就得留着 —— 见 state.mjs 的 ensureCharter)
465
+ ensureCharter(pluginDir, stateDir, log)
466
+
467
+ /* ─────────────── MCP(内部起那个 Python MCP) ─────────────── */
468
+
469
+ let mcp = null
470
+ let mcpKey = ''
471
+ const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
472
+
473
+ const ensureMcp = async () => {
474
+ if (mcp?.running && mcpKey === mcpKeyNow()) return mcp
475
+ if (mcp) { mcp.stop(); mcp = null }
476
+ const script = join(stateDir, 'mcp_server.py')
477
+ if (!existsSync(script)) throw new Error(`家里没有 mcp_server.py(应有 ${script})—— 装上插件后重启一次 dsh 会自动铺好`)
478
+ mcpKey = mcpKeyNow()
479
+ mcp = new McpClient({
480
+ command: cfg.pythonPath || 'python',
481
+ args: [script],
482
+ cwd: stateDir, // 凭证落在家里 ✓
483
+ // ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
484
+ // Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
485
+ env: uvEnv(),
486
+ log,
487
+ })
488
+ return mcp
489
+ }
490
+
491
+ const call = async (toolName, args, opts) => {
492
+ const client = await ensureMcp()
493
+ return client.call(toolName, args, opts)
494
+ }
495
+
496
+ const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
497
+
498
+ /* ─────────────── 工具 ─────────────── */
499
+
500
+ const textOutput = {
501
+ schema: { type: 'object', properties: {}, additionalProperties: true },
502
+ render: (_args, value) => [{
503
+ type: 'text',
504
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
505
+ }],
506
+ }
507
+
508
+ /**
509
+ * 工具返回值一律过一遍这里:**宿主只吃"无损 JSON"**,塞进去任何函数/循环引用/类实例都会报
510
+ * `value is not lossless JSON`(2026-10-14 第一次挂上就撞了一次)。
511
+ */
512
+ const safe = (v) => {
513
+ if (v === undefined || v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
514
+ try { return JSON.parse(JSON.stringify(v)) } catch { return String(v) }
515
+ }
516
+
517
+ const tool = (spec) => ctx.tools.register(defineTool({
518
+ ...spec,
519
+ output: textOutput,
520
+ async execute(args, exec) { return safe(await spec.execute(args, exec)) },
521
+ }))
522
+
523
+ tool({
524
+ name: 'bili_whoami',
525
+ description: '看 B站 那边登录的是谁(凭证是否有效、昵称、mid)。第一次用要先在「刷B站」设置页里扫码登录。',
526
+ parameters: {},
527
+ async execute() {
528
+ if (!hasCredential()) {
529
+ return { ok: false, loggedIn: false, hint: '还没登录:去 dsh 设置页的「刷B站」里点「扫码登录」(凭证会存在插件自己的家目录里)' }
530
+ }
531
+ const r = await call('bili_check_credential', {})
532
+ return { ok: true, ...(typeof r === 'object' ? r : { raw: r }) }
533
+ },
534
+ })
535
+
536
+ tool({
537
+ name: 'bili_search',
538
+ description: '搜 B站 视频(可排序)。返回标题 / BV号 / 播放量 / UP / 时长 等。\n'
539
+ + '⚠️ 刷一轮的用法:搜近 7 天的、按播放或最新都行,然后**读字幕原文**再判断(别看摘要下结论)。',
540
+ parameters: {
541
+ keyword: { type: 'string', description: '搜索关键词' },
542
+ num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
543
+ order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
544
+ },
545
+ async execute(args) {
546
+ return call('bili_search', {
547
+ keyword: String(args.keyword ?? ''),
548
+ num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
549
+ order: String(args.order || 'totalrank'),
550
+ })
551
+ },
552
+ })
553
+
554
+ tool({
555
+ name: 'bili_subtitle',
556
+ description: '取一个视频的字幕(AI 字幕)全文。**判断内容就靠它** —— 没有字幕时,'
557
+ + '若设置里开了「本地转写」再想别的办法,否则直接跳过这条。',
558
+ parameters: { bvid: { type: 'string', description: 'BV号' } },
559
+ async execute(args) { return call('bili_subtitle', { bvid: String(args.bvid ?? '') }) },
560
+ })
561
+
562
+ tool({
563
+ name: 'bili_comments',
564
+ description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。',
565
+ parameters: {
566
+ bvid: { type: 'string', description: 'BV号' },
567
+ num: { type: 'number', description: '取几条(默认 30)' },
568
+ },
569
+ async execute(args) {
570
+ return call('bili_comments', { bvid: String(args.bvid ?? ''), num: Math.max(1, Number(args.num) || 30) })
571
+ },
572
+ })
573
+
574
+ tool({
575
+ name: 'bili_reply',
576
+ description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
577
+ + '🔴 安全闸:设置页的「自动评论并 @ 你」**默认是关的**;关着的时候这个工具会拒绝 —— '
578
+ + '除非本人当场让你发(那时传 force: true)。',
579
+ parameters: {
580
+ bvid: { type: 'string', description: 'BV号' },
581
+ text: { type: 'string', description: '评论正文(短、干净、纯文本 @;别写敏感词)' },
582
+ rpid: { type: 'number', description: '要回复的评论 id(0/不填 = 发新评论)' },
583
+ force: { type: 'boolean', description: '本人当场让你发的,传 true 越过"自动评论关着"的闸' },
584
+ },
585
+ async execute(args) {
586
+ // dmMode==='only' 时本轮根本不该发公开评论 —— 在工具层也一并挡掉(除非当场 force)
587
+ const commentOn = Boolean(cfg.autoComment && cfg.mentionName && cfg.dmMode !== 'only')
588
+ if (!commentOn && !args.force) {
589
+ return {
590
+ ok: false,
591
+ blocked: true,
592
+ hint: cfg.dmMode === 'only'
593
+ ? '现在是「只私信」模式(dmMode=only)—— 规定不发公开评论,改成用 bili_send_message 私信。确实要发时传 force: true。'
594
+ : '要发评论得同时满足两条:设置里开着「自动评论并 @ 你」、并且填了「要 @ 谁」。或者本人当场让你发时传 force: true。',
595
+ }
596
+ }
597
+ return call('bili_reply', {
598
+ bvid: String(args.bvid ?? ''),
599
+ text: String(args.text ?? ''),
600
+ rpid: Number(args.rpid) || 0,
601
+ })
602
+ },
603
+ })
604
+
605
+ tool({
606
+ name: 'bili_status',
607
+ description: '看它今天的状态:跑了没跑、挑了几条、额度剩多少、报告在哪、登录过没有、下次窗口什么时候开。',
608
+ parameters: {},
609
+ async execute() {
610
+ refreshCfg()
611
+ const st = readJson(join(stateDir, 'state.json'), {})
612
+ const now = new Date()
613
+ return {
614
+ ok: true,
615
+ today: dayKey(now),
616
+ ranToday: st.day === dayKey(now) ? Number(st.count || 0) : 0,
617
+ pickedToday: countPickedToday(now),
618
+ perDay: cfg.perDay,
619
+ // ⭐ 2026-10-19:这一轮会用哪个 dsh(空 = 没找到 ⇒ 那一轮根本跑不起来)
620
+ dshCli: dshCliCached(),
621
+ windows: Object.fromEntries(Object.entries(WINDOWS).map(([k, w]) => [k, {
622
+ label: w.label, start: w.start, end: w.end,
623
+ switchOn: Boolean(cfg[k]),
624
+ opening: inWindow(now, w),
625
+ }])),
626
+ peakValley: cfg.peakValley,
627
+ autoComment: cfg.autoComment,
628
+ dmMode: cfg.dmMode,
629
+ dmUid: Number(cfg.dmUid) || 0,
630
+ transcribe: cfg.transcribe,
631
+ loggedIn: hasCredential(),
632
+ home: stateDir,
633
+ report: join(outDir, `${dayKey(now)}.md`),
634
+ }
635
+ },
636
+ })
637
+
638
+ tool({
639
+ name: 'bili_run',
640
+ description: '**立刻跑一轮**(不等窗口):起一个 headless 会话,把 duty.md 交给它去刷、写报告。'
641
+ + '适合"我现在就想看一份"的场合。',
642
+ parameters: {},
643
+ async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
644
+ })
645
+
646
+ tool({
647
+ name: 'bili_login_start',
648
+ description: '开始 B站 扫码登录:出二维码(画成 PNG 存在插件家目录里,返回路径)和一个 key。\n'
649
+ + '用法:拿到 PNG 后用 `present` 把它推给本人(手机 B站 → 我的 → 扫一扫),再用 `bili_login_poll` 轮询。\n'
650
+ + '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
651
+ parameters: {},
652
+ async execute() {
653
+ const { key, url } = await loginStart()
654
+ const png = await renderQrPng(cfg.pythonPath, stateDir, url)
655
+ return {
656
+ ok: true, key, url, png,
657
+ hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去设置里点「建 venv 装依赖」(venv 里要有 qrcode)',
658
+ }
659
+ },
660
+ })
661
+
662
+ tool({
663
+ name: 'bili_login_poll',
664
+ description: '查扫码结果:waiting 还没扫 / scanned 扫了待确认 / ok 成功 / expired 过期。'
665
+ + '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
666
+ parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
667
+ async execute(args) {
668
+ const r = await loginPoll(String(args.key ?? ''))
669
+ if (r.state === 'ok') {
670
+ const file = saveCredential(stateDir, r.credential)
671
+ log(`登录成功,凭证已写入 ${file}`)
672
+ return { ok: true, state: 'ok', file }
673
+ }
674
+ return { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message }
675
+ },
676
+ })
677
+
678
+ tool({
679
+ name: 'bili_setup',
680
+ description: '给刷B站装运行环境:在插件家目录建一个**独立 venv**、装上那个 Python MCP 的依赖,装完自动把 `pythonPath` 指过去。\n'
681
+ + '**第一次用之前跑一次**。⭐ 它**起完就返回、不等装完**(几分钟的活),进度看家目录里的 `install.log`。',
682
+ parameters: {},
683
+ async execute() { return runInstall() },
684
+ })
685
+
686
+ tool({
687
+ name: 'bili_config',
688
+ description: '读 / 改「刷B站」的设置。不给 `patch` 就是读;给了就改(只认 schema 里的字段)。\n'
689
+ + '例:`{"perDay":5,"peakValley":true,"noon":true,"night":true,"autoComment":false,"transcribe":false,"keywords":["DeepSeek Harness"]}`;'
690
+ + '私信投递:`{"dmMode":"only","dmUid":12345678}`(dmMode = off / only / both)。',
691
+ parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
692
+ async execute(args) {
693
+ let host = null
694
+ if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
695
+ // ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
696
+ host = (await persistCfg(args.patch)).host
697
+ }
698
+ return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow(), host }
699
+ },
700
+ })
701
+
702
+ /* ─────────────── 跑一轮(跟老方案同一条路:dsh --profile headless <prompt>) ─────────────── */
703
+
704
+ const dutyStateFile = join(stateDir, 'state.json')
705
+ const bumpState = (patch) => {
706
+ const st = readJson(dutyStateFile, {})
707
+ const today = dayKey()
708
+ if (st.day !== today) { st.day = today; st.count = 0; st.picked = 0; st.windows = {} }
709
+ writeJson(dutyStateFile, { ...st, ...patch })
710
+ return st
711
+ }
712
+
713
+ /* ⭐ 「定量刷」承诺的是**每天挑几条**(2026-10-16 审查修 H1:原来 st.count 从没自增过,
714
+ * tick 那道 `count >= perDay` 的闸形同虚设 ⇒ noon + night 两个窗口各跑满一轮,
715
+ * 后一轮还会把前一轮的报告覆盖掉)。
716
+ * 数哪儿?数**今天的报告**里那些 `### BV号` 标题行(章程写死了这个格式)。
717
+ * ⚠️ 为什么不数「报告里出现的所有 BV」(2026-10-17 复查修的):报告里还会出现**没挑中**的 BV ——
718
+ * 「因无字幕跳过 N 条」那行、评论区里别人问起的视频、"判重表读不到"的说明 —— 全算进去的话,
719
+ * perDay 默认 5 ⇒ 一轮里出现 5 个 BV 就把当天额度吃光 ⇒ **傍晚那轮直接不跑** ✗。
720
+ * ⚠️ 围栏 ``` 里的内容一律不看:那一轮没写报告时 stubReportIfMissing 会把 duty.log 尾部贴进报告,
721
+ * 那里面的 BV 是"看过的",不是"挑出来的"。
722
+ * ⚠️ 数不出来(章程那条格式没照做)就当 0 条 —— 宁可少算(顶多多跑一轮、额度也写在提示词里),
723
+ * 也别把"提到过"算成"挑中"(那会让使用者当天再也拿不到内容)✓。 */
724
+ const PICK_RE = /^#{2,3}[ \t]*(BV[0-9A-Za-z]{10})/gm
725
+ const reportFileOf = (now = new Date()) => join(outDir, `${dayKey(now)}.md`)
726
+ const countPicks = (file) => {
727
+ try {
728
+ const text = readFileSync(file, 'utf8').replace(/```[\s\S]*?```/g, '')
729
+ const ids = new Set()
730
+ for (const m of text.matchAll(PICK_RE)) ids.add(m[1])
731
+ return ids.size
732
+ } catch { return 0 }
733
+ }
734
+ // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
735
+ const countPickedToday = (now = new Date()) => {
736
+ const st = readJson(dutyStateFile, {})
737
+ const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
738
+ return Math.max(fromState, countPicks(reportFileOf(now)))
739
+ }
740
+ const roundsToday = (now = new Date()) => {
741
+ const st = readJson(dutyStateFile, {})
742
+ return st.day === dayKey(now) ? Number(st.count || 0) : 0
743
+ }
744
+
745
+ // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
746
+ // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
747
+ let upgrading = false
748
+ let lastUpdateSource = 'npm' // 上一次「检查更新」是从哪儿查到新版的:npm / github(一键升级按它选装法)
749
+ let ghUpdateCache = null
750
+ const runUpgrade = () => {
751
+ if (upgrading) return { ok: true, busy: true, log: installLogFile, hint: '正在升级,别重复点' }
752
+ upgrading = true
753
+ let spec = lastUpdateSource === 'github' ? 'github:c1overzz/dsh-bili-scroll' : 'dsh-bili-scroll'
754
+ ;(async () => {
755
+ try {
756
+ // ⭐ 2026-10-22(用户实证):没装 git 的机器上 `add github:` 必失败(pnpm 要调 git ls-remote)
757
+ // ⇒ 从 GitHub 升级时**直接取 Release 里的 tgz 直链**,https 下载,不需要 git ✓
758
+ if (lastUpdateSource === 'github') {
759
+ try {
760
+ const rr = await fetch('https://api.github.com/repos/c1overzz/dsh-bili-scroll/releases/latest', {
761
+ signal: AbortSignal.timeout(8000),
762
+ headers: { 'user-agent': 'dsh-bili-scroll', accept: 'application/vnd.github+json' },
763
+ })
764
+ if (rr.ok) {
765
+ const jj = await rr.json()
766
+ const tag = String(jj.tag_name || '').trim()
767
+ const ver = tag.replace(/^v/, '')
768
+ if (ver && ver !== myVersion()) {
769
+ const url = 'https://github.com/c1overzz/dsh-bili-scroll/releases/download/' + tag + '/dsh-bili-scroll-' + ver + '.tgz'
770
+ spec = url
771
+ appendInstall('(用 Release 的 tgz 直链:' + url + ')\n')
772
+ }
773
+ }
774
+ } catch (e) { appendInstall('(查 Release 直链没成功,退回 github: 装法)\n') }
775
+ }
776
+ appendInstall('\n=== ' + new Date().toISOString() + ' 开始升级(add ' + spec + ')===\n')
777
+ const dsh = resolveDsh()
778
+ // ⭐ 照抄那一轮的形状:resolveDsh 返回 { command, args, shell }
779
+ // (写错就退化成裸 'dsh',而宿主 PATH 里常常没有它 ⇒ 静默失败)
780
+ // ⚠️ 2026-10-19:找不到 dsh 时不再硬跑,直接把要做的事说清楚
781
+ if (dsh.missing) {
782
+ appendInstall('=== 没法自动升级:找不到 dsh。' + dsh.hint + ' ===\n')
783
+ appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
784
+ upgrading = false
785
+ return
786
+ }
787
+ let cmd = dsh.command || 'dsh'
788
+ let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', spec]
789
+ if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
790
+ appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
791
+ const code = await spawnTo(cmd, args, { sink: appendInstall })
792
+ if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
793
+ else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
794
+ } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
795
+ })()
796
+ return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
797
+ }
798
+
799
+ /**
800
+ * ⭐⭐ 2026-10-19(主人:"改一下,让他的 dsh 直接弄好不行吗"):**自己去把 dsh 找出来,别让用户手填**。
801
+ * 桌面版(Electron)不是 npm -g 装的、`dsh` 也不在 PATH 上 ⇒ 老逻辑只猜三个固定位置,猜不到就让用户填 `dshBin` ✗
802
+ * ⇒ 现在从近到远挨个试,最后**在应用目录里做一次有界递归搜索**(只认 `dsh/lib/bin.js`,最多几千个目录就收手)。
803
+ */
804
+ const findDshCli = () => {
805
+ // ① 用户显式填的
806
+ const explicit = String(cfg.dshBin || '').trim()
807
+ if (explicit && existsSync(explicit)) return explicit
808
+ // ② 让 Node 的解析器替我们找(web profile / 桌面版 app 目录,两条路都可能命中)
809
+ try {
810
+ const req = createRequire(import.meta.url)
811
+ for (const spec of ['@deepseek-ai/dsh/lib/bin.js', '@deepseek-ai/dsh']) {
812
+ try { const p = req.resolve(spec); if (p && existsSync(p)) return p } catch { /* 换下一个 */ }
813
+ }
814
+ } catch { /* 解析器不可用就跳过 */ }
815
+ // ③ npm 全局 / POSIX 常见位置
816
+ for (const g of [
817
+ join(process.env.APPDATA || '', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
818
+ join(process.env.ProgramFiles || '', 'nodejs', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
819
+ '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
820
+ '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
821
+ ].filter(Boolean)) if (existsSync(g)) return g
822
+ // ④ 桌面版:在应用目录里有界搜一遍
823
+ const roots = []
824
+ if (process.resourcesPath) roots.push(process.resourcesPath)
825
+ try { roots.push(join(dirname(process.execPath), 'resources')) } catch { /* 没有就算了 */ }
826
+ const tried = []
827
+ for (const root of roots) {
828
+ if (!root || tried.includes(root) || !existsSync(root)) continue
829
+ tried.push(root)
830
+ const hit = scanForDsh(root, 0, { n: 0 })
831
+ if (hit) return hit
832
+ }
833
+ return ''
834
+ }
835
+ // 有界递归:深度 ≤ 7、最多翻 4000 个目录(桌面版 resources 里目录不小,别再往上加了)
836
+ const scanForDsh = (dir, depth, budget) => {
837
+ if (depth > 7 || budget.n > 4000) return ''
838
+ let ents = []
839
+ try { ents = readdirSync(dir, { withFileTypes: true }) } catch { return '' }
840
+ for (const d of ents) {
841
+ if (!d.isDirectory()) continue
842
+ if (++budget.n > 4000) return ''
843
+ const p = join(dir, d.name)
844
+ if (d.name === 'dsh') {
845
+ const bin = join(p, 'lib', 'bin.js')
846
+ if (existsSync(bin)) return bin
847
+ }
848
+ const hit = scanForDsh(p, depth + 1, budget)
849
+ if (hit) return hit
850
+ }
851
+ return ''
852
+ }
853
+
854
+ // ⭐ 2026-10-19:以前找不到 dsh 就退到裸 `dsh` 硬跑 ⇒ 必然 spawn 失败、只留 exit 1 + 乱码。
855
+ // 现在先看 PATH 里到底有没有它:**没有就明确返回 missing**(调用方会停下来说清楚),
856
+ // 有才允许"最后一招"(有些人是手动配过 PATH 的 npm 全局)✓
857
+ const hasDshOnPath = () => {
858
+ const exts = process.platform === 'win32' ? ['', '.cmd', '.exe', '.bat', '.ps1'] : ['']
859
+ const dirs = String(process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':')
860
+ for (const d of dirs) {
861
+ const dir = d.replace(/^"|"$/g, '').trim()
862
+ if (!dir) continue
863
+ for (const e of exts) {
864
+ try { if (existsSync(join(dir, 'dsh' + e))) return true } catch { /* 忽略坏路径 */ }
865
+ }
866
+ }
867
+ return false
868
+ }
869
+
870
+ let autoDshLogged = ''
871
+ // ⭐ 缓存:设置页每 5 秒拉一次 /status,不能让"找不到时的递归搜索"每 5 秒跑一遍
872
+ let dshCliCache = { v: null, at: 0 }
873
+ const dshCliCached = () => {
874
+ const now = Date.now()
875
+ if (dshCliCache.v !== null && now - dshCliCache.at < 60_000) return dshCliCache.v
876
+ const v = findDshCli()
877
+ dshCliCache = { v, at: now }
878
+ return v
879
+ }
880
+ const resolveDsh = () => {
881
+ const cli = dshCliCached()
882
+ if (cli) {
883
+ const explicit = String(cfg.dshBin || '').trim()
884
+ if (cli !== explicit && autoDshLogged !== cli) {
885
+ autoDshLogged = cli
886
+ log(`自动找到 dsh:${cli}(不用手填 dshBin 了)`)
887
+ }
888
+ return { command: process.execPath, args: [cli], shell: false }
889
+ }
890
+ // ⚠️ 走到这儿就是真找不到 —— 一定要出声(静默失败比报错更坏)
891
+ // ⭐ 2026-10-19(一位桌面版用户的 duty.log):以前这里**照样返回 `dsh`** ⇒ 那一轮必然 spawn 失败、
892
+ // 只留一句 exit 1 和 `'dsh' 不是内部或外部命令` 的乱码 ⇒ 用户完全不知道该怎么办 ✗
893
+ // ⭐ 但也不能一味"判死":有些人的 PATH 里确实有 dsh(例如手动配过 PATH 的 npm 全局)
894
+ // ⇒ 保留**最后一招**:用 PATH 里那个试一次(日志说明白),失败时报告里也会写清原因 ✓
895
+ const hint = '已经把常见位置和应用目录都翻过一遍了。可以手动把「dsh 路径」填成 <应用目录>/…/@deepseek-ai/dsh/lib/bin.js,或改用 npm 装的 dsh'
896
+ if (!hasDshOnPath()) {
897
+ log('⚠️ 找不到 dsh(常见位置、应用目录、PATH 里都没有)⇒ ' + hint)
898
+ return { missing: true, hint }
899
+ }
900
+ log('⚠️ 没在常见位置找到 dsh,最后试一下 PATH 里的 `dsh` ⇒ ' + hint)
901
+ return { command: 'dsh', args: [], shell: true, fallback: true, hint }
902
+ }
903
+
904
+ // ⭐ 私信默认化(2026-10-21):用户常常只填了「@ 谁」的昵称、没填 UID
905
+ // ⇒ 我们按昵称替他查一次 mid 并缓存;**查不到就降级回只发评论**(绝不能"默认私信"却一条都送不出去 ✗)
906
+ let dmUidCache = 0
907
+ async function warmDmUid() {
908
+ if (Number(cfg.dmUid) > 0) return Number(cfg.dmUid)
909
+ if (cfg.dmMode === 'off') return 0
910
+ const name = String(cfg.mentionName || '').trim()
911
+ if (!name) return 0
912
+ try {
913
+ const mid = await resolveUid(stateDir, name, log)
914
+ if (mid > 0) { dmUidCache = mid; log(`私信对象:${name} → ${mid}`) }
915
+ return mid
916
+ } catch (e) { log('查私信 UID 出错:' + String(e?.message ?? e)); return 0 }
917
+ }
918
+
919
+ const promptFor = () => {
920
+ // ⭐ 章程取哪一份:家里那份优先(ensureCharter 保证铺好;他改过的话就是他那份,升级的事由 ensureCharter 记账处理)
921
+ const duty = charterPath(pluginDir, stateDir)
922
+ // ⭐ 2026-10-19(issue #36):这里以前是**在路径字符串上**做 split('<家目录>') —— 空操作 ✗
923
+ // 章程内容还是让那一轮自己去读,里面 5 处占位符原样进了模型的眼睛(它读成 ~)。
924
+ // 现在改成:宿主读内容 → 把占位符换成真路径 → 内联进提示词(也省掉它一次读文件)✓
925
+ let charterText = ''
926
+ try {
927
+ charterText = readFileSync(duty, 'utf8').split('<家目录>').join(stateDir)
928
+ } catch { /* 读不到就退回"让它自己读路径"(下面提示词里会说) */ }
929
+ const kws = Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : []
930
+ // ⭐ 开场先让它读**工作区**的 AGENTS.md —— 这一轮的工作目录就是使用者的工作区,
931
+ // AGENTS.md 本来就会自动注入;这里再点一句,保证它真按使用者的设定来(别人的设定同理)。
932
+ // ⭐ 那一轮的工作目录是**插件家**(沙箱只让它写自己工作目录里的东西 ✗),
933
+ // 所以"使用者的设定"由**我们自己读进来**喂给它 —— 它在宿主进程里跑,没有沙箱限制 ✓
934
+ let ownerDoc = ''
935
+ try {
936
+ // ⚠️ 2026-10-16 用户反馈("只要给人设就会走偏"):留空时我们以前退到 `process.cwd()`,
937
+ // 也就是 **dsh 进程自己的工作目录** —— 那对多数人根本不是他的工作区 ✗
938
+ // ⇒ 读错人设、甚至把别的目录的设定读进来 ✓
939
+ // ⭐ 现在的规矩:**留空 = 不读设定**(宁可按中性说话,也别猜错 ✓);要按人设就自己填工作区 ✓。
940
+ // ⭐ 2026-10-21:优先用「人设提示词」这个框(主人原话:工作区确实可以不要,有人不会用,改成直接填人设提示词确实更方便)
941
+ // ⭐ 2026-10-21:人设框既收「文字」,也收「一个路径」——填路径就去读那个文件/目录下的 AGENTS.md,
942
+ // 这样"从一个文件导入"和"自己写一段"用同一个框,不用额外按钮 ✓(老用户的工作区兜底逻辑不变)
943
+ const personaRaw = String(cfg.personaPrompt || '').trim()
944
+ let persona = personaRaw
945
+ let personaFromFile = ''
946
+ if (personaRaw && !personaRaw.includes('\n')) {
947
+ const looksPath = /^[A-Za-z]:[\\/]/.test(personaRaw) || /^\\\\/.test(personaRaw) || personaRaw.startsWith('~/')
948
+ if (looksPath) {
949
+ try {
950
+ let f = personaRaw.replace(/^~\//, process.env.USERPROFILE ? process.env.USERPROFILE + '/' : '~/')
951
+ if (existsSync(f) && statSync(f).isDirectory()) f = join(f, 'AGENTS.md')
952
+ if (existsSync(f)) {
953
+ persona = readFileSync(f, 'utf8')
954
+ personaFromFile = f
955
+ }
956
+ } catch { /* 读不到就当普通文字 */ }
957
+ }
958
+ }
959
+ if (persona) {
960
+ ownerDoc = '\n\n===== 使用者的人设' + (personaFromFile ? '(读自 ' + personaFromFile + ')' : '提示词') + '(**按这个来**)=====\n'
961
+ + persona.slice(0, 6000)
962
+ + '\n===== 人设结束 =====\n'
963
+ }
964
+ const wsDir = String(cfg.workspace || '').trim()
965
+ const agents = (!persona && wsDir) ? join(wsDir, 'AGENTS.md') : ''
966
+ if (agents && existsSync(agents)) {
967
+ const text = readFileSync(agents, 'utf8')
968
+ ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
969
+ + text.slice(0, 6000)
970
+ + '\n===== 设定结束 =====\n'
971
+ }
972
+ } catch { /* 读不到就算了,别因此跑不成 */ }
973
+ // ⭐ 「每天最多挑 N 条」是**全天**的账:上一轮已经挑过的要扣掉,这一轮只给剩下的额度
974
+ const quota = Math.max(0, Number(cfg.perDay) || 0)
975
+ const already = countPickedToday()
976
+ const left = Math.max(0, quota - already)
977
+ // ⭐ 投递方式(2026-10-17 加):dmMode = off(只评论)· only(只私信)· both(都发)
978
+ // commentOn 是"这一轮到底能不能发公开评论"的唯一判据(工具层的闸用的是同一套逻辑)
979
+ const dmUidEff = Number(cfg.dmUid) || dmUidCache
980
+ const dmDegraded = cfg.dmMode === 'only' && !(dmUidEff > 0)
981
+ const dmModeEff = dmDegraded ? 'off' : cfg.dmMode
982
+ if (dmDegraded) log('⚠️ 选的是「只私信」,但不知道发给谁(没填 UID、也没填「@ 谁」,或查不到)⇒ 这一轮降级成只发评论')
983
+ const commentOn = Boolean(cfg.autoComment && cfg.mentionName && dmModeEff !== 'only')
984
+ const dmOn = Boolean(dmModeEff !== "off" && dmUidEff > 0)
985
+ return `你是这个账号的 B站 助手。`
986
+ + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
987
+ + ownerDoc
988
+ + (ownerDoc
989
+ ? ''
990
+ : '⭐ 这一轮**没有**拿到使用者的设定(他没填「工作区」,或那个目录里没有 `AGENTS.md`)⇒ **按中性说话**:别扮演任何人、也别自己编一套设定 ✓。')
991
+ + (charterText
992
+ ? '下面是**值班章程**(里面的「<家目录>」已经替换成真实绝对路径了)—— 严格按它执行:\n\n'
993
+ + charterText + '\n\n按上面的章程刷一轮 B站,'
994
+ : `然后读章程 ${duty},按里面的要求刷一轮 B站(章程里的 <家目录> 指的是 ${stateDir}),`)
995
+ + (cfg.reportLevel === 'brief'
996
+ ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
997
+ : cfg.reportLevel === 'off'
998
+ ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
999
+ : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
1000
+ + (cfg.reportLevel === 'off' ? '' : `⚠️ 今天那份报告里可能已经有上一轮写的东西 —— 有就**追加到末尾**,别覆盖(配额按全天算)。`)
1001
+ + (cfg.reportLevel === 'off' ? '' : '⭐ 报告里**每挑中一条**,第一行写成 `### BV号`(例:`### BV1xxxxxxx`)—— 宿主**就数这些标题行**算今天挑了几条:漏写当成没挑中,给只是提到 / 跳过的视频加上标题则当成挑中。')
1002
+ + (already > 0
1003
+ ? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
1004
+ : `今天最多挑 ${quota} 条;`)
1005
+ + `关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
1006
+ + `本地转写:${cfg.transcribe
1007
+ ? (asrReadyNow()
1008
+ ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
1009
+ : '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
1010
+ : '关着(没字幕的视频就跳过)'};`
1011
+ + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
1012
+ + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
1013
+ + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
1014
+ + `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
1015
+ + ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
1016
+ + `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
1017
+ + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${commentOn
1018
+ ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
1019
+ : (dmModeEff === 'only'
1020
+ ? '关着的 —— 当前是「**只私信**」模式:**这一轮一条公开评论都不许发**,改走私信'
1021
+ : '关着的(只写报告,别发评论)')}。`
1022
+ + (dmOn
1023
+ ? `⭐⭐ **私信投递**:用 bili_send_message 把这一轮的结果发到 UID **${dmUidEff}**。`
1024
+ + `内容**别照抄评论那套**:把本轮每条值得的写成 **标题 + 链接 + 一句话理由**`
1025
+ + `(私信是私人渠道、不占别人的地方,可以比评论长一点,但**别啰嗦**)。`
1026
+ + `🔴🔴 **但私信有长度上限 —— 实测 500 字符就顶了**:`
1027
+ + `490 字那条能发出去、再长就被接口拒(报 **code 21007**)✗。`
1028
+ + `所以**一条私信务必控制在 500 字以内、最多塞 4~5 条** —— 宁可少放一条,也别整条被拒;`
1029
+ + `⚠️ **被拒了就精简一次重发**(删理由、砍条目),**别原样无脑重试** ✗。`
1030
+ + `🔴🔴 **私信是写给「人」看的,必须是干净的人话** —— **不许出现任何设置名 / 内部术语 / 台账口径** ✗:`
1031
+ + `dmMode、only、aiReply、判据、白名单、prompt、token、复核 这类词,**一个都不许出现在私信正文里**;`
1032
+ + `**更不许写「公开评论一条没发 / 楼中楼跳过」这种内部机制说明** ✗`
1033
+ + `(那是在跟使用者念操作手册,可读性极差)。他关心的只有:**这次挑了哪几条、为什么值得**。`
1034
+ + `(想交代"没别的了"就用一句人话,比如「今天没什么新的了」;`
1035
+ + `⚠️ **不要**把「无字幕跳过 N 条」这种统计口径搬进私信 —— 那是**报告**里写的,不是私信里写的。)`
1036
+ + `⚠️ 私信里**不用 @ 任何人**;⚠️ **一轮只发一条私信**(多条合并成一条);`
1037
+ + `⚠️ 私信里**不要**写"本鱼 / 本鲸 / 主人 / 人家"那套;⚠️ 发完把"已私信"记进报告(**报告里**可以写口径)。`
1038
+ + (cfg.dmMode === 'both' ? `⚠️ 现在是 **both**:公开评论照发(走下面那一整套规矩),私信**另外**再发一条。` : '')
1039
+ : '')
1040
+ + (cfg.replyToComments
1041
+ ? `回复评论:开着 —— 只回「直接回复到你那条评论下面的人」,回完记进报告;找不到你那条评论就跳过,别硬找。`
1042
+ + `\n⭐ 条数规矩(2026-10-22 主人定):**别人**每轮最多回 ${Number(cfg.replyPerRound) || 1} 条;**主人本人(uid=${Number(cfg.dmOwnerUid) || Number(cfg.dmUid) || dmUidCache || '没填'})不受条数限制**,他回你几次你就答几次;但**同一条评论只回一次**(别对同一句话重复回)。`
1043
+ : `回复评论:关着(一个字都不要回任何人 —— 有人回复你那条评论也不要理)。`)
1044
+ + `最后用一句话汇报你挑到了什么。现在几点了自己看一眼系统时间。`
1045
+ }
1046
+
1047
+ /**
1048
+ * 给 headless 那一轮写一份 overlay —— 把 B站 MCP 挂上去。
1049
+ * 🔴 为什么需要它:**某个 profile 能拿到什么工具,取决于它自己那层的 patch**
1050
+ * (headless 里那两条 B站 MCP 就是当初手写进去的)。分发给别人时不能要求人家改 profile,
1051
+ * 所以插件自己生成一份、跑的时候 `--patch` 带上(dsh 支持可重复的 `--patch`)。
1052
+ */
1053
+ /**
1054
+ * ⭐ 值班轮能看到哪些 MCP 工具(2026-10-17 审查里的 H3)
1055
+ * 起因:overlay 把整台 mcp_server.py 挂成 stdio server ⇒ 那一轮的模型手里握着**原生全套**工具
1056
+ * (bili_reply / bili_crawl / bili_send_opus / bili_upload_video / bili_login…)——
1057
+ * 宿主这边 autoComment / mentionName 两道闸只拦得住我们自己的 bili_reply 工具,拦不住它 ✗。
1058
+ * 做法:白名单写进 overlay 的 env(mcp-client 会把它并进子进程环境),
1059
+ * mcp_server.py 那头**不在白名单里的工具根本不注册** —— 连名字都不给它看见 ✓。
1060
+ * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
1061
+ */
1062
+ const mcpWhitelist = () => {
1063
+ const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
1064
+ if (cfg.autoComment || cfg.replyToComments) list.push('bili_reply') // 只有真要发评论才给这只
1065
+ // ⭐ 2026-10-19(issue #25 / #16 的实证,做法参考社区 PR #37):私信开着却忘了给 bili_send_message ⇒
1066
+ // 模型手里压根没有这只工具 ⇒ 表现成"开关也开了、这一轮也跑了,但从来没发出去过" ✗
1067
+ // ⇒ dmMode 不是 off、且填了收件人 UID 才放进去(关着就不给,白名单别松)
1068
+ if (String(cfg.dmMode || 'off') !== 'off' || cfg.dmWatch) list.push('bili_send_message') // 2026-10-21:UID 可能靠昵称查,别再拿 dmUid>0 当条件
1069
+ return list
1070
+ }
1071
+
1072
+ const ensureOverlay = () => {
1073
+ const file = join(stateDir, 'headless-overlay.yml')
1074
+ const script = join(stateDir, 'mcp_server.py')
1075
+ const posix = (p) => String(p).replace(/\\/g, '/')
1076
+ const yml = []
1077
+ // ⭐⭐ 2026-10-18(用户 `Elec-Tron-lc` 递来完整报告,逐条核过):
1078
+ // DSH **0.2.1-rc.2** 的 `@deepseek-ai/dsh-headless` 自带 patch 把三个服务 `disabled: true` 了 ——
1079
+ // `credentials` / `authorization` / `deepseek-account` ⇒ 那一轮既用不了账号模型、也读不到凭证库里的 key
1080
+ // (`resolveApiKey` 退到 env 也没有)⇒ `ACCOUNT_SIGN_IN_REQUIRED` / `NO_ADAPTER: buddy`。
1081
+ // ⭐ 我们的 overlay 是**最后**叠加的一层(bundle → profile → `--patch`)⇒ 在这里把它们打开,
1082
+ // 用户什么都不用配(0.2.0 时代没这三条禁用,所以以前不炸)。
1083
+ // ⚠️ 老版本没有这些 id 时 DSH 会把未知条目当无事发生,不会因此起不来。
1084
+ for (const id of ['credentials', 'authorization', 'deepseek-account']) {
1085
+ yml.push('- id: ' + id, ' disabled: false', '')
1086
+ }
1087
+ // ⭐⭐ 2026-10-20(社区 PR #50 指出,DSH 0.2.1-rc.2 桌面版实测):**光开那三个服务还不够** ——
1088
+ // 仍然会报 `MISSING_CREDENTIAL: llm-deepseek: no API key for provider route "deepseek-official"`,
1089
+ // 因为 headless 的 `agent-default-model` 默认解析到 `deepseek-official`(要 API key),
1090
+ // 而"账号登录"的机器没有 key;桌面版是靠 profile 那层 patch 把路由指到 deepseek-account 的,
1091
+ // **我们这一轮的一次性 overlay 没有那一层** ⇒ 还没发请求就死了。
1092
+ // ⇒ overlay 里补一条 agent-default-model,**优先跟随用户当前选的模型**;拿不到就不写(保持原行为)✓
1093
+ try {
1094
+ const sel = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1095
+ const selProvider = sel?.provider ?? sel?.providerId
1096
+ const selModel = sel?.model ?? sel?.modelId
1097
+ if (selProvider && selModel) {
1098
+ yml.push(
1099
+ '- id: agent-default-model',
1100
+ ' config:',
1101
+ ' provider: ' + String(selProvider),
1102
+ ' model: ' + String(selModel),
1103
+ ...(sel.reasoningEffort ? [' reasoningEffort: ' + String(sel.reasoningEffort)] : []),
1104
+ '',
1105
+ )
1106
+ }
1107
+ } catch { /* 老版本没有这个服务 —— 不写就退回原行为 */ }
1108
+ if (cfg.overlayMcp) {
1109
+ yml.push(
1110
+ '- insert:',
1111
+ ' - id: mcp-bili-duty',
1112
+ " name: '@deepseek-ai/dsh-mcp-client'",
1113
+ ' config:',
1114
+ ' serverName: bili-duty',
1115
+ ' transport: stdio',
1116
+ ` command: '${posix(cfg.pythonPath || 'python')}'`,
1117
+ ' args:',
1118
+ ` - '${posix(script)}'`,
1119
+ ' env:',
1120
+ ` BILI_MCP_TOOLS: '${mcpWhitelist().join(',')}'`,
1121
+ '',
1122
+ )
1123
+ }
1124
+ try { writeFileSync(file, yml.join('\n'), 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
1125
+ }
1126
+
1127
+ /**
1128
+ * ⭐ 评论发出后复核(2026-10-16 加的行 —— 起因是用户在评论区反馈
1129
+ * 「**回执成功不等于公开可见,新号会被压审**」):
1130
+ * 那一轮的 `bili_reply` 回"发送成功"只代表**接口收了**;新号 / 风控会让它进审核,
1131
+ * 那时候连 @ 通知都不会推 ⇒ 插件以为发了、下一轮又不再发 ⇒ **一条都没到人**。
1132
+ * 所以由**宿主**(有网络、有凭证、不受沙箱限制)去拉一次评论列表 / 楼中楼来核。
1133
+ * ⚠️ 措辞守则:看不到写「**没查到**」,**绝不写"没发出去"**(翻页/热度排序都会漏)。
1134
+ */
1135
+ let verifying = false
1136
+ const runVerify = async (why = 'manual') => {
1137
+ if (verifying) return { ok: false, error: '上一次复核还没跑完,等它完事' }
1138
+ verifying = true
1139
+ try {
1140
+ log(`评论复核开始(${why})`)
1141
+ const r = await verifyRecent(stateDir, { log })
1142
+ const f = appendVerifyToReport(stateDir, r.text)
1143
+ // ⭐ 2026-10-19:没有可核的记录时**别往 duty.log 里刷**(用户截图里看到同一句连了 4 行,
1144
+ // 那是每轮 90 秒后自动核一次 + 手动点,读起来像在报错 ✗)⇒ 只在有活干的时候记日志 ✓
1145
+ if (r.checked.length) log(`评论复核结束(${why}):查了 ${r.checked.length} 条${f ? `,已写进 ${f}` : ''}`)
1146
+ else log(`评论复核结束(${why}):没有可核的记录(不算错)`)
1147
+ return { ok: true, why, checked: r.checked, skipped: r.skipped, text: r.text, report: f || '' }
1148
+ } catch (e) {
1149
+ log(`评论复核失败(${why}):${e?.message ?? e}`)
1150
+ return { ok: false, why, error: String(e?.message ?? e) }
1151
+ } finally { verifying = false }
1152
+ }
1153
+
1154
+ /**
1155
+ * ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
1156
+ * 起因(2026-10-16 用户实测反馈):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
1157
+ * 最后 `out/` 是空的、判重表也没建 ⇒ **使用者完全看不到发生了什么**,也不知道白烧了钱 ✗。
1158
+ * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补 ✓),不覆盖已有的报告 ✓。
1159
+ */
1160
+ const stubReportIfMissing = (code) => {
1161
+ try {
1162
+ if (String(cfg.reportLevel || 'full') === 'off') return
1163
+ const f = join(outDir, `${dayKey()}.md`)
1164
+ if (existsSync(f)) return
1165
+ let tail = ''
1166
+ try { tail = readFileSync(logFile, 'utf8').slice(-1500) } catch { /* 读不到就算了 */ }
1167
+ writeFileSync(f, [
1168
+ `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
1169
+ '',
1170
+ `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
1171
+ '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
1172
+ '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
1173
+ '',
1174
+ '```',
1175
+ String(tail).trim(),
1176
+ '```',
1177
+ '',
1178
+ ].join('\n'), 'utf8')
1179
+ log('这一轮没写报告 ⇒ 宿主补了一份占位报告')
1180
+ } catch (e) { log(`补占位报告失败:${e?.message ?? e}`) }
1181
+ }
1182
+
1183
+ const runDuty = (why = 'window') => {
1184
+ try {
1185
+ refreshCfg() // ⭐ 设置页刚改过的值要立刻生效(config 是就地改的,快照会过期)
1186
+ const dsh = resolveDsh()
1187
+ // ⭐ 找不到 dsh 就别硬跑:以前会退化成裸 `dsh` ⇒ spawn 失败、只留 exit 1 + 一串乱码 ✗
1188
+ if (dsh.missing) {
1189
+ const msg = '这一轮没跑:找不到 dsh。' + dsh.hint
1190
+ log(msg)
1191
+ try { stubReportIfMissing(1) } catch { /* 补不上也不影响这句话已经进日志 */ }
1192
+ return { started: false, why, error: msg }
1193
+ }
1194
+ const overlay = ensureOverlay()
1195
+ const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1196
+ if (overlay) args.push('--patch', overlay)
1197
+ args.push(promptFor())
1198
+ // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
1199
+ // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
1200
+ // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
1201
+ // 它写不了家目录里的 out/ 报告、整轮卡在"我是被沙箱关着的"上面打转 ✗;
1202
+ // 现在改成"设定由插件读进来、内联进提示词" ✓,cwd 回到插件家 ✓)
1203
+ const cwd = stateDir
1204
+ log(`刷B站开跑(${why}):${dsh.command} ${args.slice(0, 3).join(' ')} … cwd=${cwd}`)
1205
+ const child = spawn(dsh.command, args, {
1206
+ cwd,
1207
+ shell: dsh.shell,
1208
+ windowsHide: true,
1209
+ stdio: ['ignore', 'pipe', 'pipe'],
1210
+ // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
1211
+ // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
1212
+ // ⭐ ELECTRON_RUN_AS_NODE:**桌面版(Electron)里 spawn 出去的 node 必须带这个**,
1213
+ // 否则它当成"再开一个 Electron 应用"、什么都不干就以 0 退出(issue #5:0.17 秒、code 0、什么都没做)
1214
+ env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
1215
+ })
1216
+ // ⚠️ 2026-10-16 issue #1(用户 `miaoxingyunduo` 贴了崩溃日志):`duty.log` 被别的进程占着时
1217
+ // (`EBUSY`) `appendFileSync` 会抛异常 —— 而这里是 **stream 的 data 回调**,没人接
1218
+ // ⇒ **未捕获异常 ⇒ 整个 DSH 宿主被杀**(所有会话一起没)。跟 10-14 定时器那次是同一类 ✗
1219
+ // ⇒ ⭐ 规矩:**插件里任何写日志/写文件的地方都包好 try/catch**,再错也只该"这一轮没跑成" ✓
1220
+ // ⚠️ 2026-10-19 用户贴来的 duty.log 里有 `'dsh' 不是内部或外部命令` 的乱码 ✗ ——
1221
+ // cmd.exe 自己吐的错误信息是 **GBK**,我们按 UTF-8 写进日志就成了方块 ⇒ 用户看不懂
1222
+ // ⇒ 先按 UTF-8 解;出现替换符(\uFFFD)就回退 GBK ✓
1223
+ const toText = (buf) => {
1224
+ try {
1225
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf))
1226
+ const u = b.toString('utf8')
1227
+ if (!u.includes('\uFFFD')) return u
1228
+ try { return new TextDecoder('gbk').decode(b) } catch { return u }
1229
+ } catch { return String(buf) }
1230
+ }
1231
+ const pick = (buf) => {
1232
+ try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 写不进去就丢,绝不能抛 */ }
1233
+ }
1234
+ child.stdout?.on('data', pick)
1235
+ child.stderr?.on('data', pick)
1236
+ child.on('exit', (code) => {
1237
+ log(`刷B站结束(${why},code=${code})`)
1238
+ stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
1239
+ // ⭐ 2026-10-20:这一轮跑成了、并且使用者选了收藏夹 ⇒ 把挑中的存进去(后台做,别拖住收尾)
1240
+ if (code === 0 && Number(cfg.favFolderId) > 0) {
1241
+ favToday('auto').catch((error) => log('自动收藏出错(已兜住):' + String(error?.message ?? error)))
1242
+ }
1243
+ rotateLogIfBig() // ⭐ 日志别无声地长(三天 797KB ⇒ 一年约 100MB)
1244
+ // ⭐ 这一轮挑了几条:从今天的报告里数(上一轮挑的还在同一份文件里 ⇒ 数出来就是全天的账)
1245
+ try {
1246
+ const st = readJson(dutyStateFile, {})
1247
+ const prev = st.day === dayKey() ? Number(st.picked || 0) : 0
1248
+ const picked = countPickedToday() // 报告是事实来源、state 只是兜底(跟闸门同一个算法)
1249
+ if (picked > prev) { bumpState({ picked }); log(`今天挑到 ${picked} 条(上限 ${Number(cfg.perDay) || 0} 条)`) }
1250
+ } catch (e) { log(`数挑了几条时出错(不影响这一轮):${e?.message ?? e}`) }
1251
+ // ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
1252
+ // 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
1253
+ // 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
1254
+ const verifyTimer = setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
1255
+ verifyTimer.unref?.() // 别为了一次复核把宿主进程的退出拖住 90 秒
1256
+ })
1257
+ child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
1258
+ return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
1259
+ } catch (e) {
1260
+ log(`刷B站开跑失败(${why}):${e?.stack ?? e}`)
1261
+ return { started: false, why, error: String(e?.message ?? e) }
1262
+ }
1263
+ }
1264
+
1265
+ /* ─────────────── 谷时定时器 ───────────────
1266
+ * 🔴 2026-10-14 事故(使用者那边表现为"DSH 突然崩了、网页 Failed to fetch (gateway/internal)"):
1267
+ * 第一版是 `setInterval(tick, 60_000)`,回调里抛的 TypeError **没人接**,直接把整个宿主进程带走
1268
+ * (`dsh: fatal uncaught exception`)。⇒ **定时器回调必须自己兜住所有异常**:
1269
+ * 插件再错也只该"这一轮没跑成",绝不该杀宿主。
1270
+ */
1271
+ const tick = () => {
1272
+ try {
1273
+ refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
1274
+ if (!cfg.peakValley) return
1275
+ const now = new Date()
1276
+ const st = readJson(dutyStateFile, {})
1277
+ const today = dayKey(now)
1278
+ // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
1279
+ if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
1280
+ // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
1281
+ if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
1282
+ for (const k of ['noon', 'night']) {
1283
+ if (!cfg[k]) continue
1284
+ if (!inWindow(now, WINDOWS[k])) continue
1285
+ const key = `${today}:${k}`
1286
+ if (st.windows?.[key]) continue
1287
+ bumpState({ windows: { ...(st.windows || {}), [key]: true }, count: roundsToday(now) + 1, lastRunAt: now.toISOString(), lastWhy: k })
1288
+ runDuty(`window:${k}`)
1289
+ return
1290
+ }
1291
+ } catch (e) {
1292
+ log(`定时器这一跳出错(已兜住,不影响宿主):${e?.stack ?? e}`)
1293
+ }
1294
+ }
1295
+
1296
+ try {
1297
+ const timer = setInterval(tick, 60_000)
1298
+ timer.unref?.() // 别因为这个定时器让进程不肯退出
1299
+ ctx.effect(() => () => clearInterval(timer), 'bili-duty: 谷时定时器')
1300
+ } catch (e) { log(`定时器没起来:${e?.message ?? e}`) }
1301
+
1302
+ // ⭐ 2026-10-21 私信双向(2.0 的主角):
1303
+ // 宿主每 N 分钟**纯代码**查一次私信(不花 token),只有"主人本人"发来新消息时,
1304
+ // 才 spawn 一轮很小的任务去回一句。默认关 —— 它一开就会花 token,得用户自己点头。
1305
+ const dmSeenFile = join(stateDir, 'dm-seen.json')
1306
+ const dmQuotaFile = join(stateDir, 'dm-quota.json')
1307
+ const readJsonFile = (f, dflt) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return dflt } }
1308
+ const writeJsonFile = (f, obj) => { try { writeFileSync(f, JSON.stringify(obj, null, 2), 'utf8') } catch { /* 写不进去就下次再说 */ } }
1309
+ const dmOwnerUid = () => {
1310
+ const direct = Number(cfg.dmOwnerUid) || Number(cfg.dmUid) || dmUidCache
1311
+ if (direct > 0) return direct
1312
+ // 2026-10-21:昵称之前查过(uid-cache.json)就直接用,不用等到"跑一轮"才有人查
1313
+ try {
1314
+ const cache = JSON.parse(readFileSync(join(stateDir, 'uid-cache.json'), 'utf8')) || {}
1315
+ const name = String(cfg.mentionName || '').trim()
1316
+ if (name && Number(cache[name]) > 0) return Number(cache[name])
1317
+ } catch { /* 没有缓存就算了 */ }
1318
+ return 0
1319
+ }
1320
+
1321
+ function dmPromptFor(fromUid, text) {
1322
+ const raw = String(text || '').slice(0, 600)
1323
+ const bv = (raw.match(/BV[0-9A-Za-z]{10}/) || [])[0] || ''
1324
+ return '这是 B站 私信里主人发来的一句话:' + JSON.stringify(raw)
1325
+ + (bv ? `\n里面有个视频 ${bv}:用 bili_subtitle 读一下它的字幕再回,说点具体的。` : '')
1326
+ + `\n用 bili_send_message 回他一句(uid=${Number(fromUid)}),100 字以内,像聊天。`
1327
+ }
1328
+
1329
+ function runDmReply(fromUid, text) {
1330
+ try {
1331
+ refreshCfg()
1332
+ const dsh = resolveDsh()
1333
+ if (dsh.missing) { log('私信回复没跑:找不到 dsh(' + dsh.hint + ')'); return { ok: false } }
1334
+ const overlay = ensureOverlay()
1335
+ const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1336
+ if (overlay) args.push('--patch', overlay)
1337
+ args.push(dmPromptFor(fromUid, text))
1338
+ log(`私信回复开跑:来自 uid=${fromUid}`)
1339
+ const child = spawn(dsh.command, args, {
1340
+ cwd: stateDir, shell: dsh.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
1341
+ env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
1342
+ })
1343
+ const toText = (buf) => { try { const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf)); const u = b.toString('utf8'); return u.includes('\uFFFD') ? (() => { try { return new TextDecoder('gbk').decode(b) } catch { return u } })() : u } catch { return String(buf) } }
1344
+ const pick = (buf) => { try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 丢日志也不能抛 */ } }
1345
+ child.stdout?.on('data', pick)
1346
+ child.stderr?.on('data', pick)
1347
+ child.on('exit', (code) => { log(`私信回复结束(code=${code})`); rotateLogIfBig() })
1348
+ return { ok: true }
1349
+ } catch (e) { log('私信回复出错:' + String(e?.message ?? e)); return { ok: false } }
1350
+ }
1351
+
1352
+ let dmTimer = null
1353
+ let dmBusy = false
1354
+ async function dmTick() {
1355
+ if (dmBusy) return
1356
+ dmBusy = true
1357
+ // ⭐ 每一跳都重新读一次配置:设置页里刚改的(开关 / 间隔 / 发给谁)下一分钟就生效,不用重启
1358
+ try { refreshCfg() } catch { /* 读不到就沿用旧值 */ }
1359
+ try {
1360
+ // ⭐ 2026-10-21:同一台机器上可能有两个宿主(桌面版 + web 版)都装着这个插件,
1361
+ // 两边都查私信 ⇒ 同一条消息可能被回两遍 ⇒ 用一个带 pid + 时间的锁,谁先跑谁干。
1362
+ {
1363
+ const lockFile = join(stateDir, 'dm-lock.json')
1364
+ let lock = null
1365
+ try { lock = JSON.parse(readFileSync(lockFile, 'utf8')) } catch { lock = null }
1366
+ const alive = (pid) => {
1367
+ if (!pid) return false
1368
+ try { process.kill(pid, 0); return true } catch { return false }
1369
+ }
1370
+ const fresh = lock && Number(lock.ts) > Date.now() - 3 * 60_000
1371
+ if (fresh && Number(lock.pid) !== process.pid && alive(Number(lock.pid))) {
1372
+ log('私信监听:另一个宿主正在跑(pid=' + lock.pid + ')⇒ 这一跳让给它')
1373
+ return
1374
+ }
1375
+ try { writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf8') } catch { /* 写不了就算了 */ }
1376
+ }
1377
+ if (!cfg.dmWatch) return
1378
+ const cred = dmCred(stateDir)
1379
+ if (!cred) return
1380
+ // 拿不到就先现查一次(之后走 uid-cache.json 的缓存,不再重复请求)
1381
+ let ownerTry = dmOwnerUid()
1382
+ if (!ownerTry && String(cfg.mentionName || '').trim()) ownerTry = await warmDmUid()
1383
+ if (ownerTry > 0) dmUidCache = ownerTry
1384
+ const owner = dmOwnerUid()
1385
+ if (!owner) { log('私信监听:还不知道回给谁(没填 UID / 没填「@ 谁」)⇒ 先监听不了'); return }
1386
+ const sess = await dmList(cred)
1387
+ const mine = sess.filter((s) => s.uid === owner && s.unread > 0)
1388
+ // ⭐ 只回主人本人:别人(陌生人)发来的一律不回 —— 这里明确记一行,免得以为它在偷听
1389
+ const others = sess.filter((s) => s.uid !== owner && s.unread > 0)
1390
+ // 陌生人未读:只报一次(同一批 key 不重复刷日志)
1391
+ if (others.length) {
1392
+ const sig = others.map((s) => s.uid + ':' + s.unread).sort().join(',')
1393
+ const seenOther = readJsonFile(join(stateDir, 'dm-others.json'), { sig: '' })
1394
+ if (seenOther.sig !== sig) {
1395
+ log('私信监听:有 ' + others.length + ' 个陌生人的未读私信,按规矩不回(只回主人本人)')
1396
+ writeJsonFile(join(stateDir, 'dm-others.json'), { sig })
1397
+ }
1398
+ }
1399
+ if (!mine.length) return
1400
+ const seen = readJsonFile(dmSeenFile, {})
1401
+ const quota = readJsonFile(dmQuotaFile, { day: '', n: 0 })
1402
+ const today = dayKey()
1403
+ if (quota.day !== today) { quota.day = today; quota.n = 0 }
1404
+ for (const s of mine) {
1405
+ const msgs = await dmFetch(cred, s.uid, 8)
1406
+ const last = [...msgs].reverse().find((m) => !m.fromMe)
1407
+ if (!last) continue
1408
+ if (seen[String(s.uid)] === last.key) continue
1409
+ seen[String(s.uid)] = last.key
1410
+ writeJsonFile(dmSeenFile, seen)
1411
+ if (Number(cfg.dmReplyPerDay) > 0 && quota.n >= Number(cfg.dmReplyPerDay)) { log('私信监听:今天的回复额度用完了(' + quota.n + ')⇒ 这条先不回'); continue }
1412
+ log(`私信监听:收到新消息 —— ${last.text.slice(0, 40)}`)
1413
+ runDmReply(s.uid, last.text)
1414
+ quota.n++
1415
+ writeJsonFile(dmQuotaFile, quota)
1416
+ }
1417
+ } catch (e) {
1418
+ log('私信监查出错(已兜住):' + String(e?.message ?? e))
1419
+ } finally { dmBusy = false }
1420
+ }
1421
+
1422
+ function startDmWatch() {
1423
+ try {
1424
+ if (dmTimer) { clearInterval(dmTimer); dmTimer = null }
1425
+ const mins = Math.max(1, Number(cfg.dmWatchMinutes) || 1)
1426
+ dmTimer = setInterval(() => { dmTick().catch((e) => log('私信定时器异常:' + String(e?.message ?? e))) }, mins * 60_000)
1427
+ if (dmTimer.unref) dmTimer.unref()
1428
+ log(`私信监听:${cfg.dmWatch ? '已开' : '关着'}(每 ${mins} 分钟查一次)`)
1429
+ } catch (e) { log('启私信监听失败:' + String(e?.message ?? e)) }
1430
+ }
1431
+
1432
+
1433
+
1434
+ try { MIG_200() } catch (e) { log('迁移没跑:' + String(e?.message ?? e)) }
1435
+
1436
+ try { startDmWatch() } catch (e) { log('私信监听没起来:' + String(e?.message ?? e)) }
1437
+ ctx.effect(() => () => { try { if (dmTimer) clearInterval(dmTimer) } catch { /* 忽略 */ } }, 'bili-duty: 私信监听')
1438
+
1439
+ /* ─────────────── HTTP(设置页用) ─────────────── */
1440
+
1441
+ const sendJson = (res, code, obj) => {
1442
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
1443
+ res.end(JSON.stringify(obj))
1444
+ }
1445
+ const readBody = (req) => new Promise((resolve) => {
1446
+ let data = ''
1447
+ let done = false
1448
+ // ⚠️ 超过 1MB 得**先把 Promise 收掉**再掐断:只 destroy 的话它永远不 settle,
1449
+ // 这个请求就挂在那儿(不回响应、也不释放)
1450
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
1451
+ req.on('data', (c) => { data += c; if (data.length > 1_000_000) { data = ''; finish({}); req.destroy() } })
1452
+ req.on('end', () => { try { finish(JSON.parse(data || '{}')) } catch { finish({}) } })
1453
+ req.on('error', () => finish({}))
1454
+ })
1455
+
1456
+ const venvPython = () => process.platform === 'win32'
1457
+ ? join(stateDir, '.venv', 'Scripts', 'python.exe')
1458
+ : join(stateDir, '.venv', 'bin', 'python')
1459
+
1460
+ /**
1461
+ * 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
1462
+ *
1463
+ * 🔴 为什么必须这样(2026-10-14 事故):第一版是 `async` 全程 await,
1464
+ * 而"建 venv + pip install"在使用者这条网络上要**几分钟** ⇒ 那个工具调用一直挂着,
1465
+ * **整个会话僵住、连"暂停"都卡,最后只能重启 dsh** ✗。
1466
+ * 现在的做法:spawn 出去、输出追加进 `install.log`,函数立刻返回;
1467
+ * 想知道装完没 —— 读日志,或看 `bili_duty_status` 里的 `installing`。
1468
+ * 依赖走**国内镜像**(使用者那条网络 pip 直连只有 ~24KB/s ✗;uv + 清华源是几十秒 ✓)。
1469
+ */
1470
+ let installing = false
1471
+ const installLogFile = join(stateDir, 'install.log')
1472
+ const appendInstall = (s) => { try { appendFileSync(installLogFile, s, 'utf8') } catch { /* 写不进去就算了 */ } }
1473
+
1474
+ let uvOk = null
1475
+ const haveUv = async () => {
1476
+ if (uvOk) return true
1477
+ const ok = await new Promise((resolve) => {
1478
+ try {
1479
+ const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
1480
+ c.on('exit', (code) => resolve(code === 0))
1481
+ c.on('error', () => resolve(false))
1482
+ } catch { resolve(false) }
1483
+ })
1484
+ // ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
1485
+ // 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
1486
+ if (ok) uvOk = true
1487
+ return ok
1488
+ }
1489
+
1490
+ /** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
1491
+ const probeCmd = (cmd) => new Promise((resolve) => {
1492
+ try {
1493
+ const c = spawn(cmd, ['--version'], { windowsHide: true, stdio: 'ignore' })
1494
+ c.on('exit', (code) => resolve(code === 0))
1495
+ c.on('error', () => resolve(false))
1496
+ } catch { resolve(false) }
1497
+ })
1498
+
1499
+ const spawnLogged = (cmd, args) => new Promise((resolve) => {
1500
+ appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
1501
+ let c
1502
+ try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: uvEnv() }) }
1503
+ catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
1504
+ const w = (b) => appendInstall(String(b))
1505
+ c.stdout?.on('data', w)
1506
+ c.stderr?.on('data', w)
1507
+ c.on('exit', (code) => { appendInstall(`\n--- exit ${code} ---\n`); resolve(code) })
1508
+ c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
1509
+ })
1510
+
1511
+ // ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
1512
+ const asrDir = join(stateDir, '.venv-asr')
1513
+ const asrLogFile = join(stateDir, 'asr-install.log')
1514
+ const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
1515
+ process.platform === 'win32' ? 'python.exe' : 'python')
1516
+ const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
1517
+ // ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
1518
+ // 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
1519
+ const asrTmp = join(stateDir, 'tmp', 'asr')
1520
+ try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
1521
+
1522
+ // ⭐ 判重表也由**宿主**预先建好(两个原因):
1523
+ // ① 那一轮被沙箱关着,"新建文件"会被拒(2026-10-14 那条规矩);
1524
+ // ② 它读不出判重表 ⇒ 以为"没记录过" ⇒ 同一批视频反复推送(用户实测反馈 2026-10-16)。
1525
+ const seenFile = join(stateDir, 'seen.md')
1526
+ try {
1527
+ if (!existsSync(seenFile)) {
1528
+ writeFileSync(seenFile, [
1529
+ '# 刷B站 · 判重表(宿主预建,那一轮只往后面追加)',
1530
+ '',
1531
+ '## 评论记录(BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字)',
1532
+ '',
1533
+ '| BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字 |',
1534
+ '| --- | --- | --- | --- | --- |',
1535
+ '',
1536
+ '## 看过的视频',
1537
+ '',
1538
+ ].join('\n'), 'utf8')
1539
+ }
1540
+ } catch { /* 建不了也别拦着插件加载 */ }
1541
+
1542
+ // ⭐ 归档目录:那一轮**被沙箱关着**,只写得了"启动前就已存在"的目录 ⇒ 宿主预建 ✓
1543
+ // (2026-10-16 主人要的"它看过什么"清单:报告是给人看的,归档是给检索用的 ✓)
1544
+ const digestDir = join(stateDir, 'digest')
1545
+ try { mkdirSync(digestDir, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
1546
+
1547
+ /* ─────────────── 自动收藏(宿主侧完成,模型拿不到这只手)─────────────── */
1548
+ /**
1549
+ * ⭐ 2026-10-20(思路来自社区 PR #63 / HebPRo,自己实现):把这一轮挑中的视频存进使用者自己的收藏夹。
1550
+ * 为什么放宿主而不给模型一只工具:① 收藏是"写动作",给模型等于多一只可能被误用的手;
1551
+ * ② 挑中哪几条宿主自己就能从报告里数出来(行首的「### BV号」),不必再花 token 问模型。
1552
+ */
1553
+ const favOps = (args) => new Promise((resolve) => {
1554
+ try {
1555
+ const py = String(cfg.pythonPath || 'python')
1556
+ const child = spawn(py, [join(stateDir, 'favops.py'), ...args], {
1557
+ cwd: stateDir, env: pyEnv(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
1558
+ })
1559
+ let outTxt = '', errTxt = ''
1560
+ const timer = setTimeout(() => { try { child.kill() } catch { /* 已退出 */ } resolve({ ok: false, error: 'favops 超时(30 秒)' }) }, 30_000)
1561
+ child.stdout?.on('data', (c) => { outTxt += c })
1562
+ child.stderr?.on('data', (c) => { errTxt += c })
1563
+ child.on('error', (error) => { clearTimeout(timer); resolve({ ok: false, error: String(error?.message ?? error) }) })
1564
+ child.on('exit', () => {
1565
+ clearTimeout(timer)
1566
+ try { resolve(JSON.parse(String(outTxt).trim().split('\n').pop() || '{}')) } catch { resolve({ ok: false, error: (errTxt || outTxt || 'favops 没有输出').slice(-300) }) }
1567
+ })
1568
+ } catch (error) { resolve({ ok: false, error: String(error?.message ?? error) }) }
1569
+ })
1570
+
1571
+ /** 从今天的报告里抠出"挑中的" BV 号(只认行首的 ### BV… 标题行;代码围栏里的不算) */
1572
+ const pickIdsFromReport = (file) => {
1573
+ try {
1574
+ const fence = new RegExp('```[\s\S]*?```', 'g')
1575
+ const text = readFileSync(file, 'utf8').replace(fence, '')
1576
+ return [...new Set([...text.matchAll(/^#{2,3}[ \t]*(BV[0-9A-Za-z]{10})/gm)].map((m) => m[1]))]
1577
+ } catch { return [] }
1578
+ }
1579
+
1580
+ let favBusy = false
1581
+ const favToday = async (why = 'manual') => {
1582
+ if (favBusy) return { ok: false, error: '上一次收藏还没跑完,等它完事' }
1583
+ favBusy = true
1584
+ try {
1585
+ refreshCfg()
1586
+ const fid = Math.max(0, Math.floor(Number(cfg.favFolderId) || 0))
1587
+ if (fid <= 0) return { ok: false, error: '还没选收藏夹(设置页「自动收藏到」选一个)' }
1588
+ if (!hasCredential()) return { ok: false, error: '还没扫码登录 B站' }
1589
+ const script = join(stateDir, 'favops.py')
1590
+ if (!existsSync(script)) return { ok: false, error: 'favops.py 不在家里(重启一次 dsh 会自动铺好)' }
1591
+ const report = join(outDir, `${dayKey()}.md`)
1592
+ const ids = pickIdsFromReport(report)
1593
+ if (!ids.length) return { ok: false, error: '今天的报告里没有挑中的视频(行首 ### BV号 那些)' }
1594
+ const failures = []
1595
+ let added = 0
1596
+ for (const bv of ids) {
1597
+ const r = await favOps(['add', bv, String(fid)])
1598
+ if (r && r.ok) added++
1599
+ else failures.push(bv + ': ' + ((r && r.error) || '未知原因'))
1600
+ }
1601
+ log(`自动收藏(${why}):${added}/${ids.length} 条` + (failures.length ? ',失败 ' + failures.length : ''))
1602
+ if (failures.length) log(' 收藏失败明细:' + failures.slice(0, 5).join(' | '))
1603
+ return { ok: true, added, total: ids.length, failures }
1604
+ } finally { favBusy = false }
1605
+ }
1606
+
1607
+ /**
1608
+ * ⭐ 日志轮转(2026-10-16 量出来的:`duty.log` 三天就 797 KB、没有轮转 ⇒ 一年约 100 MB ✗)
1609
+ * 超过 2MB 就滚成 `duty.log.1`(只留一份),别让它在 C 盘上无声地长。
1610
+ */
1611
+ /* ─────────────── 「它刷过的视频」:数据层 + 封面代理 ─────────────── */
1612
+ /** seen.md 的表格一行一条:`| 日期 | bvid | 标题 | UP | 状态 |`(跳过的也记,理由写在状态里) */
1613
+ /**
1614
+ * seen.md 的表格一行一条:`| 日期 | bvid | 标题 | UP | 状态 |`(跳过的也记,理由写在状态里)
1615
+ *
1616
+ * ⭐ 2026-10-22(一位用户的 DSH 找出来的,谢谢):原来的解析**太死**,三处会整行丢弃 ——
1617
+ * ① 日期只认 `YYYY-MM-DD`;实际记录常写成 `2026-10-22 18:51` ⇒ 每行都废 ✗
1618
+ * ② 位置写死(cells[1]=日期、cells[2]=BV);标题里带 `|`、或列数不对就错位 ✗
1619
+ * ③ 「发给你了」只看状态里有没有"评论/私信";状态写「收(值得看)」就不算 ✗
1620
+ * ⇒ 现在:**日期允许带时间**、**BV 用扫描定位**、**已投递再看评论记录表里的 rpid** ✓
1621
+ */
1622
+ /**
1623
+ * ⭐ 2026-10-22(用户 @俺爱啃猪蹄子 提的):`seen.md` 会越滚越大,而**每轮模型都要通读一遍** ⇒ 越跑越贵 ✗
1624
+ * ⇒ 30 天前的表格行挪进 `seen/YYYY-MM.md`(纯宿主代码、零 token),主表恒定为最近一个月 ✓
1625
+ */
1626
+ const rollSeen = () => {
1627
+ const file = join(stateDir, 'seen.md')
1628
+ try {
1629
+ if (!existsSync(file)) return 0
1630
+ const text = readFileSync(file, 'utf8')
1631
+ const rows = text.split(/\r?\n/)
1632
+ const cutoff = new Date(Date.now() - 30 * 86400_000)
1633
+ const pad = (n) => String(n).padStart(2, '0')
1634
+ const cutKey = cutoff.getFullYear() + '-' + pad(cutoff.getMonth() + 1) + '-' + pad(cutoff.getDate())
1635
+ const keep = []
1636
+ const moved = new Map() // 月份 → 行数组
1637
+ for (const raw of rows) {
1638
+ const line = raw
1639
+ const t = line.trim()
1640
+ if (t.startsWith('|')) {
1641
+ const cells = t.split('|').map((x) => x.trim()).slice(1, -1)
1642
+ const bvIdx = cells.findIndex((x) => /^BV[0-9A-Za-z]{10}$/.test(x))
1643
+ const dayRaw = bvIdx > 0 ? cells[bvIdx - 1] : cells[0]
1644
+ const dm = /^(\d{4}-\d{2}-\d{2})/.exec(dayRaw || '')
1645
+ if (bvIdx >= 0 && dm && dm[1] < cutKey) {
1646
+ const month = dm[1].slice(0, 7)
1647
+ if (!moved.has(month)) moved.set(month, [])
1648
+ moved.get(month).push(line)
1649
+ continue
1650
+ }
1651
+ }
1652
+ keep.push(line)
1653
+ }
1654
+ if (!moved.size) return 0
1655
+ const dir = join(stateDir, 'seen')
1656
+ mkdirSync(dir, { recursive: true })
1657
+ let n = 0
1658
+ for (const [month, arr] of moved) {
1659
+ const f = join(dir, month + '.md')
1660
+ const head = existsSync(f) ? '' : '# 刷B站的判重表归档 · ' + month + '\n\n(从 seen.md 滚过来的旧行,判重只看最近 30 天,这里只为留档)\n\n'
1661
+ appendFileSync(f, head + arr.join('\n') + '\n', 'utf8')
1662
+ n += arr.length
1663
+ }
1664
+ writeFileSync(file, keep.join('\n'), 'utf8')
1665
+ log('判重表归档:' + n + ' 行挪进 seen/(主表只剩最近 30 天)')
1666
+ return n
1667
+ } catch (e) { log('判重表归档失败(不影响使用):' + String(e?.message ?? e)); return 0 }
1668
+ }
1669
+ const parseSeenTable = () => {
1670
+ const file = join(stateDir, 'seen.md')
1671
+ let text = ''
1672
+ try { text = readFileSync(file, 'utf8') } catch { return [] }
1673
+ const out = []
1674
+ const seen = new Set()
1675
+ // 评论记录表里的 rpid(形如 `| BVxxxx | 317831597441 | ...`)⇒ 这些视频**确实发出去过**
1676
+ const deliveredBvs = new Set()
1677
+ for (const raw of text.split(/\r?\n/)) {
1678
+ const hit = /\|\s*(BV[0-9A-Za-z]{10})\s*\|\s*(\d{6,})\s*\|/.exec(raw)
1679
+ if (hit) deliveredBvs.add(hit[1])
1680
+ }
1681
+ for (const raw of text.split(/\r?\n/)) {
1682
+ const line = raw.trim()
1683
+ if (!line.startsWith('|')) continue
1684
+ const cells = line.split('|').map((s) => s.trim())
1685
+ const c = cells.slice(1, cells.length - 1)
1686
+ if (c.length < 3) continue
1687
+ // ⭐ BV 用扫描定位,不再假设在第 2 列(标题里带竖线也不会错位)
1688
+ const bvIdx = c.findIndex((x) => /^BV[0-9A-Za-z]{10}$/.test(x))
1689
+ if (bvIdx < 0) continue
1690
+ const bvid = c[bvIdx]
1691
+ const dayRaw = bvIdx > 0 ? c[bvIdx - 1] : c[0]
1692
+ // ⭐ 日期允许带时间:`2026-10-22` / `2026-10-22 18:51` / `2026-10-22T18:51`
1693
+ const dm = /^(\d{4}-\d{2}-\d{2})(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/.exec(dayRaw)
1694
+ if (!dm) continue
1695
+ const day = dm[1]
1696
+ const title = c[bvIdx + 1] || ''
1697
+ const up = c[bvIdx + 2] || ''
1698
+ const status = c[c.length - 1] || ''
1699
+ const key = bvid + '@' + day
1700
+ if (seen.has(key)) continue
1701
+ seen.add(key)
1702
+ const skipped = /跳过|跳了|没挑/.test(status)
1703
+ const picked = !skipped && /挑|收|选|值得看|可以看/.test(status)
1704
+ const commented = /评论/.test(status)
1705
+ const delivered = /评论|私信|已发|发给/.test(status) || deliveredBvs.has(bvid)
1706
+ out.push({ day, bvid, title, up, status, picked, commented, delivered, url: 'https://www.bilibili.com/video/' + bvid })
1707
+ }
1708
+ out.sort((a, b) => (a.day === b.day ? (a.picked === b.picked ? 0 : a.picked ? -1 : 1) : (a.day < b.day ? 1 : -1)))
1709
+ return out
1710
+ }
1711
+
1712
+ /** 封面代理:浏览器不能直连 i0.hdslb.com(防盗链 403 + 等于把用户 IP 交出去)⇒ 宿主取回来再转发 */
1713
+ const coverFor = async (bv) => {
1714
+ if (!/^BV[0-9A-Za-z]{10}$/.test(bv)) return null
1715
+ try {
1716
+ const r = await fetch('https://api.bilibili.com/x/web-interface/view?bvid=' + bv, {
1717
+ signal: AbortSignal.timeout(8000),
1718
+ headers: { 'user-agent': 'Mozilla/5.0', referer: 'https://www.bilibili.com/' },
1719
+ })
1720
+ if (!r.ok) return null
1721
+ const j = await r.json()
1722
+ const pic = String(j && j.data && j.data.pic || '')
1723
+ if (!pic) return null
1724
+ const img = await fetch(pic, {
1725
+ signal: AbortSignal.timeout(10000),
1726
+ headers: { 'user-agent': 'Mozilla/5.0', referer: 'https://www.bilibili.com/' },
1727
+ })
1728
+ if (!img.ok) return null
1729
+ const buf = Buffer.from(await img.arrayBuffer())
1730
+ const type = img.headers.get('content-type') || 'image/jpeg'
1731
+ return { buf, type }
1732
+ } catch (error) { log('取封面失败:' + String(error && error.message || error)); return null }
1733
+ }
1734
+
1735
+ const rotateLogIfBig = (limit = 2 * 1024 * 1024) => {
1736
+ try {
1737
+ if (!existsSync(logFile) || statSync(logFile).size < limit) return
1738
+ const bak = logFile + '.1'
1739
+ try { rmSync(bak, { force: true }) } catch { /* 删不掉就算了 */ }
1740
+ renameSync(logFile, bak)
1741
+ appendFileSync(logFile, `${new Date().toISOString()} 日志超过 2MB,旧的滚到 duty.log.1\n`, 'utf8')
1742
+ } catch { /* 轮转失败不该影响跑一轮 */ }
1743
+ }
1744
+ let installingAsr = false
1745
+
1746
+ /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
1747
+ const resolveUv = async () => {
1748
+ const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
1749
+ if (await haveUv()) return 'uv'
1750
+ if (existsSync(bundled)) return bundled
1751
+ appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
1752
+ const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
1753
+ if (got) appendInstall('uv 已就位:' + got + '\n')
1754
+ return got
1755
+ }
1756
+
1757
+ /** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
1758
+ const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
1759
+ sink(`\n$ ${cmd} ${args.join(' ')}\n`)
1760
+ let c
1761
+ try {
1762
+ c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
1763
+ env: uvEnv(env || {}) })
1764
+ } catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
1765
+ const w = (b) => sink(String(b))
1766
+ c.stdout?.on('data', w)
1767
+ c.stderr?.on('data', w)
1768
+ c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
1769
+ c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
1770
+ })
1771
+
1772
+ /** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
1773
+ const runAsrInstall = () => {
1774
+ if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
1775
+ installingAsr = true
1776
+ appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
1777
+ ;(async () => {
1778
+ try {
1779
+ const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1780
+ const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
1781
+ const py = asrPython()
1782
+ const uvCmd = await resolveUv()
1783
+ if (!existsSync(py)) {
1784
+ // --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
1785
+ if (uvCmd) {
1786
+ // ⭐ 2026-10-19(社区 PR #37 / issue #15):uv 不给 --python 时拿"最新 CPython",而 av / ctranslate2 / aiohttp
1787
+ // 在 3.14+ 没有 wheel ⇒ 转写依赖直接 ResolutionImpossible ✗ ⇒ 先钉 3.12,失败再退回默认
1788
+ const spec = String(process.env.BILI_DUTY_VENV_PYTHON || '3.12').trim()
1789
+ let vc = await spawnTo(uvCmd, ['venv', '--seed', '--python', spec, asrDir], { sink: appendAsr })
1790
+ if (vc !== 0) {
1791
+ appendAsr(`\n(uv 带 --python ${spec} 没成 —— 退回 uv 默认 Python,能不能装上看运气)\n`)
1792
+ vc = await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
1793
+ }
1794
+ }
1795
+ else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
1796
+ }
1797
+ if (!existsSync(py)) {
1798
+ // ⭐ 2026-10-19(用户 `天文星球` 的截图):uv 报 `Could not acquire lock … uv\cache\.lock 拒绝访问 (os error 5)`
1799
+ // 以前这句话只会说"先点上面的装依赖" ✗ —— 用户根本不知道该动哪儿 ✓
1800
+ // ⇒ 现在把两条真实出路写出来(0.1.11 起 uv 缓存已经改到插件家,正常不会再撞)
1801
+ appendAsr('建环境失败 —— 可能有两种原因:\n')
1802
+ appendAsr(' ① 上面日志里出现 "Could not acquire lock / uv\\cache\\.lock / 拒绝访问" ⇒ 是 uv 缓存目录没权限;\n')
1803
+ appendAsr(' 装新版(0.1.11+)会把缓存改到插件自己的家目录,就不会再碰 %LOCALAPPDATA%\\uv 了。\n')
1804
+ appendAsr(' ② 机器上没有可用的 uv 或 Python ⇒ 先点上面的「装依赖」,把主依赖装好再来。\n')
1805
+ log('转写:建环境失败'); return
1806
+ }
1807
+ appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
1808
+ let code = 1
1809
+ // ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
1810
+ // 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
1811
+ const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
1812
+ // ⭐ 2026-10-19(issue #27):以前只走一个镜像,撞上清华 403 就整轮失败 ✗
1813
+ // 改成跟主环境一样轮换三个镜像;每个镜像先 uv pip、失败再退回 python -m pip ✓
1814
+ const mirrors = [mirror, 'https://mirrors.ustc.edu.cn/pypi/simple', 'https://mirrors.aliyun.com/pypi/simple']
1815
+ const PKGS = ['faster-whisper', 'yt-dlp', AV_PIN]
1816
+ for (const m of mirrors) {
1817
+ if (code === 0) break
1818
+ appendAsr(`\n—— 镜像:${m}\n`)
1819
+ if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', m, ...PKGS], { sink: appendAsr })
1820
+ if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', m, ...PKGS], { sink: appendAsr })
1821
+ }
1822
+ if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
1823
+ // ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
1824
+ const model = String(cfg.asrModel || 'small')
1825
+ // 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
1826
+ const hfHome = join(stateDir, '.hf-cache')
1827
+ try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
1828
+ appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
1829
+ const code2 = await spawnTo(py, ['-c',
1830
+ '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("模型就绪")',
1831
+ model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
1832
+ appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
1833
+ log(`装转写依赖结束:exit=${code}/${code2}`)
1834
+ } catch (e) {
1835
+ appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
1836
+ log(`装转写依赖崩了:${e?.message ?? e}`)
1837
+ } finally { installingAsr = false }
1838
+ })()
1839
+ return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
1840
+ }
1841
+
1842
+ const runInstall = () => {
1843
+ if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
1844
+ installing = true
1845
+ appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
1846
+ ;(async () => {
1847
+ try {
1848
+ const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
1849
+ const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1850
+ const venvPy = venvPython()
1851
+ const py = String(cfg.pythonPath || 'python')
1852
+ const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
1853
+ if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
1854
+ const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
1855
+ + '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
1856
+ + '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
1857
+ appendInstall(msg + '\n'); log(msg); installing = false; return
1858
+ }
1859
+ // ⭐ 2026-10-22(群里 @俺爱啃猪蹄子 定位):钉 3.12 那句一旦失败会退回 uv 默认(可能是 3.14/3.15),
1860
+ // 而 venv 一旦建好就不再重建 ⇒ 每次装依赖都在 brotli/pyyaml 上编译失败,用户只能自己删目录 ✗
1861
+ // ⇒ 点「装主依赖」时先看现役环境是哪个版本,太高就改名换个新的(不删,留着回滚)✓
1862
+ try {
1863
+ const pyv = readFileSync(join(stateDir, '.venv', 'pyvenv.cfg'), 'utf8')
1864
+ const mv = /version\s*=\s*(\d+)\.(\d+)/.exec(pyv)
1865
+ if (mv && existsSync(venvPy) && Number(mv[2]) >= 14) {
1866
+ const tagV = mv[1] + '.' + mv[2]
1867
+ const bad = join(stateDir, '.venv.bad-' + tagV + '-' + Date.now())
1868
+ appendInstall('当前环境是 Python ' + tagV + '(太高,brotli / pyyaml 没有现成的包)⇒ 改名重建 3.12\n')
1869
+ try { renameSync(join(stateDir, '.venv'), bad) } catch (e2) { appendInstall('改名没成功:' + String(e2?.message ?? e2) + '\n') }
1870
+ }
1871
+ } catch { /* 没有 pyvenv.cfg 就算了 */ }
1872
+ if (!existsSync(venvPy)) {
1873
+ // ⭐ 2026-10-17(issue #8③):ASR 那个环境带了 --seed,主环境却漏了 ⇒ uv 建的 venv 默认不含 pip,
1874
+ // 一旦镜像失败、回退 `python -m pip` 就是 `No module named pip` ⇒ 连补救的路都没有 ✗
1875
+ if (uvCmd) {
1876
+ const spec = String(process.env.BILI_DUTY_VENV_PYTHON || '3.12').trim()
1877
+ let code = await spawnLogged(uvCmd, ['venv', '--seed', '--python', spec, join(stateDir, '.venv')])
1878
+ if (code !== 0) {
1879
+ appendInstall(`\n(uv 带 --python ${spec} 没成 —— 退回 uv 默认 Python)\n`)
1880
+ code = await spawnLogged(uvCmd, ['venv', '--seed', join(stateDir, '.venv')])
1881
+ }
1882
+ if (code !== 0) log('建 venv 没成功(两条路都试过)')
1883
+ }
1884
+ if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
1885
+ }
1886
+ let code = 1
1887
+ // ⭐ 2026-10-17(issue #8②):镜像也会整站拒绝(清华对部分 IP 回 403,而 pip 的报错写成"这个包不存在",很误导)
1888
+ // ⇒ 一个镜像失败就换下一个,别让用户在一个死镜像上反复撞
1889
+ const mirrors = [mirror, 'https://mirrors.ustc.edu.cn/pypi/simple', 'https://mirrors.aliyun.com/pypi/simple']
1890
+ for (let i = 0; i < mirrors.length && code !== 0; i++) {
1891
+ const m = mirrors[i]
1892
+ if (i > 0) appendInstall(`\n--- 换个镜像再试:${m} ---\n`)
1893
+ if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', m, '-r', reqFile])
1894
+ if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', m, '-r', reqFile])
1895
+ }
1896
+ if (code === 0 && existsSync(venvPy)) {
1897
+ // ⚠️ 光写 config.json 不够:DSH 原生设置里那份旧值("python")会在 loadCfg 时压回来
1898
+ // (已由 loadCfg 的 HOST_WINS_EXCLUDED 解决 ✓);这里再把宿主的同步结果说清楚,
1899
+ // 免得以后再出现"日志说设好了、实际没生效" ✗
1900
+ const r = await persistCfg({ pythonPath: venvPy })
1901
+ appendInstall(`已把 pythonPath 设为 ${venvPy}(DSH 那边${r?.host?.ok ? '已同步' : '没写成:' + (r?.host?.error || '未知原因')})\n`)
1902
+ }
1903
+ {
1904
+ const miss = missingVenvDeps()
1905
+ appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()}${miss.length ? ',缺:' + miss.join(' / ') : ''})===\n`)
1906
+ log(`装依赖结束:exit=${code} venvReady=${venvReadyNow()}${miss.length ? ' 缺:' + miss.join(',') : ''}`)
1907
+ }
1908
+ } catch (e) {
1909
+ appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
1910
+ log(`装依赖崩了:${e?.message ?? e}`)
1911
+ } finally { installing = false }
1912
+ })()
1913
+ return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
1914
+ }
1915
+
1916
+ /** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
1917
+ const checkUp = async () => {
1918
+ const out = { ok: false, steps: [] }
1919
+ if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
1920
+ if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
1921
+ try {
1922
+ const who = await call('bili_check_credential', {})
1923
+ out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
1924
+ out.steps.push('登录有效')
1925
+ } catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
1926
+ try {
1927
+ const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
1928
+ const t = typeof s === 'string' ? s : JSON.stringify(s)
1929
+ out.sample = t.slice(0, 160)
1930
+ out.steps.push('能搜到东西')
1931
+ out.ok = t.length > 2
1932
+ if (!out.ok) out.error = '搜到了空结果'
1933
+ } catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
1934
+ return out
1935
+ }
1936
+
1937
+ let updateCache = null // { at, latest } —— 检查更新的结果缓存
1938
+
1939
+ const status = () => {
1940
+ refreshCfg()
1941
+ const st = readJson(dutyStateFile, {})
1942
+ const today = dayKey()
1943
+ return {
1944
+ ok: true,
1945
+ home: stateDir,
1946
+ config: cfg,
1947
+ windows: WINDOWS,
1948
+ today,
1949
+ ranToday: st.day === today ? Number(st.count || 0) : 0, // 今天跑了几轮
1950
+ pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
1951
+ lastRunAt: st.lastRunAt || null,
1952
+ lastWhy: st.lastWhy || null,
1953
+ loggedIn: hasCredential(),
1954
+ venvReady: venvReadyNow(),
1955
+ version: myVersion(),
1956
+ // 2026-10-22:缺哪个 Python 包装不上,得能一眼看出来(出不了二维码 =09 缺 qrcode)
1957
+ missingDeps: (() => { try { return missingVenvDeps() } catch { return [] } })(),
1958
+ dmUidResolved: Number(cfg.dmUid) || dmUidCache || 0,
1959
+ // ⭐ 人设:老用户(只填过工作区)可以一键导入他那份 AGENTS.md
1960
+ personaFromWs: (() => {
1961
+ if (String(cfg.personaPrompt || '').trim()) return ''
1962
+ const ws = String(cfg.workspace || '').trim()
1963
+ if (!ws) return ''
1964
+ const f = join(ws, 'AGENTS.md')
1965
+ try { return existsSync(f) ? readFileSync(f, 'utf8').slice(0, 4000) : '' } catch { return '' }
1966
+ })(),
1967
+ // 2026-10-18:这几项"本地存了、但没能同步到 DSH 原生设置" ⇒ 设置页要告诉用户
1968
+ // (DSH 0.2.1 某些启动方式会拒写:profile reload requires the root Include entry)
1969
+ notSynced: Object.keys(readOverride()),
1970
+ // ⭐ 2026-10-19:把"这一轮会用哪个 dsh"显示出来 —— 桌面版排查全靠它(空字符串 = 没找到)
1971
+ dshCli: dshCliCached(),
1972
+ installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
1973
+ installing,
1974
+ // ── 本地转写那一套的状态(设置页要显示进度)──
1975
+ asrReady: asrReadyNow(),
1976
+ // 模型下没下(免得界面写"就绪"、其实还得现下)
1977
+ asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
1978
+ asrInstalling: installingAsr,
1979
+ asrLog: asrLogFile,
1980
+ asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1981
+ installLog: installLogFile,
1982
+ // ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
1983
+ installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1984
+ report: join(outDir, `${today}.md`),
1985
+ log: logFile,
1986
+ }
1987
+ }
1988
+
1989
+ const handle = async (req, res, url) => {
1990
+ refreshCfg()
1991
+ const path = url.pathname
1992
+ // ⭐ 2026-10-21:每条视频一份笔记 —— **宿主拆报告**,不让模型多写一个字(零 token、不会写错)
1993
+ const notesDir = join(stateDir, 'reports')
1994
+ let notesSig = ''
1995
+ function syncNotes() {
1996
+ try {
1997
+ const file = join(outDir, dayKey() + '.md')
1998
+ if (!existsSync(file)) return 0
1999
+ const mtime = statSync(file).mtimeMs
2000
+ const sig = file + '@' + mtime
2001
+ if (sig === notesSig) return 0
2002
+ const text = readFileSync(file, 'utf8')
2003
+ const blocks = text.split(/\n(?=###\s*BV)/).filter((b) => /^###\s*BV/.test(b.trim()))
2004
+ if (!blocks.length) { notesSig = sig; return 0 }
2005
+ mkdirSync(notesDir, { recursive: true })
2006
+ let n = 0
2007
+ for (const b of blocks) {
2008
+ const m = /^###\s*(BV[0-9A-Za-z]+)/.exec(b.trim())
2009
+ if (!m) continue
2010
+ const bvid = m[1]
2011
+ const title = (/\*\*标题\*\*\s*[::]\s*(.+)/.exec(b) || [])[1] || bvid
2012
+ const day = dayKey()
2013
+ const out = join(notesDir, bvid + '-' + day + '.md')
2014
+ if (existsSync(out)) continue
2015
+ const head = '# ' + title + '\n\n'
2016
+ + '> 来自「刷B站」' + day + ' 的那一轮 · ' + bvid
2017
+ + ' · https://www.bilibili.com/video/' + bvid + '\n'
2018
+ + '> 这一份是**单条笔记**(整天的日报在 ' + file + ')\n\n---\n\n'
2019
+ writeFileSync(out, head + b.replace(/^###\s*BV[0-9A-Za-z]+/, '').trim() + '\n', 'utf8')
2020
+ n++
2021
+ }
2022
+ notesSig = sig
2023
+ if (n > 0) log('单条笔记:新写了 ' + n + ' 份到 ' + notesDir)
2024
+ return n
2025
+ } catch (e) { log('拆单条笔记失败:' + String(e?.message ?? e)); return 0 }
2026
+ }
2027
+
2028
+
2029
+ if (req.method === 'GET' && path === '/api/bili-duty/status') {
2030
+ try { rollSeen() } catch (e) { /* 归档失败不影响状态接口 */ }
2031
+ try { syncNotes() } catch (e) { /* 笔记失败不影响状态接口 */ }
2032
+ return sendJson(res, 200, status())
2033
+ }
2034
+ if (req.method === 'GET' && path === '/api/bili-duty/config') {
2035
+ return sendJson(res, 200, { ok: true, config: cfg, windows: WINDOWS, home: stateDir })
2036
+ }
2037
+
2038
+ if (req.method === 'POST' && path === '/api/bili-duty/config') {
2039
+ const body = await readBody(req)
2040
+ const patch = {}
2041
+ for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
2042
+ 'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
2043
+ 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound', 'personaPrompt', 'dmWatch', 'dmWatchMinutes', 'dmReplyPerDay', 'dmOwnerUid']) {
2044
+ if (k in body) patch[k] = body[k]
2045
+ }
2046
+ // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
2047
+ // ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
2048
+ if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
2049
+ if ('dmUid' in patch) patch.dmUid = Math.max(0, Math.min(Number(patch.dmUid) || 0, Number.MAX_SAFE_INTEGER))
2050
+ const { clean, host } = await persistCfg(patch)
2051
+ log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
2052
+ return sendJson(res, 200, { ok: true, config: cfg, host })
2053
+ }
2054
+
2055
+ if (req.method === 'POST' && path === '/api/bili-duty/run') {
2056
+ if (cfg.dmMode !== 'off' && !(Number(cfg.dmUid) > 0)) await warmDmUid()
2057
+ return sendJson(res, 200, { ok: true, ...runDuty('manual') })
2058
+ }
2059
+
2060
+ // 建 venv + 装依赖(**不等它**:几分钟的活,进度写 install.log)
2061
+ if (req.method === 'POST' && path === '/api/bili-duty/install') {
2062
+ return sendJson(res, 200, runInstall())
2063
+ }
2064
+
2065
+ // 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
2066
+ if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
2067
+ return sendJson(res, 200, runAsrInstall())
2068
+
2069
+ }
2070
+
2071
+ // 收藏夹列表(设置页下拉用)
2072
+ if (req.method === 'GET' && path === '/api/bili-duty/fav-folders') {
2073
+ const r = await favOps(['folders'])
2074
+ return sendJson(res, 200, r && r.ok ? { ok: true, folders: r.folders || [] } : { ok: false, error: (r && r.error) || '读不到收藏夹' })
2075
+ }
2076
+ // 补收藏今天挑中的(设置页按钮)
2077
+ if (req.method === 'POST' && path === '/api/bili-duty/fav-today') return sendJson(res, 200, await favToday('manual'))
2078
+
2079
+ // 「它刷过的视频」:从 seen.md 读出来(数据按天倒序,跳过的也在,理由在 status 里)
2080
+ if (req.method === 'GET' && path === '/api/bili-duty/watched') {
2081
+ try { syncNotes() } catch (e) { /* 同上 */ }
2082
+ const all = parseSeenTable()
2083
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit')) || 60))
2084
+ const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0)
2085
+ const meta = reportMeta()
2086
+ const notes = (() => { try { return new Set(readdirSync(notesDir).map((f) => f.split('-')[0])) } catch { return new Set() } })()
2087
+ const allRows = all.map((x) => { const m = meta.get(x.bvid) || {}; return { ...x, tier: m.tier || '', why: m.why || '', note: notes.has(x.bvid) } })
2088
+ const onlyPicked = url.searchParams.get('picked') === '1'
2089
+ const tier = String(url.searchParams.get('tier') || '')
2090
+ const state = String(url.searchParams.get('state') || '') // picked | skipped | commented
2091
+ let rows = onlyPicked ? allRows.filter((x) => x.picked) : allRows
2092
+ if (tier) rows = rows.filter((x) => x.tier === tier)
2093
+ if (state === 'picked') rows = rows.filter((x) => x.picked)
2094
+ if (state === 'skipped') rows = rows.filter((x) => !x.picked)
2095
+ if (state === 'commented') rows = rows.filter((x) => x.delivered || x.commented)
2096
+ return sendJson(res, 200, { ok: true, total: rows.length, offset, limit, rows: rows.slice(offset, offset + limit) })
2097
+ }
2098
+ // 封面(宿主代理),浏览器只跟本机说话
2099
+ if (req.method === 'GET' && path === '/api/bili-duty/cover') {
2100
+ const got = await coverFor(String(url.searchParams.get('bv') || ''))
2101
+ if (!got) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('no cover'); return }
2102
+ res.writeHead(200, { 'content-type': got.type, 'cache-control': 'max-age=86400' })
2103
+ res.end(got.buf)
2104
+ return
2105
+ }
2106
+
2107
+ // ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
2108
+ // (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
2109
+ if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
2110
+
2111
+ // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
2112
+ if (req.method === 'GET' && path === '/api/bili-duty/report') {
2113
+ const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
2114
+ const file = join(outDir, day + '.md')
2115
+ let text = ''
2116
+ let exists = false
2117
+ try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
2118
+ return sendJson(res, 200, { ok: true, day, exists, file, text })
2119
+ }
2120
+
2121
+ // 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
2122
+ if (req.method === 'POST' && path === '/api/bili-duty/open') {
2123
+ const body = await readBody(req)
2124
+ const what = String(body?.what || 'out')
2125
+ // ⭐ 2026-10-21:**打开一个网页**(桌面版里 <a target=_blank> 不一定交给系统浏览器 ⇒ 改成让宿主 shell 打开)
2126
+ if (what === 'url') {
2127
+ const u = String(body?.u || '')
2128
+ const onlyHttp = /^https?:\/\//i.test(u)
2129
+ if (!onlyHttp) return sendJson(res, 200, { ok: false, error: '只接受 http(s) 链接' })
2130
+ if (process.platform !== 'win32') {
2131
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
2132
+ spawn(opener, [u], { detached: true, stdio: 'ignore' }).on('error', () => {})
2133
+ return sendJson(res, 200, { ok: true })
2134
+ }
2135
+ spawn('cmd.exe', ['/c', 'start', '', u], { detached: true, stdio: 'ignore' }).on('error', () => {})
2136
+ log('打开网页:' + u)
2137
+ return sendJson(res, 200, { ok: true })
2138
+ }
2139
+ const dir = what === 'home' ? stateDir : outDir
2140
+ if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
2141
+ // ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
2142
+ // try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
2143
+ // ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
2144
+ // 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
2145
+ const attempts = [
2146
+ ['cmd.exe', ['/c', 'start', '', dir]],
2147
+ [join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
2148
+ ['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
2149
+ ]
2150
+ let lastErr = ''
2151
+ for (const [cmd, args] of attempts) {
2152
+ const err = await new Promise((resolve) => {
2153
+ let done = false
2154
+ let child
2155
+ try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
2156
+ child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
2157
+ setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
2158
+ })
2159
+ if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
2160
+ lastErr = err
2161
+ log('打开文件夹失败(' + cmd + '):' + err)
2162
+ }
2163
+ return sendJson(res, 200, { ok: false, error: lastErr, dir })
2164
+ }
2165
+
2166
+ // 「一键升级」:点了它自己跑 add(⚠️ 之后要重启才生效)
2167
+ if (req.method === 'POST' && path === '/api/bili-duty/upgrade') return sendJson(res, 200, runUpgrade())
2168
+
2169
+ // 「检查更新」:⚠️ 只在用户点按钮时才联网(不偷偷发请求 ✓),结果缓存一天 ✓
2170
+ // ⭐ 2026-10-19(主人:"现在自动更新能让扫 github 吗"):**两条源都问** ——
2171
+ // npm 上可能还没发上去(账号被暂停时会停在旧版),那时候 GitHub 才是最新 ✓
2172
+ // ⇒ 谁给的版本号更新就用谁,并记住来源:一键升级按来源选 `add dsh-bili-scroll` / `add github:…` ✓
2173
+ if (req.method === 'GET' && path === '/api/bili-duty/update') {
2174
+ const out = { ok: true, current: myVersion(), latest: '', hasNew: false, source: 'npm', page: 'https://www.npmjs.com/package/dsh-bili-scroll' }
2175
+ // ⭐ 要比谁更新,不能只比不相等 —— 否则本地版本高于 npm 时会误报有新版
2176
+ // ⭐ 2026-10-20:预发布标签要单独看 —— 以前只比三段数字 ⇒ `0.2.0-rc.1` 被当成 [0,2,0],
2177
+ // 跟正式版打平、却比 0.1.18 更大 ⇒ 用户会被提示"升级到 rc 版" ✗;现在数字段相同时**正式版 > 预发布版**
2178
+ const vnum = (v) => String(v || '').trim().replace(/^v/i, '').split('-')[0].split('.').map((x) => parseInt(x, 10) || 0)
2179
+ const isPre = (v) => /-/.test(String(v || '').trim())
2180
+ const cmp = (a, b) => {
2181
+ const x = vnum(a), y = vnum(b)
2182
+ for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) }
2183
+ const pa = isPre(a), pb = isPre(b)
2184
+ if (pa === pb) return 0
2185
+ return pa ? -1 : 1 // 有预发布标签的更旧
2186
+ }
2187
+ const fresh = (c) => Boolean(c) && Date.now() - c.at < 24 * 3600 * 1000
2188
+ // ① npm(不依赖加速器,通常最快)
2189
+ let npmVer = ''
2190
+ try {
2191
+ if (fresh(updateCache)) npmVer = updateCache.latest
2192
+ else {
2193
+ const r = await fetch('https://registry.npmjs.org/dsh-bili-scroll/latest', { signal: AbortSignal.timeout(8000) })
2194
+ const j = await r.json()
2195
+ npmVer = String(j.version || '')
2196
+ updateCache = { at: Date.now(), latest: npmVer }
2197
+ }
2198
+ } catch (e) { log('查 npm 版本没成功(接着查 GitHub):' + (e?.message ?? e)) }
2199
+ // ② GitHub(要加速器;不通就安静跳过,别打扰用户)
2200
+ let ghVer = ''
2201
+ try {
2202
+ if (fresh(ghUpdateCache)) ghVer = ghUpdateCache.latest
2203
+ else {
2204
+ const r = await fetch('https://api.github.com/repos/c1overzz/dsh-bili-scroll/contents/package.json', {
2205
+ signal: AbortSignal.timeout(6000),
2206
+ headers: { 'user-agent': 'dsh-bili-scroll', accept: 'application/vnd.github+json' },
2207
+ })
2208
+ if (r.ok) {
2209
+ const j = await r.json()
2210
+ const txt = Buffer.from(String(j.content || ''), 'base64').toString('utf8')
2211
+ ghVer = String(JSON.parse(txt).version || '')
2212
+ ghUpdateCache = { at: Date.now(), latest: ghVer }
2213
+ }
2214
+ }
2215
+ } catch { /* 没有加速器时这里基本都失败 —— 静默,别打扰 */ }
2216
+ // 谁新用谁;一样新时优先 npm(装起来快)
2217
+ if (ghVer && (!npmVer || cmp(ghVer, npmVer) > 0)) { out.latest = ghVer; out.source = 'github' }
2218
+ else out.latest = npmVer || ghVer
2219
+ if (out.source === 'github') out.page = 'https://github.com/c1overzz/dsh-bili-scroll'
2220
+ out.hasNew = Boolean(out.latest) && cmp(out.latest, out.current) > 0
2221
+ lastUpdateSource = out.source
2222
+ if (!out.latest) { out.ok = false; out.error = '两条源都没查到版本号(网络可能不通)' }
2223
+ return sendJson(res, 200, out)
2224
+ }
2225
+
2226
+ // 「测一下」:真跑一次,回答"能不能用"
2227
+ // ⭐ 2026-10-21:把"要日志"变成"点一下" —— 诊断信息(纯文本,可整段复制)
2228
+ function diagnoseText() {
2229
+ const tail = (() => {
2230
+ try { const p = join(stateDir, 'duty.log'); return existsSync(p) ? readFileSync(p, 'utf8').slice(-6000) : '(没有 duty.log)' }
2231
+ catch (e) { return '(日志读不到:' + String(e?.message ?? e) + ')' }
2232
+ })()
2233
+ const free = (() => { try { const s = statSync ? null : null; return '' } catch { return '' } })()
2234
+ const masked = (() => {
2235
+ const c = { ...cfg }
2236
+ for (const k of ['dmUid']) c[k] = c[k] ? '(已填)' : ''
2237
+ return JSON.stringify(c, null, 2)
2238
+ })()
2239
+ return [
2240
+ '=== 刷B站 诊断信息 ===',
2241
+ '版本:' + myVersion(),
2242
+ '平台:' + process.platform + ' ' + process.arch + ' · node ' + process.versions.node + (process.versions.electron ? ' · electron ' + process.versions.electron : ''),
2243
+ 'dsh:' + (() => { try { const d = resolveDsh(); return d.missing ? '没找到(' + d.hint + ')' : d.command + ' ' + d.args.join(' ') } catch (e) { return '探测失败:' + e?.message } })(),
2244
+ 'headless profile:' + (cfg.headlessProfile || 'headless'),
2245
+ 'pythonPath:' + cfg.pythonPath,
2246
+ 'venv 就绪:' + (venvReadyNow() ? '是' : '否') + (missingVenvDeps().length ? '(缺:' + missingVenvDeps().join(' / ') + ')' : ''),
2247
+ '转写环境:' + (existsSync(join(stateDir, '.venv-asr')) ? '在' : '没装'),
2248
+ 'B站 登录:' + (hasCredential() ? '有凭证' : '没有凭证'),
2249
+ '投递方式:' + (cfg.dmMode || 'off') + (Number(cfg.dmUid) > 0 ? '(UID 已填 ' + cfg.dmUid + ')' : '(UID 没填,会用昵称查)'),
2250
+ '自动评论:' + (cfg.autoComment ? '开' : '关') + ' · 回复评论:' + (cfg.replyToComments ? '开' : '关'),
2251
+ '家目录:' + stateDir,
2252
+ '今天的报告:' + join(outDir, dayKey() + '.md'),
2253
+ '',
2254
+ '--- 配置(已脱敏)---',
2255
+ masked,
2256
+ '',
2257
+ '--- duty.log 末尾 ---',
2258
+ tail,
2259
+ ].join('\n')
2260
+ }
2261
+
2262
+ // ⭐ 环境自检:一条一条给结论 + 怎么办
2263
+ async function selfCheck() {
2264
+ const items = []
2265
+ const put = (name, ok, detail, fix) => items.push({ name, ok, detail: detail || '', fix: ok ? '' : (fix || '') })
2266
+ // ① dsh
2267
+ try { const d = resolveDsh(); put('能找到 dsh(跑一轮要用)', !d.missing, d.missing ? '没找到:' + d.hint : (d.command + ' ' + (d.args || []).join(' ')), '在设置页「dsh 路径」里填上 …/@deepseek-ai/dsh/lib/bin.js') }
2268
+ catch (e) { put('能找到 dsh(跑一轮要用)', false, String(e?.message ?? e), '同上') }
2269
+ // ② Python
2270
+ put('Python 主依赖装好了', venvReadyNow(), missingVenvDeps().length ? '缺:' + missingVenvDeps().join(' / ') : '就绪', '点「重装主依赖」,等它跑完再看这里')
2271
+ // ③ 凭证
2272
+ put('B站 已登录', hasCredential(), hasCredential() ? '家目录里有凭证文件' : '还没有凭证', '点「扫码登录」,手机确认时选「记住我 / 长期」')
2273
+ // ④ 家目录可写
2274
+ let writable = true
2275
+ try { const t = join(stateDir, '.diag-write-test'); writeFileSync(t, 'x'); rmSync(t) } catch { writable = false }
2276
+ put('家目录可写(报告要写在这儿)', writable, writable ? stateDir : '写不进去', '检查杀软是否拦了这个目录,或把家目录换到别的盘')
2277
+ // ⑤ 能不能连上 B站
2278
+ let reach = false
2279
+ let reachDetail = ''
2280
+ try {
2281
+ const r = await fetch('https://api.bilibili.com/x/web-interface/nav', { headers: { 'User-Agent': 'Mozilla/5.0' } })
2282
+ reach = r.ok
2283
+ reachDetail = 'HTTP ' + r.status
2284
+ } catch (e) { reachDetail = String(e?.message ?? e) }
2285
+ put('能连上 B站', reach, reachDetail, '检查网络 / 加速器;公司或校园网可能拦了')
2286
+ // ⑥ 私信对象
2287
+ const dmOk = cfg.dmMode === 'off'
2288
+ ? true
2289
+ : (Number(cfg.dmUid) > 0 || String(cfg.mentionName || '').trim() !== '' || dmUidCache > 0)
2290
+ put('知道私信发给谁(或没开私信)', dmOk,
2291
+ cfg.dmMode === 'off' ? '当前是「只发评论」' : (Number(cfg.dmUid) > 0 ? 'UID ' + cfg.dmUid : (dmUidCache > 0 ? '按昵称查到 ' + dmUidCache : '还没查到')),
2292
+ '填「私信发给谁(UID)」,或填「要 @ 谁」的昵称让它自己查')
2293
+ return { ok: items.every((x) => x.ok), items }
2294
+ }
2295
+
2296
+ if (req.method === 'POST' && path === '/api/bili-duty/check') {
2297
+ return sendJson(res, 200, await checkUp())
2298
+ }
2299
+
2300
+ // 「复制诊断信息」
2301
+ if (req.method === 'GET' && path === '/api/bili-duty/diagnose') {
2302
+ return sendJson(res, 200, { ok: true, text: diagnoseText() })
2303
+ }
2304
+
2305
+ // 「环境自检」
2306
+ if (req.method === 'GET' && path === '/api/bili-duty/selfcheck') {
2307
+ return sendJson(res, 200, await selfCheck())
2308
+ }
2309
+
2310
+ // ── 扫码登录(设置页用)──
2311
+ if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
2312
+ const { key, url } = await loginStart()
2313
+ const png = await renderQrPng(cfg.pythonPath, stateDir, url)
2314
+ let qr = null
2315
+ if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
2316
+ log(`出码:${url}${qr ? '(已画成 PNG)' : '(没画出 PNG —— venv 里大概还没有 qrcode)'}`)
2317
+ return sendJson(res, 200, { ok: true, key, url, qr })
2318
+ }
2319
+
2320
+ if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
2321
+ const body = await readBody(req)
2322
+ const r = await loginPoll(String(body.key || ''))
2323
+ if (r.state === 'ok') {
2324
+ const file = saveCredential(stateDir, r.credential)
2325
+ log(`登录成功,凭证已写入 ${file}`)
2326
+ return sendJson(res, 200, { ok: true, state: 'ok', file })
2327
+ }
2328
+ return sendJson(res, 200, { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message })
2329
+ }
2330
+
2331
+ return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
2332
+ }
2333
+
2334
+ try {
2335
+ ctx.effect(() => ctx.webServer.register({
2336
+ kind: 'prefix',
2337
+ path: '/api/bili-duty',
2338
+ handler: async (req, res) => {
2339
+ try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
2340
+ sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
2341
+ }
2342
+ },
2343
+ }), 'bili-duty: /api/bili-duty 路由')
2344
+ } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
2345
+
2346
+ ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
2347
+ log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
2348
+ }