dsh-bili-scroll 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
  > - 需要 **dsh ≥ `0.1.7-rc.1`**(本机是 `0.1.7-rc.2`)。⚠️ 版本不满足时,**内核会静默跳过这个插件** —— 装了跟没装一样,不会报错。
5
5
  > - **空间**:不装「本地转写」约 **84 MB**;装了转写约 **1 GB**(其中 Whisper 模型 538 MB,可在设置里换更小的模型)。
6
6
  > - 装完**必须重启一次** dsh(插件在 bundle 层组装)。
7
+ >
8
+ > ⚠️ **装的是旧版吗?** 如果设置页里那个按钮写的是「**重装依赖**」(而不是「**重装主依赖**」),说明你装的是 0.1.0 ——
9
+ > 跑一次 `dsh plugin --profile web add dsh-bili-scroll` 升级就好(那一版建 Python 环境时缺一个参数,
10
+ > 依赖装不上、二维码也画不出来,重装多少次都一样)。
7
11
 
8
12
  > DSH 插件:**每天替你刷一轮 B站** —— 它自己搜、**读字幕原文**判断、把值得的写成报告,特别值得的会留一条评论 **@ 你**。
9
13
  > **你收到的每一条 @,都是它先筛过一遍的。**
package/client.js CHANGED
@@ -134,13 +134,15 @@ window.__ModuleLoader__.load({
134
134
  const [msgTone, setMsgTone] = React.useState('')
135
135
  const [kws, setKws] = React.useState(null)
136
136
  const [ws, setWs] = React.useState(null)
137
+ const [rl, setRl] = React.useState(null) // 日报形态:full / brief / off
137
138
  // ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
138
139
  // ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
139
140
  const [perDay, setPerDay] = React.useState(null)
140
141
  const [perRound, setPerRound] = React.useState(null)
141
142
  const [mention, setMention] = React.useState(null)
142
143
  const [login, setLogin] = React.useState(null)
143
- const [runNote, setRunNote] = React.useState(null) // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
144
+ const [runNote, setRunNote] = React.useState(null)
145
+ const [report, setReport] = React.useState(null) // 非 null = 报告已展开 // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
144
146
  const [loginState, setLoginState] = React.useState('')
145
147
  const [loginTone, setLoginTone] = React.useState('')
146
148
 
@@ -148,6 +150,7 @@ window.__ModuleLoader__.load({
148
150
  React.useEffect(() => {
149
151
  if (cfg && kws === null) setKws((cfg.keywords || []).join('\n'))
150
152
  if (cfg && ws === null) setWs(cfg.workspace || '')
153
+ if (cfg && rl === null) setRl(cfg.reportLevel || 'full')
151
154
  if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
152
155
  if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
153
156
  if (cfg && mention === null) setMention(cfg.mentionName || '')
@@ -170,6 +173,33 @@ window.__ModuleLoader__.load({
170
173
  finally { setBusy('') }
171
174
  }
172
175
 
176
+ const onShowReport = async () => {
177
+ if (report !== null) { setReport(null); return } // 再点一次收起
178
+ setBusy('report')
179
+ try {
180
+ const r = await jget('/report')
181
+ setReport(r && r.exists ? (r.text || '(报告是空的)') : '今天还没有报告 —— 跑一轮之后就有了')
182
+ } catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
183
+ }
184
+
185
+ const copyText = async (text) => {
186
+ const v = String(text || '')
187
+ try {
188
+ await navigator.clipboard.writeText(v)
189
+ say('已复制:' + v, 'good')
190
+ } catch {
191
+ say('复制失败 —— 路径是:' + v) // 兜底:至少把路径显示出来,手动选中也能复制
192
+ }
193
+ }
194
+
195
+ const onOpenFolder = async (what) => {
196
+ try {
197
+ const r = await jpost('/open', { what })
198
+ if (r && r.ok) say('已请系统打开:' + (r.opened || '') + '(要是没看到窗口,用旁边的「复制报告路径」——那个一定成)', 'good')
199
+ else say('打不开(' + ((r && r.error) || '未知原因') + ')—— 你可以手动打开这个路径:' + ((r && r.dir) || ''))
200
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
201
+ }
202
+
173
203
  const onCheck = async () => {
174
204
  setBusy('check')
175
205
  say('')
@@ -355,6 +385,14 @@ window.__ModuleLoader__.load({
355
385
  'data-bd-input': '', value: ws ?? '', placeholder: '留空 = dsh 自己的工作目录',
356
386
  onChange: (e) => setWs(e.target.value),
357
387
  }),
388
+ h('div', { 'data-bd-label': '' }, '日报写多细'),
389
+ h('select', {
390
+ 'data-bd-input': '', value: rl ?? 'full',
391
+ onChange: (e) => { setRl(e.target.value); patch({ reportLevel: e.target.value }, '日报形态') },
392
+ },
393
+ h('option', { value: 'full' }, '完整 —— 每条:标题 / UP / 为什么值得(默认)'),
394
+ h('option', { value: 'brief' }, '薄 —— 每条一两行,只留值不值得 + 链接'),
395
+ h('option', { value: 'off' }, '不写 —— 只发评论,不生成日报文件')),
358
396
  h('div', { 'data-bd-row': '' },
359
397
  h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onSaveText }, '保存'),
360
398
  msg ? h('div', { 'data-bd-msg': '', 'data-good': msgTone === 'good' ? '' : undefined, 'data-warn': msgTone === 'warn' ? '' : undefined }, msg) : null)),
@@ -387,7 +425,14 @@ window.__ModuleLoader__.load({
387
425
  h('div', { 'data-bd-kv': '' },
388
426
  `家目录:${st.home}`,
389
427
  h('br'),
390
- `今天的报告:${st.report}`)),
428
+ `今天的报告:${st.report}`),
429
+ h('div', { 'data-bd-row': '' },
430
+ h('button', { 'data-bd-btn': '', disabled: busy === 'report', onClick: onShowReport },
431
+ report !== null ? '收起报告' : '看今天的报告'),
432
+ h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
433
+ h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
434
+ h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
435
+ report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
391
436
  )
392
437
  }
393
438
 
package/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  import z from '@deepseek-ai/schemastery'
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import { spawn } from 'node:child_process'
20
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
20
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
21
21
  import { join, dirname } from 'node:path'
22
22
  import { fileURLToPath } from 'node:url'
23
23
  import { McpClient } from './lib/mcp.mjs'
@@ -64,11 +64,34 @@ export const Config = z.object({
64
64
  overlayMcp: z.boolean().default(true).extra('volatile', true),
65
65
  /** 跑 headless 时的工作目录 */
66
66
  workspace: z.string().default('').extra('volatile', true),
67
+ /** 日报形态:full=完整(现状)· brief=每条一两行 · off=不写报告(只发评论) */
68
+ reportLevel: z.string().default('full').extra('volatile', true),
67
69
  })
68
70
 
69
71
  export function apply(ctx, config) {
70
72
  const pluginDir = dirname(fileURLToPath(import.meta.url))
71
73
  const stateDir = stateDirOf(ctx)
74
+
75
+ // ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
76
+ // (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
77
+ const sitePkgs = (venvRoot) => {
78
+ const win = join(venvRoot, 'Lib', 'site-packages')
79
+ if (existsSync(win)) return win
80
+ try {
81
+ const lib = join(venvRoot, 'lib')
82
+ for (const d of readdirSync(lib)) {
83
+ if (!d.startsWith('python')) continue
84
+ const p = join(lib, d, 'site-packages')
85
+ if (existsSync(p)) return p
86
+ }
87
+ } catch { /* 没有就算了 */ }
88
+ return win
89
+ }
90
+ const hasPkg = (venvRoot, prefix) => {
91
+ try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
92
+ }
93
+ const venvReadyNow = () => existsSync(venvPython()) && hasPkg(join(stateDir, '.venv'), 'bilibili_api')
94
+ const asrReadyNow = () => existsSync(asrPython()) && hasPkg(asrDir, 'faster_whisper')
72
95
  const cfgFile = join(stateDir, 'config.json')
73
96
  const logFile = join(stateDir, 'duty.log')
74
97
  const outDir = join(stateDir, 'out')
@@ -92,7 +115,7 @@ export function apply(ctx, config) {
92
115
  */
93
116
  const DEFAULTS = {
94
117
  perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
95
- keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '',
118
+ keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full',
96
119
  }
97
120
  const normalize = (src, base) => {
98
121
  const out = { ...base }
@@ -338,7 +361,7 @@ export function apply(ctx, config) {
338
361
  parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
339
362
  async execute(args) {
340
363
  if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) saveCfg(args.patch)
341
- return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: existsSync(venvPython()) }
364
+ return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow() }
342
365
  },
343
366
  })
344
367
 
@@ -394,7 +417,11 @@ export function apply(ctx, config) {
394
417
  + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
395
418
  + ownerDoc
396
419
  + `然后读章程 ${duty},按里面的要求刷一轮 B站,`
397
- + `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`
420
+ + (cfg.reportLevel === 'brief'
421
+ ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
422
+ : cfg.reportLevel === 'off'
423
+ ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
424
+ : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
398
425
  + `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
399
426
  + `本地转写是${cfg.transcribe ? '开着的(没字幕就用家目录里的 asr.py 转,命令见章程)' : '关着的(没字幕就跳过)'};`
400
427
  + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
@@ -636,7 +663,7 @@ export function apply(ctx, config) {
636
663
  const code2 = await spawnTo(py, ['-c',
637
664
  'import os,sys;os.environ["HF_HUB_DISABLE_XET"]="1";from faster_whisper import WhisperModel;WhisperModel(sys.argv[1], device="cpu", compute_type="int8");print("模型就绪")',
638
665
  model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
639
- appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${existsSync(py)})===\n`)
666
+ appendAsr(`=== 结束(装包 exit ${code},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
640
667
  log(`装转写依赖结束:exit=${code}/${code2}`)
641
668
  } catch (e) {
642
669
  appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
@@ -670,8 +697,8 @@ export function apply(ctx, config) {
670
697
  let code = 1
671
698
  if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
672
699
  if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
673
- if (existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
674
- appendInstall(`=== 结束(exit ${code},venvReady=${existsSync(venvPy)})===\n`)
700
+ if (code === 0 && existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
701
+ appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
675
702
  log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
676
703
  } catch (e) {
677
704
  appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
@@ -684,7 +711,7 @@ export function apply(ctx, config) {
684
711
  /** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
685
712
  const checkUp = async () => {
686
713
  const out = { ok: false, steps: [] }
687
- if (!existsSync(venvPython())) { out.error = '环境还没装:先点「装主依赖」(几十秒)'; return out }
714
+ if (!venvReadyNow()) { out.error = '环境还没装好(可能上次装失败了)—— 点「重装主依赖」再试一次'; return out }
688
715
  if (!hasCredential()) { out.error = '还没登录:点「扫码登录」,手机 B站 扫一下'; return out }
689
716
  try {
690
717
  const who = await call('bili_check_credential', {})
@@ -715,11 +742,11 @@ export function apply(ctx, config) {
715
742
  lastRunAt: st.lastRunAt || null,
716
743
  lastWhy: st.lastWhy || null,
717
744
  loggedIn: hasCredential(),
718
- venvReady: existsSync(venvPython()),
719
- installFailed: !installing && !existsSync(venvPython()) && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
745
+ venvReady: venvReadyNow(),
746
+ installFailed: !installing && !venvReadyNow() && (() => { try { return readFileSync(installLogFile, 'utf8').length > 0 } catch { return false } })(),
720
747
  installing,
721
748
  // ── 本地转写那一套的状态(设置页要显示进度)──
722
- asrReady: existsSync(asrPython()),
749
+ asrReady: asrReadyNow(),
723
750
  // 模型下没下(免得界面写"就绪"、其实还得现下)
724
751
  asrModelReady: (() => { try { return existsSync(join(stateDir, '.hf-cache', 'hub')) } catch { return false } })(),
725
752
  asrInstalling: installingAsr,
@@ -746,10 +773,11 @@ export function apply(ctx, config) {
746
773
  const patch = {}
747
774
  for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel',
748
775
  'commentPerRound', 'mentionName',
749
- 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace']) {
776
+ 'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel']) {
750
777
  if (k in body) patch[k] = body[k]
751
778
  }
752
779
  if ('perDay' in patch) patch.perDay = Math.max(0, Math.min(Number(patch.perDay) || 0, 50))
780
+ if ('reportLevel' in patch) patch.reportLevel = ['full', 'brief', 'off'].includes(String(patch.reportLevel)) ? String(patch.reportLevel) : 'full'
753
781
  saveCfg(patch)
754
782
  log(`设置已更新:${JSON.stringify(patch)}`)
755
783
  return sendJson(res, 200, { ok: true, config: cfg })
@@ -769,6 +797,46 @@ export function apply(ctx, config) {
769
797
  return sendJson(res, 200, runAsrInstall())
770
798
  }
771
799
 
800
+ // 「看今天的报告」:把报告的文本读出来给设置页显示(浏览器不能直接开本地文件 ✗)
801
+ if (req.method === 'GET' && path === '/api/bili-duty/report') {
802
+ const day = String(url.searchParams.get('day') || dayKey()).replace(/[^0-9-]/g, '') || dayKey()
803
+ const file = join(outDir, day + '.md')
804
+ let text = ''
805
+ let exists = false
806
+ try { exists = existsSync(file); if (exists) text = readFileSync(file, 'utf8').slice(0, 200000) } catch { /* 读不到就给空 */ }
807
+ return sendJson(res, 200, { ok: true, day, exists, file, text })
808
+ }
809
+
810
+ // 「打开文件夹」:浏览器开不了本地目录 ⇒ 让宿主去开(只认 out / home 两个,别的不受理)
811
+ if (req.method === 'POST' && path === '/api/bili-duty/open') {
812
+ const body = await readBody(req)
813
+ const dir = String(body?.what || 'out') === 'home' ? stateDir : outDir
814
+ if (process.platform !== 'win32') return sendJson(res, 200, { ok: false, error: '这个按钮目前只做了 Windows' })
815
+ // ⭐ 三种方式依次试(PATH 不全时 explorer.exe 会找不到 ✗;spawn 的失败是**异步**的,
816
+ // try/catch 抓不到 ⇒ 必须挂 'error' 监听,否则会升级成未捕获异常)
817
+ // ⭐ 顺序很重要:把**最可能真的弹出窗口**的放第一(cmd start 走 shell 的 ShellExecute ✓)
818
+ // 并且**不要再传 windowsHide**(2026-10-15 实测:传了以后"成功"但窗口不出现 ✗)
819
+ const attempts = [
820
+ ['cmd.exe', ['/c', 'start', '', dir]],
821
+ [join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
822
+ ['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
823
+ ]
824
+ let lastErr = ''
825
+ for (const [cmd, args] of attempts) {
826
+ const err = await new Promise((resolve) => {
827
+ let done = false
828
+ let child
829
+ try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
830
+ child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
831
+ setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
832
+ })
833
+ if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
834
+ lastErr = err
835
+ log('打开文件夹失败(' + cmd + '):' + err)
836
+ }
837
+ return sendJson(res, 200, { ok: false, error: lastErr, dir })
838
+ }
839
+
772
840
  // 「测一下」:真跑一次,回答"能不能用"
773
841
  if (req.method === 'POST' && path === '/api/bili-duty/check') {
774
842
  return sendJson(res, 200, await checkUp())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-scroll",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DSH 插件:每天替你刷一轮 B站 —— 读字幕挑出值得的、写一份日报,值得的就 @ 你",
5
5
  "keywords": [
6
6
  "dsh",
@@ -10,6 +10,9 @@
10
10
  "mcp",
11
11
  "duty"
12
12
  ],
13
+ "repository": { "type": "git", "url": "git+https://github.com/c1overzz/dsh-bili-scroll.git" },
14
+ "homepage": "https://github.com/c1overzz/dsh-bili-scroll#readme",
15
+ "bugs": { "url": "https://github.com/c1overzz/dsh-bili-scroll/issues" },
13
16
  "license": "MIT",
14
17
  "type": "module",
15
18
  "main": "index.js",
package/prompts/duty.md CHANGED
@@ -19,6 +19,20 @@
19
19
  - ⚠️ **工具失败两次就记为"跳过"**:不要反复重试同一个命令、也不要为了绕过它写一堆临时脚本。
20
20
  (这两条省的是使用者的钱:实测里"纠结"那一轮比干净的一轮多花了**近一倍** token。)
21
21
 
22
+ ## 边界(这是别人的账号,这几条不能碰)
23
+
24
+ - ⚠️ **只发它自己那一条「@ 使用者」的新评论**,发完就走。
25
+ - ⚠️ **不要回复任何人的评论** —— 包括有人回复到它那条评论下面的:**也不要回**。
26
+ - ⚠️ **不要参与讨论、不要争辩、不要替使用者答问题**。有人问技术问题,就写进报告里,让使用者自己回。
27
+ - ⚠️ **同一个视频下不要留第二条评论**(判重表记着)。
28
+ - 理由:它发出去的每个字,都算在**使用者本人的账号**上 —— 越界就是替他惹麻烦。
29
+
30
+ ## 日报可以只有薄薄两行(看这一轮开头怎么说的)
31
+
32
+ - **薄版**:每条一两行 —— 「值不值得 + 链接」就够,别写摘要。
33
+ - **完整**:就是现在这样(标题 / UP / 为什么值得)。
34
+ - **不写**:开头这么说了,就别生成报告文件,只在真发了评论时记一句。
35
+
22
36
  ## 一轮要做的事
23
37
 
24
38
  1. **搜一轮**(近 7 天优先):用**设置页里填的「关注关键词」**;没填就用默认这几组 ——