dsh-bili-scroll 0.1.5 → 0.1.7

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,1076 +1,1347 @@
1
- /**
2
- * dsh-bili-duty —— 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, 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, 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-duty`)+ **本模块导出 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
- /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人) */
61
- commentPerRound: z.natural().default(1).extra('volatile', true),
62
- /** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
63
- mentionName: z.string().default('').extra('volatile', true),
64
- /** 关注关键词(空了用章程里默认那几组) */
65
- keywords: z.array(z.string()).default([]).extra('volatile', true),
66
- /** Python:指向 venv 里的那个最好 */
67
- pythonPath: z.string().default('python').extra('volatile', true),
68
- /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
69
- dshBin: z.string().default('').extra('volatile', true),
70
- /** headless 那一轮用哪个 profile */
71
- headlessProfile: z.string().default('headless').extra('volatile', true),
72
- /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
73
- overlayMcp: z.boolean().default(true).extra('volatile', true),
74
- /** 跑 headless 时的工作目录 */
75
- workspace: z.string().default('').extra('volatile', true),
76
- /** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
77
- reportLevel: z.string().default('full').extra('volatile', true),
78
- /** 回复评论:默认关。开了之后只回「直接回复到它那条评论下面的人」 */
79
- replyToComments: z.boolean().default(false).extra('volatile', true),
80
- /** 每轮最多回几条 */
81
- replyPerRound: z.number().default(1).extra('volatile', true),
82
- })
83
-
84
- /**
85
- * ⭐ 给所有 Python 子进程统一 UTF-8。
86
- * 起因(2026-10-16,用户在评论区反馈):Windows 上 Python 默认按 GBK 编解码,
87
- * 我们的脚本 / 内置 MCP 都会往管道里打中文与 emoji(`✅`、`—` 之类),一进管道就可能
88
- * 报 UnicodeDecodeError / 乱码或直接中断。**编码这种环境差异,插件该自己钉死,不该让用户碰。**
89
- */
90
- const pyEnv = (extra = {}) => ({
91
- ...process.env,
92
- PYTHONUTF8: '1',
93
- PYTHONIOENCODING: 'utf-8',
94
- ...extra,
95
- })
96
-
97
- export function apply(ctx, config) {
98
- const pluginDir = dirname(fileURLToPath(import.meta.url))
99
- // ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
100
- let myVer = ''
101
- const myVersion = () => {
102
- if (myVer) return myVer
103
- try { myVer = String(JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).version || '') } catch { myVer = '' }
104
- return myVer
105
- }
106
- const stateDir = stateDirOf(ctx)
107
-
108
- // ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
109
- // (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
110
- const sitePkgs = (venvRoot) => {
111
- const win = join(venvRoot, 'Lib', 'site-packages')
112
- if (existsSync(win)) return win
113
- try {
114
- const lib = join(venvRoot, 'lib')
115
- for (const d of readdirSync(lib)) {
116
- if (!d.startsWith('python')) continue
117
- const p = join(lib, d, 'site-packages')
118
- if (existsSync(p)) return p
119
- }
120
- } catch { /* 没有就算了 */ }
121
- return win
122
- }
123
- const hasPkg = (venvRoot, prefix) => {
124
- try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
125
- }
126
- const venvReadyNow = () => existsSync(venvPython()) && hasPkg(join(stateDir, '.venv'), 'bilibili_api')
127
- const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
128
- const cfgFile = join(stateDir, 'config.json')
129
- const logFile = join(stateDir, 'duty.log')
130
- const outDir = join(stateDir, 'out')
131
- mkdirSync(outDir, { recursive: true })
132
-
133
- const log = (msg) => {
134
- const line = `${new Date().toISOString()} ${msg}`
135
- try { appendFileSync(logFile, line + '\n', 'utf8') } catch { /* 日志写不进去也不致命 */ }
136
- try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
137
- }
138
-
139
- // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
140
- for (const n of hostDeps.notes) log(n)
141
-
142
- /**
143
- * ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
144
- * `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
145
- * 是个**对象**、`config.keywords` 也不是数组(日志里打成 `[object Object]`)。对象是"真值",
146
- * 于是 `if (!cfg.peakValley) return` 这类守卫全部失效,一路走到 `cfg.keywords.filter(...)`
147
- * 抛 TypeError —— 而那是在**定时器回调**里,没人接 ⇒ **整个 dsh 进程被带走** ✗✗。
148
- * ⇒ 两条规矩:
149
- * ① 逐字段按默认值的类型挑一遍(拿不准就用默认值),再叠自己的 `config.json`;
150
- * ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
151
- */
152
- const DEFAULTS = {
153
- perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
154
- keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
155
- }
156
- const normalize = (src, base) => {
157
- const out = { ...base }
158
- for (const k of Object.keys(base)) {
159
- const v = src?.[k]
160
- if (v === undefined || v === null) continue
161
- if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
162
- else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
163
- else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
164
- else if (typeof base[k] === 'string') { if (typeof v === 'string') out[k] = v }
165
- }
166
- return out
167
- }
168
- const cfg = normalize(readJson(cfgFile, {}), normalize(config, { ...DEFAULTS }))
169
- const saveCfg = (patch) => { Object.assign(cfg, normalize(patch, cfg)); writeJson(cfgFile, cfg) }
170
-
171
- ensureHomeFiles(pluginDir, stateDir, log)
172
-
173
- /* ── 设置命名空间:让 DSH 自带的「插件设置」能编辑这份 Config ──
174
- * 三个条件缺一不可(抄 qq-mode-console 的血泪注释):
175
- * ① loader entry 的 id 必须**等于命名空间名** —— 我们 cordis.patch.yml 里就是 `bili-duty` ✓
176
- * ② 插件模块导出 `Config` ✓ ③ Config 里至少一个字段标 volatile ✓(我们都标了)
177
- */
178
- try {
179
- if (typeof ctx.settings?.register === 'function') {
180
- ctx.settings.register('bili-duty', Config, { base: {}, applies: 'live' })
181
- log('已注册设置命名空间 bili-duty')
182
- }
183
- } catch (e) {
184
- if (!/already registered/i.test(String(e?.message ?? e))) log(`注册设置命名空间失败:${e?.message ?? e}`)
185
- }
186
-
187
- /* ─────────────── MCP(内部起那个 Python MCP) ─────────────── */
188
-
189
- let mcp = null
190
- let mcpKey = ''
191
- const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
192
-
193
- const ensureMcp = async () => {
194
- if (mcp?.running && mcpKey === mcpKeyNow()) return mcp
195
- if (mcp) { mcp.stop(); mcp = null }
196
- const script = join(stateDir, 'mcp_server.py')
197
- if (!existsSync(script)) throw new Error(`家里没有 mcp_server.py(应有 ${script})—— 装上插件后重启一次 dsh 会自动铺好`)
198
- mcpKey = mcpKeyNow()
199
- mcp = new McpClient({
200
- command: cfg.pythonPath || 'python',
201
- args: [script],
202
- cwd: stateDir, // 凭证落在家里 ✓
203
- // ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
204
- // Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
205
- env: pyEnv(),
206
- log,
207
- })
208
- return mcp
209
- }
210
-
211
- const call = async (toolName, args, opts) => {
212
- const client = await ensureMcp()
213
- return client.call(toolName, args, opts)
214
- }
215
-
216
- const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
217
-
218
- /* ─────────────── 工具 ─────────────── */
219
-
220
- const textOutput = {
221
- schema: { type: 'object', properties: {}, additionalProperties: true },
222
- render: (_args, value) => [{
223
- type: 'text',
224
- text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
225
- }],
226
- }
227
-
228
- /**
229
- * 工具返回值一律过一遍这里:**宿主只吃"无损 JSON"**,塞进去任何函数/循环引用/类实例都会报
230
- * `value is not lossless JSON`(2026-10-14 第一次挂上就撞了一次)。
231
- */
232
- const safe = (v) => {
233
- if (v === undefined || v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
234
- try { return JSON.parse(JSON.stringify(v)) } catch { return String(v) }
235
- }
236
-
237
- const tool = (spec) => ctx.tools.register(defineTool({
238
- ...spec,
239
- output: textOutput,
240
- async execute(args, exec) { return safe(await spec.execute(args, exec)) },
241
- }))
242
-
243
- tool({
244
- name: 'bili_whoami',
245
- description: '看 B站 那边登录的是谁(凭证是否有效、昵称、mid)。第一次用要先在「刷B站」设置页里扫码登录。',
246
- parameters: {},
247
- async execute() {
248
- if (!hasCredential()) {
249
- return { ok: false, loggedIn: false, hint: '还没登录:去 dsh 设置页的「刷B站」里点「扫码登录」(凭证会存在插件自己的家目录里)' }
250
- }
251
- const r = await call('bili_check_credential', {})
252
- return { ok: true, ...(typeof r === 'object' ? r : { raw: r }) }
253
- },
254
- })
255
-
256
- tool({
257
- name: 'bili_search',
258
- description: '搜 B站 视频(可排序)。返回标题 / BV号 / 播放量 / UP / 时长 等。\n'
259
- + '⚠️ 刷一轮的用法:搜近 7 天的、按播放或最新都行,然后**读字幕原文**再判断(别看摘要下结论)。',
260
- parameters: {
261
- keyword: { type: 'string', description: '搜索关键词' },
262
- num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
263
- order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
264
- },
265
- async execute(args) {
266
- return call('bili_search', {
267
- keyword: String(args.keyword ?? ''),
268
- num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
269
- order: String(args.order || 'totalrank'),
270
- })
271
- },
272
- })
273
-
274
- tool({
275
- name: 'bili_subtitle',
276
- description: '取一个视频的字幕(AI 字幕)全文。**判断内容就靠它** —— 没有字幕时,'
277
- + '若设置里开了「本地转写」再想别的办法,否则直接跳过这条。',
278
- parameters: { bvid: { type: 'string', description: 'BV号' } },
279
- async execute(args) { return call('bili_subtitle', { bvid: String(args.bvid ?? '') }) },
280
- })
281
-
282
- tool({
283
- name: 'bili_comments',
284
- description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。',
285
- parameters: {
286
- bvid: { type: 'string', description: 'BV号' },
287
- num: { type: 'number', description: '取几条(默认 30)' },
288
- },
289
- async execute(args) {
290
- return call('bili_comments', { bvid: String(args.bvid ?? ''), num: Math.max(1, Number(args.num) || 30) })
291
- },
292
- })
293
-
294
- tool({
295
- name: 'bili_reply',
296
- description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
297
- + '🔴 安全闸:设置页的「自动评论并 @ 你」**默认是关的**;关着的时候这个工具会拒绝 —— '
298
- + '除非本人当场让你发(那时传 force: true)。',
299
- parameters: {
300
- bvid: { type: 'string', description: 'BV号' },
301
- text: { type: 'string', description: '评论正文(短、干净、纯文本 @;别写敏感词)' },
302
- rpid: { type: 'number', description: '要回复的评论 id(0/不填 = 发新评论)' },
303
- force: { type: 'boolean', description: '本人当场让你发的,传 true 越过"自动评论关着"的闸' },
304
- },
305
- async execute(args) {
306
- if ((!cfg.autoComment || !cfg.mentionName) && !args.force) {
307
- return {
308
- ok: false,
309
- blocked: true,
310
- hint: '要发评论得同时满足两条:设置里开着「自动评论并 @ 你」、并且填了「要 @ 谁」。或者本人当场让你发时传 force: true。',
311
- }
312
- }
313
- return call('bili_reply', {
314
- bvid: String(args.bvid ?? ''),
315
- text: String(args.text ?? ''),
316
- rpid: Number(args.rpid) || 0,
317
- })
318
- },
319
- })
320
-
321
- tool({
322
- name: 'bili_status',
323
- description: '看它今天的状态:跑了没跑、挑了几条、额度剩多少、报告在哪、登录过没有、下次窗口什么时候开。',
324
- parameters: {},
325
- async execute() {
326
- const st = readJson(join(stateDir, 'state.json'), {})
327
- const now = new Date()
328
- return {
329
- ok: true,
330
- today: dayKey(now),
331
- ranToday: st.day === dayKey(now) ? Number(st.count || 0) : 0,
332
- perDay: cfg.perDay,
333
- windows: Object.fromEntries(Object.entries(WINDOWS).map(([k, w]) => [k, {
334
- label: w.label, start: w.start, end: w.end,
335
- switchOn: Boolean(cfg[k]),
336
- opening: inWindow(now, w),
337
- }])),
338
- peakValley: cfg.peakValley,
339
- autoComment: cfg.autoComment,
340
- transcribe: cfg.transcribe,
341
- loggedIn: hasCredential(),
342
- home: stateDir,
343
- report: join(outDir, `${dayKey(now)}.md`),
344
- }
345
- },
346
- })
347
-
348
- tool({
349
- name: 'bili_run',
350
- description: '**立刻跑一轮**(不等窗口):起一个 headless 会话,把 duty.md 交给它去刷、写报告。'
351
- + '适合"我现在就想看一份"的场合。',
352
- parameters: {},
353
- async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
354
- })
355
-
356
- tool({
357
- name: 'bili_login_start',
358
- description: '开始 B站 扫码登录:出二维码(画成 PNG 存在插件家目录里,返回路径)和一个 key。\n'
359
- + '用法:拿到 PNG 后用 `present` 把它推给本人(手机 B站 → 我的 → 扫一扫),再用 `bili_login_poll` 轮询。\n'
360
- + '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
361
- parameters: {},
362
- async execute() {
363
- const { key, url } = await loginStart()
364
- const png = await renderQrPng(cfg.pythonPath, stateDir, url)
365
- return {
366
- ok: true, key, url, png,
367
- hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去设置里点「建 venv 装依赖」(venv 里要有 qrcode)',
368
- }
369
- },
370
- })
371
-
372
- tool({
373
- name: 'bili_login_poll',
374
- description: '查扫码结果:waiting 还没扫 / scanned 扫了待确认 / ok 成功 / expired 过期。'
375
- + '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
376
- parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
377
- async execute(args) {
378
- const r = await loginPoll(String(args.key ?? ''))
379
- if (r.state === 'ok') {
380
- const file = saveCredential(stateDir, r.credential)
381
- log(`登录成功,凭证已写入 ${file}`)
382
- return { ok: true, state: 'ok', file }
383
- }
384
- return { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message }
385
- },
386
- })
387
-
388
- tool({
389
- name: 'bili_setup',
390
- description: '给刷B站装运行环境:在插件家目录建一个**独立 venv**、装上那个 Python MCP 的依赖,装完自动把 `pythonPath` 指过去。\n'
391
- + '**第一次用之前跑一次**。⭐ 它**起完就返回、不等装完**(几分钟的活),进度看家目录里的 `install.log`。',
392
- parameters: {},
393
- async execute() { return runInstall() },
394
- })
395
-
396
- tool({
397
- name: 'bili_config',
398
- description: '读 / 改「刷B站」的设置。不给 `patch` 就是读;给了就改(只认 schema 里的字段)。\n'
399
- + '例:`{"perDay":5,"peakValley":true,"noon":true,"night":true,"autoComment":false,"transcribe":false,"keywords":["DeepSeek Harness"]}`',
400
- parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
401
- async execute(args) {
402
- if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) saveCfg(args.patch)
403
- return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow() }
404
- },
405
- })
406
-
407
- /* ─────────────── 跑一轮(跟老方案同一条路:dsh --profile headless <prompt>) ─────────────── */
408
-
409
- const dutyStateFile = join(stateDir, 'state.json')
410
- const bumpState = (patch) => {
411
- const st = readJson(dutyStateFile, {})
412
- const today = dayKey()
413
- if (st.day !== today) { st.day = today; st.count = 0; st.windows = {} }
414
- writeJson(dutyStateFile, { ...st, ...patch })
415
- return st
416
- }
417
-
418
- // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
419
- // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
420
- let upgrading = false
421
- const runUpgrade = () => {
422
- if (upgrading) return { ok: true, busy: true, log: installLogFile, hint: '正在升级,别重复点' }
423
- upgrading = true
424
- appendInstall('\n=== ' + new Date().toISOString() + ' 开始升级(add dsh-bili-scroll)===\n')
425
- ;(async () => {
426
- try {
427
- const dsh = resolveDsh()
428
- // ⭐ 照抄那一轮的形状:resolveDsh 返回 { command, args, shell }
429
- // (写错就退化成裸 'dsh',而宿主 PATH 里常常没有它 ⇒ 静默失败)
430
- let cmd = dsh.command || 'dsh'
431
- let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', 'dsh-bili-scroll']
432
- if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
433
- appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
434
- const code = await spawnTo(cmd, args, { sink: appendInstall })
435
- if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
436
- else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
437
- } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
438
- })()
439
- return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
440
- }
441
-
442
- const resolveDsh = () => {
443
- const explicit = String(cfg.dshBin || '').trim()
444
- if (explicit && existsSync(explicit)) return { command: process.execPath, args: [explicit], shell: false }
445
- // 常见的全局安装位置(Windows 的 npm 前缀 / POSIX 的 lib)
446
- const guesses = [
447
- join(process.env.APPDATA || '', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
448
- '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
449
- '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
450
- ].filter(Boolean)
451
- for (const g of guesses) if (existsSync(g)) return { command: process.execPath, args: [g], shell: false }
452
- return { command: 'dsh', args: [], shell: true }
453
- }
454
-
455
- const promptFor = () => {
456
- // ⭐ 章程取哪一份:**插件里那份(永远跟版本一致)**;但家里那份如果**比它新**,说明使用者自己改过 ⇒ 听使用者的。
457
- const shipped = join(pluginDir, 'prompts', 'duty.md')
458
- const homeCopy = join(stateDir, 'duty.md')
459
- let duty = shipped
460
- try {
461
- if (existsSync(homeCopy) && statSync(homeCopy).mtimeMs > statSync(shipped).mtimeMs) duty = homeCopy
462
- } catch { /* 读不到就用 shipped 那份 */ }
463
- const kws = Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : []
464
- // ⭐ 开场先让它读**工作区**的 AGENTS.md —— 这一轮的工作目录就是使用者的工作区,
465
- // AGENTS.md 本来就会自动注入;这里再点一句,保证它真按使用者的设定来(别人的设定同理)。
466
- // ⭐ 那一轮的工作目录是**插件家**(沙箱只让它写自己工作目录里的东西 ✗),
467
- // 所以"使用者的设定"由**我们自己读进来**喂给它 —— 它在宿主进程里跑,没有沙箱限制 ✓
468
- let ownerDoc = ''
469
- try {
470
- const wsDir = String(cfg.workspace || '').trim() || process.cwd()
471
- const agents = join(wsDir, 'AGENTS.md')
472
- if (existsSync(agents)) {
473
- const text = readFileSync(agents, 'utf8')
474
- ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
475
- + text.slice(0, 6000)
476
- + '\n===== 设定结束 =====\n'
477
- }
478
- } catch { /* 读不到就算了,别因此跑不成 */ }
479
- return `你是这个账号的 B站 助手。`
480
- + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
481
- + ownerDoc
482
- + `然后读章程 ${duty},按里面的要求刷一轮 B站,`
483
- + (cfg.reportLevel === 'brief'
484
- ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
485
- : cfg.reportLevel === 'off'
486
- ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
487
- : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
488
- + `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
489
- + `本地转写:${cfg.transcribe
490
- ? (asrReadyNow()
491
- ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
492
- : '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
493
- : '关着(没字幕的视频就跳过)'};`
494
- + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
495
- + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
496
- + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
497
- + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
498
- ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
499
- : '关着的(只写报告,别发评论)'}。`
500
- + (cfg.replyToComments
501
- ? `回复评论:开着(每轮最多回 ${Number(cfg.replyPerRound) || 1} 条)—— 只回「直接回复到你那条评论下面的人」,回完记进报告;找不到你那条评论就跳过,别硬找。`
502
- : `回复评论:关着(一个字都不要回任何人 —— 有人回复你那条评论也不要理)。`)
503
- + `最后用一句话汇报你挑到了什么。现在几点了自己看一眼系统时间。`
504
- }
505
-
506
- /**
507
- * 给 headless 那一轮写一份 overlay —— 把 B站 MCP 挂上去。
508
- * 🔴 为什么需要它:**某个 profile 能拿到什么工具,取决于它自己那层的 patch**
509
- * (headless 里那两条 B站 MCP 就是当初手写进去的)。分发给别人时不能要求人家改 profile,
510
- * 所以插件自己生成一份、跑的时候 `--patch` 带上(dsh 支持可重复的 `--patch`)。
511
- */
512
- const ensureOverlay = () => {
513
- if (!cfg.overlayMcp) return null
514
- const file = join(stateDir, 'headless-overlay.yml')
515
- const script = join(stateDir, 'mcp_server.py')
516
- const posix = (p) => String(p).replace(/\\/g, '/')
517
- const yml = [
518
- '- insert:',
519
- ' - id: mcp-bili-duty',
520
- " name: '@deepseek-ai/dsh-mcp-client'",
521
- ' config:',
522
- ' serverName: bili-duty',
523
- ' transport: stdio',
524
- ` command: '${posix(cfg.pythonPath || 'python')}'`,
525
- ' args:',
526
- ` - '${posix(script)}'`,
527
- '',
528
- ].join('\n')
529
- try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
530
- }
531
-
532
- /**
533
- * ⭐ 评论发出后复核(2026-10-16 加的行 —— 起因是用户在评论区反馈
534
- * 「**回执成功不等于公开可见,新号会被压审**」):
535
- * 那一轮的 `bili_reply` 回"发送成功"只代表**接口收了**;新号 / 风控会让它进审核,
536
- * 那时候连 @ 通知都不会推 ⇒ 插件以为发了、下一轮又不再发 ⇒ **一条都没到人**。
537
- * 所以由**宿主**(有网络、有凭证、不受沙箱限制)去拉一次评论列表 / 楼中楼来核。
538
- * ⚠️ 措辞守则:看不到写「**没查到**」,**绝不写"没发出去"**(翻页/热度排序都会漏)。
539
- */
540
- let verifying = false
541
- const runVerify = async (why = 'manual') => {
542
- if (verifying) return { ok: false, error: '上一次复核还没跑完,等它完事' }
543
- verifying = true
544
- try {
545
- log(`评论复核开始(${why})`)
546
- const r = await verifyRecent(stateDir, { log })
547
- const f = appendVerifyToReport(stateDir, r.text)
548
- log(`评论复核结束(${why}):查了 ${r.checked.length} 条${f ? `,已写进 ${f}` : ''}`)
549
- return { ok: true, why, checked: r.checked, skipped: r.skipped, text: r.text, report: f || '' }
550
- } catch (e) {
551
- log(`评论复核失败(${why}):${e?.message ?? e}`)
552
- return { ok: false, why, error: String(e?.message ?? e) }
553
- } finally { verifying = false }
554
- }
555
-
556
- /**
557
- * ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
558
- * 起因(2026-10-16 用户实测反馈):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
559
- * 最后 `out/` 是空的、判重表也没建 ⇒ **使用者完全看不到发生了什么**,也不知道白烧了钱 ✗。
560
- * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补 ✓),不覆盖已有的报告 ✓。
561
- */
562
- const stubReportIfMissing = (code) => {
563
- try {
564
- if (String(cfg.reportLevel || 'full') === 'off') return
565
- const f = join(outDir, `${dayKey()}.md`)
566
- if (existsSync(f)) return
567
- let tail = ''
568
- try { tail = readFileSync(logFile, 'utf8').slice(-1500) } catch { /* 读不到就算了 */ }
569
- writeFileSync(f, [
570
- `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
571
- '',
572
- `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
573
- '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
574
- '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
575
- '',
576
- '```',
577
- String(tail).trim(),
578
- '```',
579
- '',
580
- ].join('\n'), 'utf8')
581
- log('这一轮没写报告 ⇒ 宿主补了一份占位报告')
582
- } catch (e) { log(`补占位报告失败:${e?.message ?? e}`) }
583
- }
584
-
585
- const runDuty = (why = 'window') => {
586
- try {
587
- const dsh = resolveDsh()
588
- const overlay = ensureOverlay()
589
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
590
- if (overlay) args.push('--patch', overlay)
591
- args.push(promptFor())
592
- // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
593
- // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
594
- // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
595
- // 它写不了家目录里的 out/ 报告、整轮卡在"我是被沙箱关着的"上面打转 ✗;
596
- // 现在改成"设定由插件读进来、内联进提示词" ✓,cwd 回到插件家 ✓)
597
- const cwd = stateDir
598
- log(`刷B站开跑(${why}):${dsh.command} ${args.slice(0, 3).join(' ')} … cwd=${cwd}`)
599
- const child = spawn(dsh.command, args, {
600
- cwd,
601
- shell: dsh.shell,
602
- windowsHide: true,
603
- stdio: ['ignore', 'pipe', 'pipe'],
604
- // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
605
- // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
606
- env: pyEnv({ BILI_DUTY_HOME: stateDir }),
607
- })
608
- const pick = (buf) => appendFileSync(logFile, String(buf), 'utf8')
609
- child.stdout?.on('data', pick)
610
- child.stderr?.on('data', pick)
611
- child.on('exit', (code) => {
612
- log(`刷B站结束(${why},code=${code})`)
613
- stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
614
- // ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
615
- // 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
616
- // 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
617
- setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
618
- })
619
- child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
620
- return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
621
- } catch (e) {
622
- log(`刷B站开跑失败(${why}):${e?.stack ?? e}`)
623
- return { started: false, why, error: String(e?.message ?? e) }
624
- }
625
- }
626
-
627
- /* ─────────────── 谷时定时器 ───────────────
628
- * 🔴 2026-10-14 事故(使用者那边表现为"DSH 突然崩了、网页 Failed to fetch (gateway/internal)"):
629
- * 第一版是 `setInterval(tick, 60_000)`,回调里抛的 TypeError **没人接**,直接把整个宿主进程带走
630
- * (`dsh: fatal uncaught exception`)。⇒ **定时器回调必须自己兜住所有异常**:
631
- * 插件再错也只该"这一轮没跑成",绝不该杀宿主。
632
- */
633
- const tick = () => {
634
- try {
635
- if (!cfg.peakValley) return
636
- const now = new Date()
637
- const st = readJson(dutyStateFile, {})
638
- const today = dayKey(now)
639
- const count = st.day === today ? Number(st.count || 0) : 0
640
- if (count >= Number(cfg.perDay || 0)) return
641
- for (const k of ['noon', 'night']) {
642
- if (!cfg[k]) continue
643
- if (!inWindow(now, WINDOWS[k])) continue
644
- const key = `${today}:${k}`
645
- if (st.windows?.[key]) continue
646
- bumpState({ windows: { ...(st.windows || {}), [key]: true }, lastRunAt: now.toISOString(), lastWhy: k })
647
- runDuty(`window:${k}`)
648
- return
649
- }
650
- } catch (e) {
651
- log(`定时器这一跳出错(已兜住,不影响宿主):${e?.stack ?? e}`)
652
- }
653
- }
654
-
655
- try {
656
- const timer = setInterval(tick, 60_000)
657
- timer.unref?.() // 别因为这个定时器让进程不肯退出
658
- ctx.effect(() => () => clearInterval(timer), 'bili-duty: 谷时定时器')
659
- } catch (e) { log(`定时器没起来:${e?.message ?? e}`) }
660
-
661
- /* ─────────────── HTTP(设置页用) ─────────────── */
662
-
663
- const sendJson = (res, code, obj) => {
664
- res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
665
- res.end(JSON.stringify(obj))
666
- }
667
- const readBody = (req) => new Promise((resolve) => {
668
- let data = ''
669
- req.on('data', (c) => { data += c; if (data.length > 1_000_000) req.destroy() })
670
- req.on('end', () => { try { resolve(JSON.parse(data || '{}')) } catch { resolve({}) } })
671
- req.on('error', () => resolve({}))
672
- })
673
-
674
- const venvPython = () => process.platform === 'win32'
675
- ? join(stateDir, '.venv', 'Scripts', 'python.exe')
676
- : join(stateDir, '.venv', 'bin', 'python')
677
-
678
- /**
679
- * 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
680
- *
681
- * 🔴 为什么必须这样(2026-10-14 事故):第一版是 `async` 全程 await,
682
- * 而"建 venv + pip install"在使用者这条网络上要**几分钟** ⇒ 那个工具调用一直挂着,
683
- * **整个会话僵住、连"暂停"都卡,最后只能重启 dsh** ✗。
684
- * 现在的做法:spawn 出去、输出追加进 `install.log`,函数立刻返回;
685
- * 想知道装完没 —— 读日志,或看 `bili_duty_status` 里的 `installing`。
686
- * 依赖走**国内镜像**(使用者那条网络 pip 直连只有 ~24KB/s ✗;uv + 清华源是几十秒 ✓)。
687
- */
688
- let installing = false
689
- const installLogFile = join(stateDir, 'install.log')
690
- const appendInstall = (s) => { try { appendFileSync(installLogFile, s, 'utf8') } catch { /* 写不进去就算了 */ } }
691
-
692
- let uvOk = null
693
- const haveUv = async () => {
694
- if (uvOk !== null) return uvOk
695
- uvOk = await new Promise((resolve) => {
696
- try {
697
- const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
698
- c.on('exit', (code) => resolve(code === 0))
699
- c.on('error', () => resolve(false))
700
- } catch { resolve(false) }
701
- })
702
- return uvOk
703
- }
704
-
705
- /** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
706
- const probeCmd = (cmd) => new Promise((resolve) => {
707
- try {
708
- const c = spawn(cmd, ['--version'], { windowsHide: true, stdio: 'ignore' })
709
- c.on('exit', (code) => resolve(code === 0))
710
- c.on('error', () => resolve(false))
711
- } catch { resolve(false) }
712
- })
713
-
714
- const spawnLogged = (cmd, args) => new Promise((resolve) => {
715
- appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
716
- let c
717
- try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: pyEnv() }) }
718
- catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
719
- const w = (b) => appendInstall(String(b))
720
- c.stdout?.on('data', w)
721
- c.stderr?.on('data', w)
722
- c.on('exit', (code) => { appendInstall(`\n--- exit ${code} ---\n`); resolve(code) })
723
- c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
724
- })
725
-
726
- // ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
727
- const asrDir = join(stateDir, '.venv-asr')
728
- const asrLogFile = join(stateDir, 'asr-install.log')
729
- const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
730
- process.platform === 'win32' ? 'python.exe' : 'python')
731
- const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
732
- // ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
733
- // 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
734
- const asrTmp = join(stateDir, 'tmp', 'asr')
735
- try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
736
-
737
- // ⭐ 判重表也由**宿主**预先建好(两个原因):
738
- // ① 那一轮被沙箱关着,"新建文件"会被拒(2026-10-14 那条规矩);
739
- // ② 它读不出判重表 ⇒ 以为"没记录过" ⇒ 同一批视频反复推送(用户实测反馈 2026-10-16)。
740
- const seenFile = join(stateDir, 'seen.md')
741
- try {
742
- if (!existsSync(seenFile)) {
743
- writeFileSync(seenFile, [
744
- '# 刷B站 · 判重表(宿主预建,那一轮只往后面追加)',
745
- '',
746
- '## 评论记录(BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字)',
747
- '',
748
- '| BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字 |',
749
- '| --- | --- | --- | --- | --- |',
750
- '',
751
- '## 看过的视频',
752
- '',
753
- ].join('\n'), 'utf8')
754
- }
755
- } catch { /* 建不了也别拦着插件加载 */ }
756
- let installingAsr = false
757
-
758
- /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
759
- const resolveUv = async () => {
760
- const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
761
- if (await haveUv()) return 'uv'
762
- if (existsSync(bundled)) return bundled
763
- appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
764
- const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
765
- if (got) appendInstall('uv 已就位:' + got + '\n')
766
- return got
767
- }
768
-
769
- /** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
770
- const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
771
- sink(`\n$ ${cmd} ${args.join(' ')}\n`)
772
- let c
773
- try {
774
- c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
775
- env: pyEnv(env || {}) })
776
- } catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
777
- const w = (b) => sink(String(b))
778
- c.stdout?.on('data', w)
779
- c.stderr?.on('data', w)
780
- c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
781
- c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
782
- })
783
-
784
- /** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
785
- const runAsrInstall = () => {
786
- if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
787
- installingAsr = true
788
- appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
789
- ;(async () => {
790
- try {
791
- const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
792
- const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
793
- const py = asrPython()
794
- const uvCmd = await resolveUv()
795
- if (!existsSync(py)) {
796
- // --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
797
- if (uvCmd) await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
798
- else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
799
- }
800
- if (!existsSync(py)) {
801
- appendAsr('建环境失败 —— 先点上面的「装依赖」,把 uv 或 Python 准备好\n')
802
- log('转写:建环境失败'); return
803
- }
804
- appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
805
- let code = 1
806
- // ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
807
- // 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
808
- const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
809
- if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
810
- if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
811
- if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
812
- // ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
813
- const model = String(cfg.asrModel || 'small')
814
- // 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
815
- const hfHome = join(stateDir, '.hf-cache')
816
- try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
817
- appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
818
- const code2 = await spawnTo(py, ['-c',
819
- '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("模型就绪")',
820
- model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
821
- appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
822
- log(`装转写依赖结束:exit=${code}/${code2}`)
823
- } catch (e) {
824
- appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
825
- log(`装转写依赖崩了:${e?.message ?? e}`)
826
- } finally { installingAsr = false }
827
- })()
828
- return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
829
- }
830
-
831
- const runInstall = () => {
832
- if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
833
- installing = true
834
- appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
835
- ;(async () => {
836
- try {
837
- const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
838
- const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
839
- const venvPy = venvPython()
840
- const py = String(cfg.pythonPath || 'python')
841
- const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
842
- if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
843
- const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
844
- + '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
845
- + '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
846
- appendInstall(msg + '\n'); log(msg); installing = false; return
847
- }
848
- if (!existsSync(venvPy)) {
849
- if (uvCmd) await spawnLogged(uvCmd, ['venv', join(stateDir, '.venv')])
850
- if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
851
- }
852
- let code = 1
853
- if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
854
- if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
855
- if (code === 0 && existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
856
- appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
857
- log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
858
- } catch (e) {
859
- appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
860
- log(`装依赖崩了:${e?.message ?? e}`)
861
- } finally { installing = false }
862
- })()
863
- return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
864
- }
865
-
866
- /** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
867
- const checkUp = async () => {
868
- const out = { ok: false, steps: [] }
869
- if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
870
- if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
871
- try {
872
- const who = await call('bili_check_credential', {})
873
- out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
874
- out.steps.push('登录有效')
875
- } catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
876
- try {
877
- const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
878
- const t = typeof s === 'string' ? s : JSON.stringify(s)
879
- out.sample = t.slice(0, 160)
880
- out.steps.push('能搜到东西')
881
- out.ok = t.length > 2
882
- if (!out.ok) out.error = '搜到了空结果'
883
- } catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
884
- return out
885
- }
886
-
887
- let updateCache = null // { at, latest } —— 检查更新的结果缓存
888
-
889
- const status = () => {
890
- const st = readJson(dutyStateFile, {})
891
- const today = dayKey()
892
- return {
893
- ok: true,
894
- home: stateDir,
895
- config: cfg,
896
- windows: WINDOWS,
897
- today,
898
- ranToday: st.day === today ? Number(st.count || 0) : 0,
899
- lastRunAt: st.lastRunAt || null,
900
- lastWhy: st.lastWhy || null,
901
- loggedIn: hasCredential(),
902
- venvReady: venvReadyNow(),
903
- version: myVersion(),
904
- installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
905
- installing,
906
- // ── 本地转写那一套的状态(设置页要显示进度)──
907
- asrReady: asrReadyNow(),
908
- // 模型下没下(免得界面写"就绪"、其实还得现下)
909
- asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
910
- asrInstalling: installingAsr,
911
- asrLog: asrLogFile,
912
- asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
913
- installLog: installLogFile,
914
- // ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
915
- installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
916
- report: join(outDir, `${today}.md`),
917
- log: logFile,
918
- }
919
- }
920
-
921
- const handle = async (req, res, url) => {
922
- const path = url.pathname
923
- if (req.method === 'GET' && path === '/api/bili-duty/status') return sendJson(res, 200, status())
924
-
925
- if (req.method === 'GET' && path === '/api/bili-duty/config') {
926
- return sendJson(res, 200, { ok: true, config: cfg, windows: WINDOWS, home: stateDir })
927
- }
928
-
929
- if (req.method === 'POST' && path === '/api/bili-duty/config') {
930
- const body = await readBody(req)
931
- const patch = {}
932
- for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
933
- 'commentPerRound', 'mentionName',
934
- 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound']) {
935
- if (k in body) patch[k] = body[k]
936
- }
937
- if ('perDay' in patch) patch.perDay = Math.max(0, Math.min(Number(patch.perDay) || 0, 50))
938
- if ('reportLevel' in patch) patch.reportLevel = ['full', 'brief', 'off'].includes(String(patch.reportLevel)) ? String(patch.reportLevel) : 'full'
939
- if ('replyToComments' in patch) patch.replyToComments = Boolean(patch.replyToComments)
940
- if ('replyPerRound' in patch) patch.replyPerRound = Math.max(1, Math.min(Number(patch.replyPerRound) || 1, 10))
941
- saveCfg(patch)
942
- log(`设置已更新:${JSON.stringify(patch)}`)
943
- return sendJson(res, 200, { ok: true, config: cfg })
944
- }
945
-
946
- if (req.method === 'POST' && path === '/api/bili-duty/run') {
947
- return sendJson(res, 200, { ok: true, ...runDuty('manual') })
948
- }
949
-
950
- // 建 venv + 装依赖(**不等它**:几分钟的活,进度写 install.log)
951
- if (req.method === 'POST' && path === '/api/bili-duty/install') {
952
- return sendJson(res, 200, runInstall())
953
- }
954
-
955
- // 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
956
- if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
957
- return sendJson(res, 200, runAsrInstall())
958
- }
959
-
960
- // ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
961
- // (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
962
- if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
963
-
964
- // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
965
- if (req.method === 'GET' && path === '/api/bili-duty/report') {
966
- const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
967
- const file = join(outDir, day + '.md')
968
- let text = ''
969
- let exists = false
970
- try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
971
- return sendJson(res, 200, { ok: true, day, exists, file, text })
972
- }
973
-
974
- // 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
975
- if (req.method === 'POST' && path === '/api/bili-duty/open') {
976
- const body = await readBody(req)
977
- const dir = String(body?.what || 'out') === 'home' ? stateDir : outDir
978
- if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
979
- // ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
980
- // try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
981
- // ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
982
- // 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
983
- const attempts = [
984
- ['cmd.exe', ['/c', 'start', '', dir]],
985
- [join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
986
- ['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
987
- ]
988
- let lastErr = ''
989
- for (const [cmd, args] of attempts) {
990
- const err = await new Promise((resolve) => {
991
- let done = false
992
- let child
993
- try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
994
- child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
995
- setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
996
- })
997
- if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
998
- lastErr = err
999
- log('打开文件夹失败(' + cmd + '):' + err)
1000
- }
1001
- return sendJson(res, 200, { ok: false, error: lastErr, dir })
1002
- }
1003
-
1004
- // 「一键升级」:点了它自己跑 add(⚠️ 之后要重启才生效)
1005
- if (req.method === 'POST' && path === '/api/bili-duty/upgrade') return sendJson(res, 200, runUpgrade())
1006
-
1007
- // 「检查更新」:⚠️ 只在用户点按钮时才联网(不偷偷发请求 ✓),结果缓存一天 ✓
1008
- if (req.method === 'GET' && path === '/api/bili-duty/update') {
1009
- const out = { ok: true, current: myVersion(), latest: '', hasNew: false, page: 'https://www.npmjs.com/package/dsh-bili-scroll' }
1010
- try {
1011
- if (updateCache && Date.now() - updateCache.at < 24 * 3600 * 1000) out.latest = updateCache.latest
1012
- else {
1013
- const ctrl = new AbortController()
1014
- const timer = setTimeout(() => ctrl.abort(), 8000)
1015
- const r = await fetch('https://registry.npmjs.org/dsh-bili-scroll/latest', { signal: ctrl.signal })
1016
- clearTimeout(timer)
1017
- const j = await r.json()
1018
- out.latest = String(j.version || '')
1019
- updateCache = { at: Date.now(), latest: out.latest }
1020
- }
1021
- } catch (e) { out.ok = false; out.error = String(e?.message ?? e); return sendJson(res, 200, out) }
1022
- // ⭐ 要比谁更新,不能只比不相等 —— 否则本地版本高于 npm 时会误报有新版
1023
- const vnum = (v) => String(v || '').split('.').map((x) => parseInt(x, 10) || 0)
1024
- const cmp = (a, b) => {
1025
- const x = vnum(a), y = vnum(b)
1026
- for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) }
1027
- return 0
1028
- }
1029
- out.hasNew = Boolean(out.latest) && cmp(out.latest, out.current) > 0
1030
- return sendJson(res, 200, out)
1031
- }
1032
-
1033
- // 「测一下」:真跑一次,回答"能不能用"
1034
- if (req.method === 'POST' && path === '/api/bili-duty/check') {
1035
- return sendJson(res, 200, await checkUp())
1036
- }
1037
-
1038
- // ── 扫码登录(设置页用)──
1039
- if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
1040
- const { key, url } = await loginStart()
1041
- const png = await renderQrPng(cfg.pythonPath, stateDir, url)
1042
- let qr = null
1043
- if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
1044
- log(`出码:${url}${qr ? '(已画成 PNG)' : '(没画出 PNG —— venv 里大概还没有 qrcode)'}`)
1045
- return sendJson(res, 200, { ok: true, key, url, qr })
1046
- }
1047
-
1048
- if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
1049
- const body = await readBody(req)
1050
- const r = await loginPoll(String(body.key || ''))
1051
- if (r.state === 'ok') {
1052
- const file = saveCredential(stateDir, r.credential)
1053
- log(`登录成功,凭证已写入 ${file}`)
1054
- return sendJson(res, 200, { ok: true, state: 'ok', file })
1055
- }
1056
- return sendJson(res, 200, { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message })
1057
- }
1058
-
1059
- return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
1060
- }
1061
-
1062
- try {
1063
- ctx.effect(() => ctx.webServer.register({
1064
- kind: 'prefix',
1065
- path: '/api/bili-duty',
1066
- handler: async (req, res) => {
1067
- try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
1068
- sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
1069
- }
1070
- },
1071
- }), 'bili-duty: /api/bili-duty 路由')
1072
- } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
1073
-
1074
- ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
1075
- log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
1076
- }
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
+ /** ⭐ 私信投递:off=只发评论(现状)· only=只私信(完全不公开评论)· both=评论 + 私信 */
57
+ dmMode: z.string().default('off').extra('volatile', true),
58
+ /** ⭐ 私信发给谁的 UID(数字)。要在 dmMode 不是 off 时填 */
59
+ dmUid: z.natural().default(0).extra('volatile', true),
60
+ /** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
61
+ transcribe: z.boolean().default(false).extra('volatile', true),
62
+ /** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
63
+ asrModel: z.string().default('small').extra('volatile', true),
64
+ /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人);**有效范围 0–20,口径见 clampPatch**(0 = 不发评论)
65
+ * ⚠️ 这里故意**不加 `.max(20)`**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ **整条插件不激活** ✗
66
+ * (跟 schemastery 拿不到时的降级同一个道理)—— 范围一律由 clampPatch 在**写**和**读**两头钳。 */
67
+ commentPerRound: z.natural().default(1).extra('volatile', true),
68
+ /** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
69
+ mentionName: z.string().default('').extra('volatile', true),
70
+ /** 关注关键词(空了用章程里默认那几组) */
71
+ keywords: z.array(z.string()).default([]).extra('volatile', true),
72
+ /** Python:指向 venv 里的那个最好 */
73
+ pythonPath: z.string().default('python').extra('volatile', true),
74
+ /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
75
+ dshBin: z.string().default('').extra('volatile', true),
76
+ /** headless 那一轮用哪个 profile */
77
+ headlessProfile: z.string().default('headless').extra('volatile', true),
78
+ /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
79
+ overlayMcp: z.boolean().default(true).extra('volatile', true),
80
+ /** 跑 headless 时的工作目录 */
81
+ workspace: z.string().default('').extra('volatile', true),
82
+ /** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
83
+ reportLevel: z.string().default('full').extra('volatile', true),
84
+ /** 回复评论:默认关。开了之后只回「直接回复到它那条评论下面的人」 */
85
+ replyToComments: z.boolean().default(false).extra('volatile', true),
86
+ /** 每轮最多回几条 */
87
+ replyPerRound: z.number().default(1).extra('volatile', true),
88
+ })
89
+
90
+ /**
91
+ * ⭐ 给所有 Python 子进程统一 UTF-8。
92
+ * 起因(2026-10-16,用户在评论区反馈):Windows 上 Python 默认按 GBK 编解码,
93
+ * 我们的脚本 / 内置 MCP 都会往管道里打中文与 emoji(`✅`、`—` 之类),一进管道就可能
94
+ * 报 UnicodeDecodeError / 乱码或直接中断。**编码这种环境差异,插件该自己钉死,不该让用户碰。**
95
+ */
96
+ const pyEnv = (extra = {}) => ({
97
+ ...process.env,
98
+ PYTHONUTF8: '1',
99
+ PYTHONIOENCODING: 'utf-8',
100
+ ...extra,
101
+ })
102
+
103
+ export function apply(ctx, config) {
104
+ const pluginDir = dirname(fileURLToPath(import.meta.url))
105
+ // ⭐ 自己的版本号(从 package.json 读,设置页要显示它)
106
+ let myVer = ''
107
+ const myVersion = () => {
108
+ if (myVer) return myVer
109
+ try { myVer = String(JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).version || '') } catch { myVer = '' }
110
+ return myVer
111
+ }
112
+ const stateDir = stateDirOf(ctx)
113
+
114
+ // ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
115
+ // (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
116
+ const sitePkgs = (venvRoot) => {
117
+ const win = join(venvRoot, 'Lib', 'site-packages')
118
+ if (existsSync(win)) return win
119
+ try {
120
+ const lib = join(venvRoot, 'lib')
121
+ for (const d of readdirSync(lib)) {
122
+ if (!d.startsWith('python')) continue
123
+ const p = join(lib, d, 'site-packages')
124
+ if (existsSync(p)) return p
125
+ }
126
+ } catch { /* 没有就算了 */ }
127
+ return win
128
+ }
129
+ const hasPkg = (venvRoot, prefix) => {
130
+ try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
131
+ }
132
+ const venvReadyNow = () => existsSync(venvPython()) && hasPkg(join(stateDir, '.venv'), 'bilibili_api')
133
+ const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
134
+ const cfgFile = join(stateDir, 'config.json')
135
+ const logFile = join(stateDir, 'duty.log')
136
+ const outDir = join(stateDir, 'out')
137
+ mkdirSync(outDir, { recursive: true })
138
+
139
+ const log = (msg) => {
140
+ const line = `${new Date().toISOString()} ${msg}`
141
+ try { appendFileSync(logFile, line + '\n', 'utf8') } catch { /* 日志写不进去也不致命 */ }
142
+ try { ctx.logger?.info?.(`[bili-duty] ${msg}`) } catch { /* 没有 logger 就算了 */ }
143
+ }
144
+
145
+ // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
146
+ for (const n of hostDeps.notes) log(n)
147
+
148
+ /**
149
+ * ⚠️ 2026-10-14 事故(第一次挂上就炸了整个 DSH,见当天的 lesson):
150
+ * `apply(ctx, config)` 传进来的那个 `config`,字段值**不保证是标量** —— 我们的 `config.pythonPath`
151
+ * 是个**对象**、`config.keywords` 也不是数组(日志里打成 `[object Object]`)。对象是"真值",
152
+ * 于是 `if (!cfg.peakValley) return` 这类守卫全部失效,一路走到 `cfg.keywords.filter(...)`
153
+ * 抛 TypeError —— 而那是在**定时器回调**里,没人接 ⇒ **整个 dsh 进程被带走** ✗✗。
154
+ * ⇒ 两条规矩:
155
+ * ① 逐字段按默认值的类型挑一遍(拿不准就用默认值),再叠自己的 `config.json`;
156
+ * ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
157
+ */
158
+ const DEFAULTS = {
159
+ perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
160
+ dmMode: 'off', dmUid: 0,
161
+ keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
162
+ }
163
+ const normalize = (src, base) => {
164
+ const out = { ...base }
165
+ for (const k of Object.keys(base)) {
166
+ const v = src?.[k]
167
+ if (v === undefined || v === null) continue
168
+ if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
169
+ else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
170
+ else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
171
+ else if (typeof base[k] === 'string') { if (typeof v === 'string') out[k] = v }
172
+ }
173
+ return out
174
+ }
175
+ /* ── 配置从哪来:DSH 自带的「插件设置」页 > 插件自己的 config.json > 默认值 ──
176
+ * 老代码是 `normalize(readJson(cfgFile, {}), normalize(config, DEFAULTS))` —— config.json 一刀切盖住宿主,
177
+ * 后果:用户在 DSH 自带的插件设置页里改了值,插件照旧读 config.json 的旧值 ⇒ **静默不生效** ✗。
178
+ * 判据用 describe() 给的 `user` 层(= 用户在原生设置页**显式设过**的键):
179
+ * 设过的键 ⇒ 听宿主的;没设过 ⇒ 还是 config.json 说了算(老用户零回归 ✓)。
180
+ * 拿不到设置服务就完全退回老行为。
181
+ */
182
+ /* ⭐ 「设置里能写什么范围」收在一处 —— HTTP 接口、bili_config 工具、**读配置**三处都走它,
183
+ * 所以 perDay 0–50 / commentPerRound 0–20 这些数字**只有这一份**(schema 里不再写一遍)。
184
+ * (2026-10-16 审查修:原来只有 HTTP 路径钳制,工具路径连 perDay 都能写任意值;
185
+ * commentPerRound 两条路径都没钳,直接 POST 能写 9999)
186
+ * (2026-10-17 复查补:DSH 原生设置页那条路我们钳不到**写** —— 用户能在那儿直接填 9999 ⇒
187
+ * loadCfg 读回来也过一遍,写进去和读出来一个口径) */
188
+ const clampPatch = (patch) => {
189
+ const p = { ...patch }
190
+ if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
191
+ if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
192
+ if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
193
+ if ('replyPerRound' in p) p.replyPerRound = Math.max(1, Math.min(Number(p.replyPerRound) || 1, 10))
194
+ if ('commentPerRound' in p) p.commentPerRound = Math.max(0, Math.min(Number(p.commentPerRound) || 0, 20))
195
+ return p
196
+ }
197
+
198
+ const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
199
+ const savedCfg = () => normalize(readJson(cfgFile, {}), { ...DEFAULTS })
200
+ const CFG_SIG = ['perDay', 'mentionName', 'replyToComments', 'overlayMcp'] // 靠这几个键认出"哪一行是我们"
201
+ let settingsRow = { at: 0, row: null }
202
+ /**
203
+ * 我们那一行设置表单(拿不到就返回 null ⇒ 一切按 config.json 来)。
204
+ * ⚠️ DSH 自带的「插件设置」页**不需要注册** —— 老代码那句 `ctx.settings.register(...)` 是个不存在的方法,
205
+ * 每次都抛 TypeError、被 catch 吞掉(2026-10-17 读宿主源码 @deepseek-ai/dsh-settings 才明白):
206
+ * describe() 自己扫 profile 里**带 volatile 字段**的条目列出来(lib/index.js:413-452),
207
+ * 命名空间 ns **等于 loader 条目的 id**(我们 cordis.patch.yml 里是 `bili-scroll`,不是老注释写的 bili-duty)。
208
+ * 另外两条同样要紧(都是读宿主源码得来的,别再想当然):
209
+ * ① `apply(ctx, config)` 收到的 config 就是 fiber.config 本体,volatile 字段一改宿主**就地**改它
210
+ * (cordis-plugin-loader/src/config/entry.ts:`this.fiber._config = …` + `_commitVolatile()`)⇒
211
+ * 启动时快照的 cfg 永远看不到设置页的改动,用之前得 refreshCfg()。
212
+ * ② describe() 每行给三层:value(生效值)/ base(继承层)/ user(用户在设置页显式设过的那层)。
213
+ */
214
+ const settingsDesc = (fresh = false) => {
215
+ const t = Date.now()
216
+ if (!fresh && t - settingsRow.at < 3000) return settingsRow.row // 挡 UI 轮询:describe() 要扫所有条目
217
+ let row = null
218
+ try {
219
+ const rows = ctx.settings?.describe?.() || []
220
+ row = rows.find((r) => r?.value && CFG_SIG.every((k) => k in r.value)) || null
221
+ } catch (e) { log(`读 DSH 设置表单失败(不影响跑):${e?.message ?? e}`) }
222
+ settingsRow = { at: t, row }
223
+ return row
224
+ }
225
+ const loadCfg = () => {
226
+ const saved = savedCfg()
227
+ const row = settingsDesc()
228
+ // ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
229
+ if (!row) return clampPatch(saved)
230
+ const user = row.user || {}
231
+ const out = { ...hostCfg() }
232
+ for (const k of Object.keys(DEFAULTS)) if (user[k] === undefined) out[k] = saved[k]
233
+ return clampPatch(out)
234
+ }
235
+ const cfg = loadCfg()
236
+ // ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
237
+ const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
238
+ const saveCfg = (patch) => { Object.assign(cfg, normalize(patch, cfg)); writeJson(cfgFile, cfg) }
239
+ /**
240
+ * ⭐ 同时写一份到宿主:不写的话会出现死结 —— 某个键一旦在原生设置页设过就由宿主说了算,
241
+ * 插件页再改那个键就成了"改了没反应" ✗。写失败不算致命(config.json 那份还在),把话带回给用户。
242
+ */
243
+ const saveCfgHost = async (patch) => {
244
+ const attempt = async (fresh) => {
245
+ const row = settingsDesc(fresh)
246
+ if (!row?.ns || typeof ctx.settings?.update !== 'function') {
247
+ throw Object.assign(new Error('DSH 这边没有可写的设置表单(值只落在插件自己的 config.json 里)'), { noForm: true })
248
+ }
249
+ await ctx.settings.update(row.ns, patch, row.revision)
250
+ return row.ns
251
+ }
252
+ try { return { ok: true, ns: await attempt(false) } }
253
+ catch (e) {
254
+ if (!e?.noForm) { // revision 过期(刚有别人改过)⇒ 重取一次再写
255
+ try { return { ok: true, ns: await attempt(true), retried: true } } catch (e2) { e = e2 }
256
+ }
257
+ log(`写 DSH 设置失败:${e?.message ?? e}`)
258
+ return { ok: false, error: String(e?.message ?? e) }
259
+ }
260
+ }
261
+ /**
262
+ * ⭐ 落配置的**唯一入口**:插件自己那份 config.json + DSH 原生设置页那份,钳完范围一起写。
263
+ * (2026-10-17 复查修:`saveCfg` + `await saveCfgHost` 这对义务原来在三个调用点各抄一遍 ——
264
+ * 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
265
+ */
266
+ const persistCfg = async (patch) => {
267
+ const clean = clampPatch(patch)
268
+ saveCfg(clean)
269
+ const host = await saveCfgHost(clean)
270
+ return { clean, host }
271
+ }
272
+
273
+ ensureHomeFiles(pluginDir, stateDir, log)
274
+
275
+ // ⭐ 值班章程单独记账(跟 mcp_server.py 不一样:用户改过就得留着 —— 见 state.mjs 的 ensureCharter)
276
+ ensureCharter(pluginDir, stateDir, log)
277
+
278
+ /* ─────────────── MCP(内部起那个 Python MCP) ─────────────── */
279
+
280
+ let mcp = null
281
+ let mcpKey = ''
282
+ const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
283
+
284
+ const ensureMcp = async () => {
285
+ if (mcp?.running && mcpKey === mcpKeyNow()) return mcp
286
+ if (mcp) { mcp.stop(); mcp = null }
287
+ const script = join(stateDir, 'mcp_server.py')
288
+ if (!existsSync(script)) throw new Error(`家里没有 mcp_server.py(应有 ${script})—— 装上插件后重启一次 dsh 会自动铺好`)
289
+ mcpKey = mcpKeyNow()
290
+ mcp = new McpClient({
291
+ command: cfg.pythonPath || 'python',
292
+ args: [script],
293
+ cwd: stateDir, // 凭证落在家里 ✓
294
+ // ⭐ 这个内置的 Python MCP 会往 stdout 打中文(ensure_ascii=False);
295
+ // Windows 默认 GBK 的话,中文/emoji 一进管道就可能炸 ⇒ 钉死 UTF-8
296
+ env: pyEnv(),
297
+ log,
298
+ })
299
+ return mcp
300
+ }
301
+
302
+ const call = async (toolName, args, opts) => {
303
+ const client = await ensureMcp()
304
+ return client.call(toolName, args, opts)
305
+ }
306
+
307
+ const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
308
+
309
+ /* ─────────────── 工具 ─────────────── */
310
+
311
+ const textOutput = {
312
+ schema: { type: 'object', properties: {}, additionalProperties: true },
313
+ render: (_args, value) => [{
314
+ type: 'text',
315
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
316
+ }],
317
+ }
318
+
319
+ /**
320
+ * 工具返回值一律过一遍这里:**宿主只吃"无损 JSON"**,塞进去任何函数/循环引用/类实例都会报
321
+ * `value is not lossless JSON`(2026-10-14 第一次挂上就撞了一次)。
322
+ */
323
+ const safe = (v) => {
324
+ if (v === undefined || v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
325
+ try { return JSON.parse(JSON.stringify(v)) } catch { return String(v) }
326
+ }
327
+
328
+ const tool = (spec) => ctx.tools.register(defineTool({
329
+ ...spec,
330
+ output: textOutput,
331
+ async execute(args, exec) { return safe(await spec.execute(args, exec)) },
332
+ }))
333
+
334
+ tool({
335
+ name: 'bili_whoami',
336
+ description: '看 B站 那边登录的是谁(凭证是否有效、昵称、mid)。第一次用要先在「刷B站」设置页里扫码登录。',
337
+ parameters: {},
338
+ async execute() {
339
+ if (!hasCredential()) {
340
+ return { ok: false, loggedIn: false, hint: '还没登录:去 dsh 设置页的「刷B站」里点「扫码登录」(凭证会存在插件自己的家目录里)' }
341
+ }
342
+ const r = await call('bili_check_credential', {})
343
+ return { ok: true, ...(typeof r === 'object' ? r : { raw: r }) }
344
+ },
345
+ })
346
+
347
+ tool({
348
+ name: 'bili_search',
349
+ description: '搜 B站 视频(可排序)。返回标题 / BV号 / 播放量 / UP / 时长 等。\n'
350
+ + '⚠️ 刷一轮的用法:搜近 7 天的、按播放或最新都行,然后**读字幕原文**再判断(别看摘要下结论)。',
351
+ parameters: {
352
+ keyword: { type: 'string', description: '搜索关键词' },
353
+ num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
354
+ order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
355
+ },
356
+ async execute(args) {
357
+ return call('bili_search', {
358
+ keyword: String(args.keyword ?? ''),
359
+ num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
360
+ order: String(args.order || 'totalrank'),
361
+ })
362
+ },
363
+ })
364
+
365
+ tool({
366
+ name: 'bili_subtitle',
367
+ description: '取一个视频的字幕(AI 字幕)全文。**判断内容就靠它** —— 没有字幕时,'
368
+ + '若设置里开了「本地转写」再想别的办法,否则直接跳过这条。',
369
+ parameters: { bvid: { type: 'string', description: 'BV号' } },
370
+ async execute(args) { return call('bili_subtitle', { bvid: String(args.bvid ?? '') }) },
371
+ })
372
+
373
+ tool({
374
+ name: 'bili_comments',
375
+ description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。',
376
+ parameters: {
377
+ bvid: { type: 'string', description: 'BV号' },
378
+ num: { type: 'number', description: '取几条(默认 30)' },
379
+ },
380
+ async execute(args) {
381
+ return call('bili_comments', { bvid: String(args.bvid ?? ''), num: Math.max(1, Number(args.num) || 30) })
382
+ },
383
+ })
384
+
385
+ tool({
386
+ name: 'bili_reply',
387
+ description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
388
+ + '🔴 安全闸:设置页的「自动评论并 @ 你」**默认是关的**;关着的时候这个工具会拒绝 —— '
389
+ + '除非本人当场让你发(那时传 force: true)。',
390
+ parameters: {
391
+ bvid: { type: 'string', description: 'BV号' },
392
+ text: { type: 'string', description: '评论正文(短、干净、纯文本 @;别写敏感词)' },
393
+ rpid: { type: 'number', description: '要回复的评论 id(0/不填 = 发新评论)' },
394
+ force: { type: 'boolean', description: '本人当场让你发的,传 true 越过"自动评论关着"的闸' },
395
+ },
396
+ async execute(args) {
397
+ // dmMode==='only' 时本轮根本不该发公开评论 —— 在工具层也一并挡掉(除非当场 force)
398
+ const commentOn = Boolean(cfg.autoComment && cfg.mentionName && cfg.dmMode !== 'only')
399
+ if (!commentOn && !args.force) {
400
+ return {
401
+ ok: false,
402
+ blocked: true,
403
+ hint: cfg.dmMode === 'only'
404
+ ? '现在是「只私信」模式(dmMode=only)—— 规定不发公开评论,改成用 bili_send_message 私信。确实要发时传 force: true。'
405
+ : '要发评论得同时满足两条:设置里开着「自动评论并 @ 你」、并且填了「要 @ 谁」。或者本人当场让你发时传 force: true。',
406
+ }
407
+ }
408
+ return call('bili_reply', {
409
+ bvid: String(args.bvid ?? ''),
410
+ text: String(args.text ?? ''),
411
+ rpid: Number(args.rpid) || 0,
412
+ })
413
+ },
414
+ })
415
+
416
+ tool({
417
+ name: 'bili_status',
418
+ description: '看它今天的状态:跑了没跑、挑了几条、额度剩多少、报告在哪、登录过没有、下次窗口什么时候开。',
419
+ parameters: {},
420
+ async execute() {
421
+ refreshCfg()
422
+ const st = readJson(join(stateDir, 'state.json'), {})
423
+ const now = new Date()
424
+ return {
425
+ ok: true,
426
+ today: dayKey(now),
427
+ ranToday: st.day === dayKey(now) ? Number(st.count || 0) : 0,
428
+ pickedToday: countPickedToday(now),
429
+ perDay: cfg.perDay,
430
+ windows: Object.fromEntries(Object.entries(WINDOWS).map(([k, w]) => [k, {
431
+ label: w.label, start: w.start, end: w.end,
432
+ switchOn: Boolean(cfg[k]),
433
+ opening: inWindow(now, w),
434
+ }])),
435
+ peakValley: cfg.peakValley,
436
+ autoComment: cfg.autoComment,
437
+ dmMode: cfg.dmMode,
438
+ dmUid: Number(cfg.dmUid) || 0,
439
+ transcribe: cfg.transcribe,
440
+ loggedIn: hasCredential(),
441
+ home: stateDir,
442
+ report: join(outDir, `${dayKey(now)}.md`),
443
+ }
444
+ },
445
+ })
446
+
447
+ tool({
448
+ name: 'bili_run',
449
+ description: '**立刻跑一轮**(不等窗口):起一个 headless 会话,把 duty.md 交给它去刷、写报告。'
450
+ + '适合"我现在就想看一份"的场合。',
451
+ parameters: {},
452
+ async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
453
+ })
454
+
455
+ tool({
456
+ name: 'bili_login_start',
457
+ description: '开始 B站 扫码登录:出二维码(画成 PNG 存在插件家目录里,返回路径)和一个 key。\n'
458
+ + '用法:拿到 PNG 后用 `present` 把它推给本人(手机 B站 → 我的 → 扫一扫),再用 `bili_login_poll` 轮询。\n'
459
+ + '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
460
+ parameters: {},
461
+ async execute() {
462
+ const { key, url } = await loginStart()
463
+ const png = await renderQrPng(cfg.pythonPath, stateDir, url)
464
+ return {
465
+ ok: true, key, url, png,
466
+ hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去设置里点「建 venv 装依赖」(venv 里要有 qrcode)',
467
+ }
468
+ },
469
+ })
470
+
471
+ tool({
472
+ name: 'bili_login_poll',
473
+ description: '查扫码结果:waiting 还没扫 / scanned 扫了待确认 / ok 成功 / expired 过期。'
474
+ + '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
475
+ parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
476
+ async execute(args) {
477
+ const r = await loginPoll(String(args.key ?? ''))
478
+ if (r.state === 'ok') {
479
+ const file = saveCredential(stateDir, r.credential)
480
+ log(`登录成功,凭证已写入 ${file}`)
481
+ return { ok: true, state: 'ok', file }
482
+ }
483
+ return { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message }
484
+ },
485
+ })
486
+
487
+ tool({
488
+ name: 'bili_setup',
489
+ description: '给刷B站装运行环境:在插件家目录建一个**独立 venv**、装上那个 Python MCP 的依赖,装完自动把 `pythonPath` 指过去。\n'
490
+ + '**第一次用之前跑一次**。⭐ 它**起完就返回、不等装完**(几分钟的活),进度看家目录里的 `install.log`。',
491
+ parameters: {},
492
+ async execute() { return runInstall() },
493
+ })
494
+
495
+ tool({
496
+ name: 'bili_config',
497
+ description: '读 / 改「刷B站」的设置。不给 `patch` 就是读;给了就改(只认 schema 里的字段)。\n'
498
+ + '例:`{"perDay":5,"peakValley":true,"noon":true,"night":true,"autoComment":false,"transcribe":false,"keywords":["DeepSeek Harness"]}`;'
499
+ + '私信投递:`{"dmMode":"only","dmUid":12345678}`(dmMode = off / only / both)。',
500
+ parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
501
+ async execute(args) {
502
+ let host = null
503
+ if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
504
+ // ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
505
+ host = (await persistCfg(args.patch)).host
506
+ }
507
+ return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow(), host }
508
+ },
509
+ })
510
+
511
+ /* ─────────────── 跑一轮(跟老方案同一条路:dsh --profile headless <prompt>) ─────────────── */
512
+
513
+ const dutyStateFile = join(stateDir, 'state.json')
514
+ const bumpState = (patch) => {
515
+ const st = readJson(dutyStateFile, {})
516
+ const today = dayKey()
517
+ if (st.day !== today) { st.day = today; st.count = 0; st.picked = 0; st.windows = {} }
518
+ writeJson(dutyStateFile, { ...st, ...patch })
519
+ return st
520
+ }
521
+
522
+ /* ⭐ 「定量刷」承诺的是**每天挑几条**(2026-10-16 审查修 H1:原来 st.count 从没自增过,
523
+ * tick 那道 `count >= perDay` 的闸形同虚设 ⇒ noon + night 两个窗口各跑满一轮,
524
+ * 后一轮还会把前一轮的报告覆盖掉)。
525
+ * 数哪儿?数**今天的报告**里那些 `### BV号` 标题行(章程写死了这个格式)。
526
+ * ⚠️ 为什么不数「报告里出现的所有 BV」(2026-10-17 复查修的):报告里还会出现**没挑中**的 BV ——
527
+ * 「因无字幕跳过 N 条」那行、评论区里别人问起的视频、"判重表读不到"的说明 —— 全算进去的话,
528
+ * perDay 默认 5 ⇒ 一轮里出现 5 个 BV 就把当天额度吃光 ⇒ **傍晚那轮直接不跑** ✗。
529
+ * ⚠️ 围栏 ``` 里的内容一律不看:那一轮没写报告时 stubReportIfMissing 会把 duty.log 尾部贴进报告,
530
+ * 那里面的 BV 是"看过的",不是"挑出来的"。
531
+ * ⚠️ 数不出来(章程那条格式没照做)就当 0 条 —— 宁可少算(顶多多跑一轮、额度也写在提示词里),
532
+ * 也别把"提到过"算成"挑中"(那会让使用者当天再也拿不到内容)✓。 */
533
+ const PICK_RE = /^#{2,3}[ \t]*(BV[0-9A-Za-z]{10})/gm
534
+ const reportFileOf = (now = new Date()) => join(outDir, `${dayKey(now)}.md`)
535
+ const countPicks = (file) => {
536
+ try {
537
+ const text = readFileSync(file, 'utf8').replace(/```[\s\S]*?```/g, '')
538
+ const ids = new Set()
539
+ for (const m of text.matchAll(PICK_RE)) ids.add(m[1])
540
+ return ids.size
541
+ } catch { return 0 }
542
+ }
543
+ // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
544
+ const countPickedToday = (now = new Date()) => {
545
+ const st = readJson(dutyStateFile, {})
546
+ const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
547
+ return Math.max(fromState, countPicks(reportFileOf(now)))
548
+ }
549
+ const roundsToday = (now = new Date()) => {
550
+ const st = readJson(dutyStateFile, {})
551
+ return st.day === dayKey(now) ? Number(st.count || 0) : 0
552
+ }
553
+
554
+ // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
555
+ // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
556
+ let upgrading = false
557
+ const runUpgrade = () => {
558
+ if (upgrading) return { ok: true, busy: true, log: installLogFile, hint: '正在升级,别重复点' }
559
+ upgrading = true
560
+ appendInstall('\n=== ' + new Date().toISOString() + ' 开始升级(add dsh-bili-scroll)===\n')
561
+ ;(async () => {
562
+ try {
563
+ const dsh = resolveDsh()
564
+ // ⭐ 照抄那一轮的形状:resolveDsh 返回 { command, args, shell }
565
+ // (写错就退化成裸 'dsh',而宿主 PATH 里常常没有它 ⇒ 静默失败)
566
+ let cmd = dsh.command || 'dsh'
567
+ let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', 'dsh-bili-scroll']
568
+ if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
569
+ appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
570
+ const code = await spawnTo(cmd, args, { sink: appendInstall })
571
+ if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
572
+ else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
573
+ } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
574
+ })()
575
+ return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
576
+ }
577
+
578
+ const resolveDsh = () => {
579
+ const explicit = String(cfg.dshBin || '').trim()
580
+ if (explicit && existsSync(explicit)) return { command: process.execPath, args: [explicit], shell: false }
581
+ // 常见的全局安装位置(Windows 的 npm 前缀 / POSIX 的 lib)
582
+ const guesses = [
583
+ join(process.env.APPDATA || '', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
584
+ '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
585
+ '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
586
+ ].filter(Boolean)
587
+ // ⭐ 2026-10-16(issue #5 / #6 / #7):**桌面版是 Electron 安装包,不是 npm -g 装的** ⇒ 上面几条全找不到
588
+ // ⇒ 老逻辑退到 `dsh`(PATH 里没有这个命令)⇒ 那一轮必败、而且只留下一句 exit 1 ✗
589
+ // 桌面版把 dsh 打在应用里 ⇒ 按 `process.resourcesPath` 猜几个位置(找到就用;找不到就**明说**)
590
+ if (process.resourcesPath) {
591
+ for (const rel of [
592
+ ['app.asar.unpacked', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
593
+ ['app', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
594
+ ['node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
595
+ ]) {
596
+ const p = join(process.resourcesPath, ...rel)
597
+ if (existsSync(p)) return { command: process.execPath, args: [p], shell: false }
598
+ }
599
+ }
600
+ for (const g of guesses) if (existsSync(g)) return { command: process.execPath, args: [g], shell: false }
601
+ // ⚠️ 走到这儿就是真找不到 —— 一定要出声(静默失败比报错更坏)
602
+ log('⚠️ 找不到 dsh:桌面版多半不是 npm 全局安装的 ⇒ 请在设置里把「dsh 路径」指到 <应用目录>/…/@deepseek-ai/dsh/lib/bin.js,或改用 npm 装的 dsh')
603
+ return { command: 'dsh', args: [], shell: true }
604
+ }
605
+
606
+ const promptFor = () => {
607
+ // ⭐ 章程取哪一份:家里那份优先(ensureCharter 保证铺好;他改过的话就是他那份,升级的事由 ensureCharter 记账处理)
608
+ let duty = charterPath(pluginDir, stateDir)
609
+ // ⭐ 2026-10-17(issue #8④):章程里的 `<家目录>` 这种占位符会被模型读成 `~`,实测**整轮转写白跑** ✗
610
+ // ⇒ 在拼提示词这一步把**绝对路径**填进去,别让它猜(我们的老规矩:提示词一律给绝对路径)
611
+ try { duty = String(duty).split('<家目录>').join(stateDir) } catch { /* 替换失败就按原文用 */ }
612
+ const kws = Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : []
613
+ // ⭐ 开场先让它读**工作区**的 AGENTS.md —— 这一轮的工作目录就是使用者的工作区,
614
+ // AGENTS.md 本来就会自动注入;这里再点一句,保证它真按使用者的设定来(别人的设定同理)。
615
+ // ⭐ 那一轮的工作目录是**插件家**(沙箱只让它写自己工作目录里的东西 ✗),
616
+ // 所以"使用者的设定"由**我们自己读进来**喂给它 —— 它在宿主进程里跑,没有沙箱限制 ✓
617
+ let ownerDoc = ''
618
+ try {
619
+ // ⚠️ 2026-10-16 用户反馈("只要给人设就会走偏"):留空时我们以前退到 `process.cwd()`,
620
+ // 也就是 **dsh 进程自己的工作目录** —— 那对多数人根本不是他的工作区 ✗
621
+ // ⇒ 读错人设、甚至把别的目录的设定读进来 ✓
622
+ // ⭐ 现在的规矩:**留空 = 不读设定**(宁可按中性说话,也别猜错 ✓);要按人设就自己填工作区 ✓。
623
+ const wsDir = String(cfg.workspace || '').trim()
624
+ const agents = wsDir ? join(wsDir, 'AGENTS.md') : ''
625
+ if (agents && existsSync(agents)) {
626
+ const text = readFileSync(agents, 'utf8')
627
+ ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
628
+ + text.slice(0, 6000)
629
+ + '\n===== 设定结束 =====\n'
630
+ }
631
+ } catch { /* 读不到就算了,别因此跑不成 */ }
632
+ // ⭐ 「每天最多挑 N 条」是**全天**的账:上一轮已经挑过的要扣掉,这一轮只给剩下的额度
633
+ const quota = Math.max(0, Number(cfg.perDay) || 0)
634
+ const already = countPickedToday()
635
+ const left = Math.max(0, quota - already)
636
+ // ⭐ 投递方式(2026-10-17 加):dmMode = off(只评论)· only(只私信)· both(都发)
637
+ // commentOn 是"这一轮到底能不能发公开评论"的唯一判据(工具层的闸用的是同一套逻辑)
638
+ const commentOn = Boolean(cfg.autoComment && cfg.mentionName && cfg.dmMode !== 'only')
639
+ const dmOn = Boolean(cfg.dmMode !== 'off' && Number(cfg.dmUid) > 0)
640
+ return `你是这个账号的 B站 助手。`
641
+ + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
642
+ + ownerDoc
643
+ + (ownerDoc
644
+ ? ''
645
+ : '⭐ 这一轮**没有**拿到使用者的设定(他没填「工作区」,或那个目录里没有 `AGENTS.md`)⇒ **按中性说话**:别扮演任何人、也别自己编一套设定 ✓。')
646
+ + `然后读章程 ${duty},按里面的要求刷一轮 B站,`
647
+ + (cfg.reportLevel === 'brief'
648
+ ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
649
+ : cfg.reportLevel === 'off'
650
+ ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
651
+ : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
652
+ + (cfg.reportLevel === 'off' ? '' : `⚠️ 今天那份报告里可能已经有上一轮写的东西 —— 有就**追加到末尾**,别覆盖(配额按全天算)。`)
653
+ + (cfg.reportLevel === 'off' ? '' : '⭐ 报告里**每挑中一条**,第一行写成 `### BV号`(例:`### BV1xxxxxxx`)—— 宿主**就数这些标题行**算今天挑了几条:漏写当成没挑中,给只是提到 / 跳过的视频加上标题则当成挑中。')
654
+ + (already > 0
655
+ ? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
656
+ : `今天最多挑 ${quota} 条;`)
657
+ + `关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
658
+ + `本地转写:${cfg.transcribe
659
+ ? (asrReadyNow()
660
+ ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
661
+ : '**开着、但依赖没装好** ⇒ 这一轮没字幕的视频只能跳过')
662
+ : '关着(没字幕的视频就跳过)'};`
663
+ + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
664
+ + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
665
+ + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
666
+ + `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
667
+ + ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
668
+ + `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
669
+ + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${commentOn
670
+ ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
671
+ : (cfg.dmMode === 'only'
672
+ ? '关着的 —— 当前是「**只私信**」模式:**这一轮一条公开评论都不许发**,改走私信'
673
+ : '关着的(只写报告,别发评论)')}。`
674
+ + (dmOn
675
+ ? `⭐⭐ **私信投递**:用 bili_send_message 把这一轮的结果发到 UID **${Number(cfg.dmUid)}**。`
676
+ + `内容**别照抄评论那套**:把本轮每条值得的写成 **标题 + 链接 + 一句话理由**`
677
+ + `(私信是私人渠道、不占别人的地方,可以比评论长一点,但**别啰嗦**)。`
678
+ + `🔴🔴 **但私信有长度上限 —— 实测 500 字符就顶了**:`
679
+ + `490 字那条能发出去、再长就被接口拒(报 **code 21007**)✗。`
680
+ + `所以**一条私信务必控制在 500 字以内、最多塞 4~5 条** —— 宁可少放一条,也别整条被拒;`
681
+ + `⚠️ **被拒了就精简一次重发**(删理由、砍条目),**别原样无脑重试** ✗。`
682
+ + `🔴🔴 **私信是写给「人」看的,必须是干净的人话** —— **不许出现任何设置名 / 内部术语 / 台账口径** ✗:`
683
+ + `dmMode、only、aiReply、判据、白名单、prompt、token、复核 这类词,**一个都不许出现在私信正文里**;`
684
+ + `**更不许写「公开评论一条没发 / 楼中楼跳过」这种内部机制说明** ✗`
685
+ + `(那是在跟使用者念操作手册,可读性极差)。他关心的只有:**这次挑了哪几条、为什么值得**。`
686
+ + `(想交代"没别的了"就用一句人话,比如「今天没什么新的了」;`
687
+ + `⚠️ **不要**把「无字幕跳过 N 条」这种统计口径搬进私信 —— 那是**报告**里写的,不是私信里写的。)`
688
+ + `⚠️ 私信里**不用 @ 任何人**;⚠️ **一轮只发一条私信**(多条合并成一条);`
689
+ + `⚠️ 私信里**不要**写"本鱼 / 本鲸 / 主人 / 人家"那套;⚠️ 发完把"已私信"记进报告(**报告里**可以写口径)。`
690
+ + (cfg.dmMode === 'both' ? `⚠️ 现在是 **both**:公开评论照发(走下面那一整套规矩),私信**另外**再发一条。` : '')
691
+ : '')
692
+ + (cfg.replyToComments
693
+ ? `回复评论:开着(每轮最多回 ${Number(cfg.replyPerRound) || 1} 条)—— 只回「直接回复到你那条评论下面的人」,回完记进报告;找不到你那条评论就跳过,别硬找。`
694
+ : `回复评论:关着(一个字都不要回任何人 —— 有人回复你那条评论也不要理)。`)
695
+ + `最后用一句话汇报你挑到了什么。现在几点了自己看一眼系统时间。`
696
+ }
697
+
698
+ /**
699
+ * 给 headless 那一轮写一份 overlay —— 把 B站 MCP 挂上去。
700
+ * 🔴 为什么需要它:**某个 profile 能拿到什么工具,取决于它自己那层的 patch**
701
+ * (headless 里那两条 B站 MCP 就是当初手写进去的)。分发给别人时不能要求人家改 profile,
702
+ * 所以插件自己生成一份、跑的时候 `--patch` 带上(dsh 支持可重复的 `--patch`)。
703
+ */
704
+ /**
705
+ * ⭐ 值班轮能看到哪些 MCP 工具(2026-10-17 审查里的 H3)
706
+ * 起因:overlay 把整台 mcp_server.py 挂成 stdio server ⇒ 那一轮的模型手里握着**原生全套**工具
707
+ * (bili_reply / bili_crawl / bili_send_opus / bili_upload_video / bili_login…)——
708
+ * 宿主这边 autoComment / mentionName 两道闸只拦得住我们自己的 bili_reply 工具,拦不住它 ✗。
709
+ * 做法:白名单写进 overlay 的 env(mcp-client 会把它并进子进程环境),
710
+ * mcp_server.py 那头**不在白名单里的工具根本不注册** —— 连名字都不给它看见 ✓。
711
+ * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
712
+ */
713
+ const mcpWhitelist = () => {
714
+ const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
715
+ if (cfg.autoComment || cfg.replyToComments) list.push('bili_reply') // 只有真要发评论才给这只
716
+ return list
717
+ }
718
+
719
+ const ensureOverlay = () => {
720
+ if (!cfg.overlayMcp) return null
721
+ const file = join(stateDir, 'headless-overlay.yml')
722
+ const script = join(stateDir, 'mcp_server.py')
723
+ const posix = (p) => String(p).replace(/\\/g, '/')
724
+ const yml = [
725
+ '- insert:',
726
+ ' - id: mcp-bili-duty',
727
+ " name: '@deepseek-ai/dsh-mcp-client'",
728
+ ' config:',
729
+ ' serverName: bili-duty',
730
+ ' transport: stdio',
731
+ ` command: '${posix(cfg.pythonPath || 'python')}'`,
732
+ ' args:',
733
+ ` - '${posix(script)}'`,
734
+ ' env:',
735
+ ` BILI_MCP_TOOLS: '${mcpWhitelist().join(',')}'`,
736
+ '',
737
+ ].join('\n')
738
+ try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
739
+ }
740
+
741
+ /**
742
+ * ⭐ 评论发出后复核(2026-10-16 加的行 —— 起因是用户在评论区反馈
743
+ * 「**回执成功不等于公开可见,新号会被压审**」):
744
+ * 那一轮的 `bili_reply` 回"发送成功"只代表**接口收了**;新号 / 风控会让它进审核,
745
+ * 那时候连 @ 通知都不会推 ⇒ 插件以为发了、下一轮又不再发 ⇒ **一条都没到人**。
746
+ * 所以由**宿主**(有网络、有凭证、不受沙箱限制)去拉一次评论列表 / 楼中楼来核。
747
+ * ⚠️ 措辞守则:看不到写「**没查到**」,**绝不写"没发出去"**(翻页/热度排序都会漏)。
748
+ */
749
+ let verifying = false
750
+ const runVerify = async (why = 'manual') => {
751
+ if (verifying) return { ok: false, error: '上一次复核还没跑完,等它完事' }
752
+ verifying = true
753
+ try {
754
+ log(`评论复核开始(${why})`)
755
+ const r = await verifyRecent(stateDir, { log })
756
+ const f = appendVerifyToReport(stateDir, r.text)
757
+ log(`评论复核结束(${why}):查了 ${r.checked.length} 条${f ? `,已写进 ${f}` : ''}`)
758
+ return { ok: true, why, checked: r.checked, skipped: r.skipped, text: r.text, report: f || '' }
759
+ } catch (e) {
760
+ log(`评论复核失败(${why}):${e?.message ?? e}`)
761
+ return { ok: false, why, error: String(e?.message ?? e) }
762
+ } finally { verifying = false }
763
+ }
764
+
765
+ /**
766
+ * ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
767
+ * 起因(2026-10-16 用户实测反馈):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
768
+ * 最后 `out/` 是空的、判重表也没建 ⇒ **使用者完全看不到发生了什么**,也不知道白烧了钱 ✗。
769
+ * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补 ✓),不覆盖已有的报告 ✓。
770
+ */
771
+ const stubReportIfMissing = (code) => {
772
+ try {
773
+ if (String(cfg.reportLevel || 'full') === 'off') return
774
+ const f = join(outDir, `${dayKey()}.md`)
775
+ if (existsSync(f)) return
776
+ let tail = ''
777
+ try { tail = readFileSync(logFile, 'utf8').slice(-1500) } catch { /* 读不到就算了 */ }
778
+ writeFileSync(f, [
779
+ `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
780
+ '',
781
+ `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
782
+ '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
783
+ '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
784
+ '',
785
+ '```',
786
+ String(tail).trim(),
787
+ '```',
788
+ '',
789
+ ].join('\n'), 'utf8')
790
+ log('这一轮没写报告 ⇒ 宿主补了一份占位报告')
791
+ } catch (e) { log(`补占位报告失败:${e?.message ?? e}`) }
792
+ }
793
+
794
+ const runDuty = (why = 'window') => {
795
+ try {
796
+ refreshCfg() // ⭐ 设置页刚改过的值要立刻生效(config 是就地改的,快照会过期)
797
+ const dsh = resolveDsh()
798
+ const overlay = ensureOverlay()
799
+ const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
800
+ if (overlay) args.push('--patch', overlay)
801
+ args.push(promptFor())
802
+ // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
803
+ // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
804
+ // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
805
+ // 它写不了家目录里的 out/ 报告、整轮卡在"我是被沙箱关着的"上面打转 ✗;
806
+ // 现在改成"设定由插件读进来、内联进提示词" ✓,cwd 回到插件家 ✓)
807
+ const cwd = stateDir
808
+ log(`刷B站开跑(${why}):${dsh.command} ${args.slice(0, 3).join(' ')} … cwd=${cwd}`)
809
+ const child = spawn(dsh.command, args, {
810
+ cwd,
811
+ shell: dsh.shell,
812
+ windowsHide: true,
813
+ stdio: ['ignore', 'pipe', 'pipe'],
814
+ // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
815
+ // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
816
+ // ⭐ ELECTRON_RUN_AS_NODE:**桌面版(Electron)里 spawn 出去的 node 必须带这个**,
817
+ // 否则它当成"再开一个 Electron 应用"、什么都不干就以 0 退出(issue #5:0.17 秒、code 0、什么都没做)
818
+ env: pyEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
819
+ })
820
+ // ⚠️ 2026-10-16 issue #1(用户 `miaoxingyunduo` 贴了崩溃日志):`duty.log` 被别的进程占着时
821
+ // (`EBUSY`) `appendFileSync` 会抛异常 —— 而这里是 **stream 的 data 回调**,没人接
822
+ // ⇒ **未捕获异常 ⇒ 整个 DSH 宿主被杀**(所有会话一起没)。跟 10-14 定时器那次是同一类 ✗
823
+ // ⇒ ⭐ 规矩:**插件里任何写日志/写文件的地方都包好 try/catch**,再错也只该"这一轮没跑成" ✓
824
+ const pick = (buf) => {
825
+ try { appendFileSync(logFile, String(buf), 'utf8') } catch { /* 写不进去就丢,绝不能抛 */ }
826
+ }
827
+ child.stdout?.on('data', pick)
828
+ child.stderr?.on('data', pick)
829
+ child.on('exit', (code) => {
830
+ log(`刷B站结束(${why},code=${code})`)
831
+ stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
832
+ rotateLogIfBig() // ⭐ 日志别无声地长(三天 797KB ⇒ 一年约 100MB)
833
+ // ⭐ 这一轮挑了几条:从今天的报告里数(上一轮挑的还在同一份文件里 ⇒ 数出来就是全天的账)
834
+ try {
835
+ const st = readJson(dutyStateFile, {})
836
+ const prev = st.day === dayKey() ? Number(st.picked || 0) : 0
837
+ const picked = countPickedToday() // 报告是事实来源、state 只是兜底(跟闸门同一个算法)
838
+ if (picked > prev) { bumpState({ picked }); log(`今天挑到 ${picked} 条(上限 ${Number(cfg.perDay) || 0} 条)`) }
839
+ } catch (e) { log(`数挑了几条时出错(不影响这一轮):${e?.message ?? e}`) }
840
+ // ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
841
+ // 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
842
+ // 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
843
+ const verifyTimer = setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
844
+ verifyTimer.unref?.() // 别为了一次复核把宿主进程的退出拖住 90 秒
845
+ })
846
+ child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
847
+ return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
848
+ } catch (e) {
849
+ log(`刷B站开跑失败(${why}):${e?.stack ?? e}`)
850
+ return { started: false, why, error: String(e?.message ?? e) }
851
+ }
852
+ }
853
+
854
+ /* ─────────────── 谷时定时器 ───────────────
855
+ * 🔴 2026-10-14 事故(使用者那边表现为"DSH 突然崩了、网页 Failed to fetch (gateway/internal)"):
856
+ * 第一版是 `setInterval(tick, 60_000)`,回调里抛的 TypeError **没人接**,直接把整个宿主进程带走
857
+ * (`dsh: fatal uncaught exception`)。⇒ **定时器回调必须自己兜住所有异常**:
858
+ * 插件再错也只该"这一轮没跑成",绝不该杀宿主。
859
+ */
860
+ const tick = () => {
861
+ try {
862
+ refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
863
+ if (!cfg.peakValley) return
864
+ const now = new Date()
865
+ const st = readJson(dutyStateFile, {})
866
+ const today = dayKey(now)
867
+ // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
868
+ if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
869
+ // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
870
+ if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
871
+ for (const k of ['noon', 'night']) {
872
+ if (!cfg[k]) continue
873
+ if (!inWindow(now, WINDOWS[k])) continue
874
+ const key = `${today}:${k}`
875
+ if (st.windows?.[key]) continue
876
+ bumpState({ windows: { ...(st.windows || {}), [key]: true }, count: roundsToday(now) + 1, lastRunAt: now.toISOString(), lastWhy: k })
877
+ runDuty(`window:${k}`)
878
+ return
879
+ }
880
+ } catch (e) {
881
+ log(`定时器这一跳出错(已兜住,不影响宿主):${e?.stack ?? e}`)
882
+ }
883
+ }
884
+
885
+ try {
886
+ const timer = setInterval(tick, 60_000)
887
+ timer.unref?.() // 别因为这个定时器让进程不肯退出
888
+ ctx.effect(() => () => clearInterval(timer), 'bili-duty: 谷时定时器')
889
+ } catch (e) { log(`定时器没起来:${e?.message ?? e}`) }
890
+
891
+ /* ─────────────── HTTP(设置页用) ─────────────── */
892
+
893
+ const sendJson = (res, code, obj) => {
894
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
895
+ res.end(JSON.stringify(obj))
896
+ }
897
+ const readBody = (req) => new Promise((resolve) => {
898
+ let data = ''
899
+ let done = false
900
+ // ⚠️ 超过 1MB 得**先把 Promise 收掉**再掐断:只 destroy 的话它永远不 settle,
901
+ // 这个请求就挂在那儿(不回响应、也不释放)
902
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
903
+ req.on('data', (c) => { data += c; if (data.length > 1_000_000) { data = ''; finish({}); req.destroy() } })
904
+ req.on('end', () => { try { finish(JSON.parse(data || '{}')) } catch { finish({}) } })
905
+ req.on('error', () => finish({}))
906
+ })
907
+
908
+ const venvPython = () => process.platform === 'win32'
909
+ ? join(stateDir, '.venv', 'Scripts', 'python.exe')
910
+ : join(stateDir, '.venv', 'bin', 'python')
911
+
912
+ /**
913
+ * 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
914
+ *
915
+ * 🔴 为什么必须这样(2026-10-14 事故):第一版是 `async` 全程 await,
916
+ * 而"建 venv + pip install"在使用者这条网络上要**几分钟** ⇒ 那个工具调用一直挂着,
917
+ * **整个会话僵住、连"暂停"都卡,最后只能重启 dsh** ✗。
918
+ * 现在的做法:spawn 出去、输出追加进 `install.log`,函数立刻返回;
919
+ * 想知道装完没 —— 读日志,或看 `bili_duty_status` 里的 `installing`。
920
+ * 依赖走**国内镜像**(使用者那条网络 pip 直连只有 ~24KB/s ✗;uv + 清华源是几十秒 ✓)。
921
+ */
922
+ let installing = false
923
+ const installLogFile = join(stateDir, 'install.log')
924
+ const appendInstall = (s) => { try { appendFileSync(installLogFile, s, 'utf8') } catch { /* 写不进去就算了 */ } }
925
+
926
+ let uvOk = null
927
+ const haveUv = async () => {
928
+ if (uvOk) return true
929
+ const ok = await new Promise((resolve) => {
930
+ try {
931
+ const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
932
+ c.on('exit', (code) => resolve(code === 0))
933
+ c.on('error', () => resolve(false))
934
+ } catch { resolve(false) }
935
+ })
936
+ // ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
937
+ // 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
938
+ if (ok) uvOk = true
939
+ return ok
940
+ }
941
+
942
+ /** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
943
+ const probeCmd = (cmd) => new Promise((resolve) => {
944
+ try {
945
+ const c = spawn(cmd, ['--version'], { windowsHide: true, stdio: 'ignore' })
946
+ c.on('exit', (code) => resolve(code === 0))
947
+ c.on('error', () => resolve(false))
948
+ } catch { resolve(false) }
949
+ })
950
+
951
+ const spawnLogged = (cmd, args) => new Promise((resolve) => {
952
+ appendInstall(`\n$ ${cmd} ${args.join(' ')}\n`)
953
+ let c
954
+ try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: pyEnv() }) }
955
+ catch (e) { appendInstall(`起不来:${e?.message ?? e}\n`); return resolve(1) }
956
+ const w = (b) => appendInstall(String(b))
957
+ c.stdout?.on('data', w)
958
+ c.stderr?.on('data', w)
959
+ c.on('exit', (code) => { appendInstall(`\n--- exit ${code} ---\n`); resolve(code) })
960
+ c.on('error', (e) => { appendInstall(`起不来:${e?.message ?? e}\n`); resolve(1) })
961
+ })
962
+
963
+ // ───────── 本地转写(ASR):**独立环境**,别把主 venv 撑成 1GB ─────────
964
+ const asrDir = join(stateDir, '.venv-asr')
965
+ const asrLogFile = join(stateDir, 'asr-install.log')
966
+ const asrPython = () => join(asrDir, process.platform === 'win32' ? 'Scripts' : 'bin',
967
+ process.platform === 'win32' ? 'python.exe' : 'python')
968
+ const appendAsr = (s) => { try { appendFileSync(asrLogFile, String(s)) } catch { /* 日志写不了也不能炸 */ } }
969
+ // ⭐ 转写的临时目录必须由**宿主**预先建好:值班那一轮跑在沙箱里,
970
+ // 而沙箱只允许写"该子进程启动前就已存在"的目录(2026-10-14 那一轮自己挖出来的规则)
971
+ const asrTmp = join(stateDir, 'tmp', 'asr')
972
+ try { mkdirSync(asrTmp, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
973
+
974
+ // ⭐ 判重表也由**宿主**预先建好(两个原因):
975
+ // ① 那一轮被沙箱关着,"新建文件"会被拒(2026-10-14 那条规矩);
976
+ // ② 它读不出判重表 ⇒ 以为"没记录过" ⇒ 同一批视频反复推送(用户实测反馈 2026-10-16)。
977
+ const seenFile = join(stateDir, 'seen.md')
978
+ try {
979
+ if (!existsSync(seenFile)) {
980
+ writeFileSync(seenFile, [
981
+ '# 刷B站 · 判重表(宿主预建,那一轮只往后面追加)',
982
+ '',
983
+ '## 评论记录(BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字)',
984
+ '',
985
+ '| BV | rpid | root(楼中楼才填) | 时间 | 正文前 20 字 |',
986
+ '| --- | --- | --- | --- | --- |',
987
+ '',
988
+ '## 看过的视频',
989
+ '',
990
+ ].join('\n'), 'utf8')
991
+ }
992
+ } catch { /* 建不了也别拦着插件加载 */ }
993
+
994
+ // ⭐ 归档目录:那一轮**被沙箱关着**,只写得了"启动前就已存在"的目录 ⇒ 宿主预建 ✓
995
+ // (2026-10-16 主人要的"它看过什么"清单:报告是给人看的,归档是给检索用的 ✓)
996
+ const digestDir = join(stateDir, 'digest')
997
+ try { mkdirSync(digestDir, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
998
+
999
+ /**
1000
+ * ⭐ 日志轮转(2026-10-16 量出来的:`duty.log` 三天就 797 KB、没有轮转 ⇒ 一年约 100 MB ✗)
1001
+ * 超过 2MB 就滚成 `duty.log.1`(只留一份),别让它在 C 盘上无声地长。
1002
+ */
1003
+ const rotateLogIfBig = (limit = 2 * 1024 * 1024) => {
1004
+ try {
1005
+ if (!existsSync(logFile) || statSync(logFile).size < limit) return
1006
+ const bak = logFile + '.1'
1007
+ try { rmSync(bak, { force: true }) } catch { /* 删不掉就算了 */ }
1008
+ renameSync(logFile, bak)
1009
+ appendFileSync(logFile, `${new Date().toISOString()} 日志超过 2MB,旧的滚到 duty.log.1\n`, 'utf8')
1010
+ } catch { /* 轮转失败不该影响跑一轮 */ }
1011
+ }
1012
+ let installingAsr = false
1013
+
1014
+ /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
1015
+ const resolveUv = async () => {
1016
+ const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
1017
+ if (await haveUv()) return 'uv'
1018
+ if (existsSync(bundled)) return bundled
1019
+ appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
1020
+ const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
1021
+ if (got) appendInstall('uv 已就位:' + got + '\n')
1022
+ return got
1023
+ }
1024
+
1025
+ /** 跟 spawnLogged 一样,但可以写进别的日志、可以带环境变量 */
1026
+ const spawnTo = (cmd, args, { sink = appendInstall, env = null } = {}) => new Promise((resolve) => {
1027
+ sink(`\n$ ${cmd} ${args.join(' ')}\n`)
1028
+ let c
1029
+ try {
1030
+ c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
1031
+ env: pyEnv(env || {}) })
1032
+ } catch (e) { sink(`起不来:${e?.message ?? e}\n`); return resolve(1) }
1033
+ const w = (b) => sink(String(b))
1034
+ c.stdout?.on('data', w)
1035
+ c.stderr?.on('data', w)
1036
+ c.on('exit', (code) => { sink(`\n--- exit ${code} ---\n`); resolve(code) })
1037
+ c.on('error', (e) => { sink(`起不来:${e?.message ?? e}\n`); resolve(1) })
1038
+ })
1039
+
1040
+ /** 装"本地转写"那一套:独立环境 + faster-whisper + yt-dlp + 先把 Whisper 模型下好 */
1041
+ const runAsrInstall = () => {
1042
+ if (installingAsr) return { ok: true, busy: true, log: asrLogFile, hint: '正在装转写依赖,别重复起' }
1043
+ installingAsr = true
1044
+ appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
1045
+ ;(async () => {
1046
+ try {
1047
+ const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1048
+ const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
1049
+ const py = asrPython()
1050
+ const uvCmd = await resolveUv()
1051
+ if (!existsSync(py)) {
1052
+ // --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
1053
+ if (uvCmd) await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
1054
+ else await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
1055
+ }
1056
+ if (!existsSync(py)) {
1057
+ appendAsr('建环境失败 —— 先点上面的「装依赖」,把 uv 或 Python 准备好\n')
1058
+ log('转写:建环境失败'); return
1059
+ }
1060
+ appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
1061
+ let code = 1
1062
+ // ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
1063
+ // 而 av 17+ 不再接受这个参数 ⇒ 装了最新的 av 反而转不了(2026-10-14 在主人机器上实证)
1064
+ const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
1065
+ if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
1066
+ if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
1067
+ if (code !== 0) { appendAsr('装包失败 —— 看上面的报错\n'); log('转写:装包失败'); return }
1068
+ // ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
1069
+ const model = String(cfg.asrModel || 'small')
1070
+ // 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
1071
+ const hfHome = join(stateDir, '.hf-cache')
1072
+ try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
1073
+ appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
1074
+ const code2 = await spawnTo(py, ['-c',
1075
+ '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("模型就绪")',
1076
+ model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
1077
+ appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
1078
+ log(`装转写依赖结束:exit=${code}/${code2}`)
1079
+ } catch (e) {
1080
+ appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
1081
+ log(`装转写依赖崩了:${e?.message ?? e}`)
1082
+ } finally { installingAsr = false }
1083
+ })()
1084
+ return { ok: true, started: true, log: asrLogFile, hint: '正在后台装转写依赖 —— 进度显示在下面(要下几百 MB 模型,慢一点正常)' }
1085
+ }
1086
+
1087
+ const runInstall = () => {
1088
+ if (installing) return { ok: true, busy: true, log: installLogFile, hint: '正在装,别重复起' }
1089
+ installing = true
1090
+ appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
1091
+ ;(async () => {
1092
+ try {
1093
+ const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
1094
+ const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
1095
+ const venvPy = venvPython()
1096
+ const py = String(cfg.pythonPath || 'python')
1097
+ const uvCmd = await resolveUv() // ⭐ 系统里的 → 家里下好的 → 自己下一份
1098
+ if (!existsSync(venvPy) && !uvCmd && !(await probeCmd(py))) {
1099
+ const msg = '这台机器上没有 uv,也没有可用的 python(试过 "' + py + '"),而且我自动下 uv 也没成功'
1100
+ + '(大概是网络到不了 GitHub / PyPI 镜像)。⇒ 请手动装 uv(单个 exe:https://docs.astral.sh/uv/)'
1101
+ + '或 Python 3.10+(安装时勾 Add to PATH),然后回来再点一次「装依赖」。'
1102
+ appendInstall(msg + '\n'); log(msg); installing = false; return
1103
+ }
1104
+ if (!existsSync(venvPy)) {
1105
+ // ⭐ 2026-10-17(issue #8③):ASR 那个环境带了 --seed,主环境却漏了 ⇒ uv 建的 venv 默认不含 pip,
1106
+ // 一旦镜像失败、回退 `python -m pip` 就是 `No module named pip` ⇒ 连补救的路都没有 ✗
1107
+ if (uvCmd) await spawnLogged(uvCmd, ['venv', '--seed', join(stateDir, '.venv')])
1108
+ if (!existsSync(venvPy) && !uvCmd) await spawnLogged(py, ['-m', 'venv', join(stateDir, '.venv')])
1109
+ }
1110
+ let code = 1
1111
+ // ⭐ 2026-10-17(issue #8②):镜像也会整站拒绝(清华对部分 IP 回 403,而 pip 的报错写成"这个包不存在",很误导)
1112
+ // ⇒ 一个镜像失败就换下一个,别让用户在一个死镜像上反复撞
1113
+ const mirrors = [mirror, 'https://mirrors.ustc.edu.cn/pypi/simple', 'https://mirrors.aliyun.com/pypi/simple']
1114
+ for (let i = 0; i < mirrors.length && code !== 0; i++) {
1115
+ const m = mirrors[i]
1116
+ if (i > 0) appendInstall(`\n--- 换个镜像再试:${m} ---\n`)
1117
+ if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', m, '-r', reqFile])
1118
+ if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', m, '-r', reqFile])
1119
+ }
1120
+ if (code === 0 && existsSync(venvPy)) {
1121
+ await persistCfg({ pythonPath: venvPy }) // ⭐ 不然设置页里显式设过 pythonPath 的话,这里就白写了
1122
+ appendInstall(`已把 pythonPath 设为 ${venvPy}\n`)
1123
+ }
1124
+ appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
1125
+ log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
1126
+ } catch (e) {
1127
+ appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
1128
+ log(`装依赖崩了:${e?.message ?? e}`)
1129
+ } finally { installing = false }
1130
+ })()
1131
+ return { ok: true, started: true, log: installLogFile, hint: '正在后台装依赖 —— 进度就显示在下面(走国内镜像,通常几十秒到一两分钟)' }
1132
+ }
1133
+
1134
+ /** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
1135
+ const checkUp = async () => {
1136
+ const out = { ok: false, steps: [] }
1137
+ if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
1138
+ if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
1139
+ try {
1140
+ const who = await call('bili_check_credential', {})
1141
+ out.who = (typeof who === 'object' ? JSON.stringify(who) : String(who)).slice(0, 140)
1142
+ out.steps.push('登录有效')
1143
+ } catch (e) { out.error = '登录检查失败:' + (e?.message ?? e); return out }
1144
+ try {
1145
+ const s = await call('bili_search', { keyword: 'DeepSeek Harness', num: 3 })
1146
+ const t = typeof s === 'string' ? s : JSON.stringify(s)
1147
+ out.sample = t.slice(0, 160)
1148
+ out.steps.push('能搜到东西')
1149
+ out.ok = t.length > 2
1150
+ if (!out.ok) out.error = '搜到了空结果'
1151
+ } catch (e) { out.error = '搜索失败:' + (e?.message ?? e) }
1152
+ return out
1153
+ }
1154
+
1155
+ let updateCache = null // { at, latest } —— 检查更新的结果缓存
1156
+
1157
+ const status = () => {
1158
+ refreshCfg()
1159
+ const st = readJson(dutyStateFile, {})
1160
+ const today = dayKey()
1161
+ return {
1162
+ ok: true,
1163
+ home: stateDir,
1164
+ config: cfg,
1165
+ windows: WINDOWS,
1166
+ today,
1167
+ ranToday: st.day === today ? Number(st.count || 0) : 0, // 今天跑了几轮
1168
+ pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
1169
+ lastRunAt: st.lastRunAt || null,
1170
+ lastWhy: st.lastWhy || null,
1171
+ loggedIn: hasCredential(),
1172
+ venvReady: venvReadyNow(),
1173
+ version: myVersion(),
1174
+ installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
1175
+ installing,
1176
+ // ── 本地转写那一套的状态(设置页要显示进度)──
1177
+ asrReady: asrReadyNow(),
1178
+ // 模型下没下(免得界面写"就绪"、其实还得现下)
1179
+ asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
1180
+ asrInstalling: installingAsr,
1181
+ asrLog: asrLogFile,
1182
+ asrTail: (() => { try { return readFileSync(asrLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1183
+ installLog: installLogFile,
1184
+ // ⭐ 让设置页能显示"装到哪了"(陌生人原来只看到一句"后台在装" ✗)
1185
+ installTail: (() => { try { return readFileSync(installLogFile, 'utf8').slice(-1400) } catch { return '' } })(),
1186
+ report: join(outDir, `${today}.md`),
1187
+ log: logFile,
1188
+ }
1189
+ }
1190
+
1191
+ const handle = async (req, res, url) => {
1192
+ refreshCfg()
1193
+ const path = url.pathname
1194
+ if (req.method === 'GET' && path === '/api/bili-duty/status') return sendJson(res, 200, status())
1195
+
1196
+ if (req.method === 'GET' && path === '/api/bili-duty/config') {
1197
+ return sendJson(res, 200, { ok: true, config: cfg, windows: WINDOWS, home: stateDir })
1198
+ }
1199
+
1200
+ if (req.method === 'POST' && path === '/api/bili-duty/config') {
1201
+ const body = await readBody(req)
1202
+ const patch = {}
1203
+ for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
1204
+ 'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
1205
+ 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound']) {
1206
+ if (k in body) patch[k] = body[k]
1207
+ }
1208
+ // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
1209
+ // ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
1210
+ if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
1211
+ if ('dmUid' in patch) patch.dmUid = Math.max(0, Math.min(Number(patch.dmUid) || 0, Number.MAX_SAFE_INTEGER))
1212
+ const { clean, host } = await persistCfg(patch)
1213
+ log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
1214
+ return sendJson(res, 200, { ok: true, config: cfg, host })
1215
+ }
1216
+
1217
+ if (req.method === 'POST' && path === '/api/bili-duty/run') {
1218
+ return sendJson(res, 200, { ok: true, ...runDuty('manual') })
1219
+ }
1220
+
1221
+ // 建 venv + 装依赖(**不等它**:几分钟的活,进度写 install.log)
1222
+ if (req.method === 'POST' && path === '/api/bili-duty/install') {
1223
+ return sendJson(res, 200, runInstall())
1224
+ }
1225
+
1226
+ // 装"本地转写"那套(独立环境:faster-whisper + yt-dlp + Whisper 模型)
1227
+ if (req.method === 'POST' && path === '/api/bili-duty/install-asr') {
1228
+ return sendJson(res, 200, runAsrInstall())
1229
+ }
1230
+
1231
+ // ⭐ 「复核评论」:拿判重表里最近的记录,去 B站 核一遍到底公开可见没有
1232
+ // (设置页那个按钮、以及每轮结束 90 秒后自动跑一次,都走这里)
1233
+ if (req.method === 'POST' && path === '/api/bili-duty/verify') return sendJson(res, 200, await runVerify('manual'))
1234
+
1235
+ // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
1236
+ if (req.method === 'GET' && path === '/api/bili-duty/report') {
1237
+ const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
1238
+ const file = join(outDir, day + '.md')
1239
+ let text = ''
1240
+ let exists = false
1241
+ try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
1242
+ return sendJson(res, 200, { ok: true, day, exists, file, text })
1243
+ }
1244
+
1245
+ // 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
1246
+ if (req.method === 'POST' && path === '/api/bili-duty/open') {
1247
+ const body = await readBody(req)
1248
+ const dir = String(body?.what || 'out') === 'home' ? stateDir : outDir
1249
+ if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
1250
+ // ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
1251
+ // try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
1252
+ // ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
1253
+ // 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
1254
+ const attempts = [
1255
+ ['cmd.exe', ['/c', 'start', '', dir]],
1256
+ [join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
1257
+ ['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
1258
+ ]
1259
+ let lastErr = ''
1260
+ for (const [cmd, args] of attempts) {
1261
+ const err = await new Promise((resolve) => {
1262
+ let done = false
1263
+ let child
1264
+ try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
1265
+ child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
1266
+ setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
1267
+ })
1268
+ if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
1269
+ lastErr = err
1270
+ log('打开文件夹失败(' + cmd + '):' + err)
1271
+ }
1272
+ return sendJson(res, 200, { ok: false, error: lastErr, dir })
1273
+ }
1274
+
1275
+ // 「一键升级」:点了它自己跑 add(⚠️ 之后要重启才生效)
1276
+ if (req.method === 'POST' && path === '/api/bili-duty/upgrade') return sendJson(res, 200, runUpgrade())
1277
+
1278
+ // 「检查更新」:⚠️ 只在用户点按钮时才联网(不偷偷发请求 ✓),结果缓存一天 ✓
1279
+ if (req.method === 'GET' && path === '/api/bili-duty/update') {
1280
+ const out = { ok: true, current: myVersion(), latest: '', hasNew: false, page: 'https://www.npmjs.com/package/dsh-bili-scroll' }
1281
+ try {
1282
+ if (updateCache && Date.now() - updateCache.at < 24 * 3600 * 1000) out.latest = updateCache.latest
1283
+ else {
1284
+ const ctrl = new AbortController()
1285
+ const timer = setTimeout(() => ctrl.abort(), 8000)
1286
+ const r = await fetch('https://registry.npmjs.org/dsh-bili-scroll/latest', { signal: ctrl.signal })
1287
+ clearTimeout(timer)
1288
+ const j = await r.json()
1289
+ out.latest = String(j.version || '')
1290
+ updateCache = { at: Date.now(), latest: out.latest }
1291
+ }
1292
+ } catch (e) { out.ok = false; out.error = String(e?.message ?? e); return sendJson(res, 200, out) }
1293
+ // ⭐ 要比谁更新,不能只比不相等 —— 否则本地版本高于 npm 时会误报有新版
1294
+ const vnum = (v) => String(v || '').split('.').map((x) => parseInt(x, 10) || 0)
1295
+ const cmp = (a, b) => {
1296
+ const x = vnum(a), y = vnum(b)
1297
+ for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) }
1298
+ return 0
1299
+ }
1300
+ out.hasNew = Boolean(out.latest) && cmp(out.latest, out.current) > 0
1301
+ return sendJson(res, 200, out)
1302
+ }
1303
+
1304
+ // 「测一下」:真跑一次,回答"能不能用"
1305
+ if (req.method === 'POST' && path === '/api/bili-duty/check') {
1306
+ return sendJson(res, 200, await checkUp())
1307
+ }
1308
+
1309
+ // ── 扫码登录(设置页用)──
1310
+ if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
1311
+ const { key, url } = await loginStart()
1312
+ const png = await renderQrPng(cfg.pythonPath, stateDir, url)
1313
+ let qr = null
1314
+ if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
1315
+ log(`出码:${url}${qr ? '(已画成 PNG)' : '(没画出 PNG —— venv 里大概还没有 qrcode)'}`)
1316
+ return sendJson(res, 200, { ok: true, key, url, qr })
1317
+ }
1318
+
1319
+ if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
1320
+ const body = await readBody(req)
1321
+ const r = await loginPoll(String(body.key || ''))
1322
+ if (r.state === 'ok') {
1323
+ const file = saveCredential(stateDir, r.credential)
1324
+ log(`登录成功,凭证已写入 ${file}`)
1325
+ return sendJson(res, 200, { ok: true, state: 'ok', file })
1326
+ }
1327
+ return sendJson(res, 200, { ok: r.state !== 'unknown', state: r.state, code: r.code, message: r.message })
1328
+ }
1329
+
1330
+ return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
1331
+ }
1332
+
1333
+ try {
1334
+ ctx.effect(() => ctx.webServer.register({
1335
+ kind: 'prefix',
1336
+ path: '/api/bili-duty',
1337
+ handler: async (req, res) => {
1338
+ try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
1339
+ sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
1340
+ }
1341
+ },
1342
+ }), 'bili-duty: /api/bili-duty 路由')
1343
+ } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
1344
+
1345
+ ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
1346
+ log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
1347
+ }