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/client.js CHANGED
@@ -1,522 +1,577 @@
1
- /**
2
- * dsh-bili-scroll —— 浏览器半端(**设置在「设置」里的一页**)
3
- * ============================================================================
4
- * ⭐ **client bundle 不是 ESM,是自注册脚本**(2026-10-14 从官方包里挖到的约定):
5
- *
6
- * window.__ModuleLoader__.load({
7
- * id: '<包名>', // 必须等于 package.json 的 name
8
- * factory: (require) => { ... return { inject: [...], apply(ctx) {...} } },
9
- * })
10
- *
11
- * ⭐ **怎么进「设置」**(抄第三方插件 `dsh-meme/client.js`,2026-10-14 使用者指出位置不对后才查到):
12
- *
13
- * const slots = ctx.get('slots')
14
- * slots.inject('settings.section', () => slots.register(
15
- * { name: 'settings.section', id: 'bili-scroll', order: 30, label: '刷B站' }, // label = 左侧导航那一项
16
- * () => h(Panel),
17
- * ))
18
- *
19
- * ⇒ **别自己糊一个标题栏按钮 + 全屏 overlay** ✗(第一版就是这么干的,使用者一眼看出"跟别的插件不一样")。
20
- *
21
- * 这一页要满足使用者 2026-10-14 说的形状:
22
- * · **总开关 → 打开之后下面才展开子开关**(峰谷自动刷 → 中午 / 晚上)
23
- * · **数字项要能自己填**(定量刷:每天几条)
24
- * · **账号绑定必须在设置页里**(扫码登录 + 二维码直接显示在页里)
25
- * · 会发东西出去的(自动评论)、要下大模型的(本地转写)**默认关**
26
- * ============================================================================
27
- */
28
- window.__ModuleLoader__.load({
29
- id: 'dsh-bili-scroll',
30
- factory: (require) => {
31
- const React = require('react')
32
- const h = React.createElement
33
-
34
- const API = '/api/bili-duty'
35
- const jget = async (p) => (await fetch(API + p, { credentials: 'same-origin' })).json()
36
- const jpost = async (p, body) => (await fetch(API + p, {
37
- method: 'POST',
38
- credentials: 'same-origin',
39
- headers: { 'content-type': 'application/json' },
40
- body: JSON.stringify(body || {}),
41
- })).json()
42
-
43
- const CSS = `
44
- [data-bd-page]{display:flex;flex-direction:column;gap:18px;padding:2px 2px 8px;max-width:640px;}
45
- [data-bd-sec]{display:flex;flex-direction:column;gap:8px;}
46
- [data-bd-h]{font-size:11px;font-weight:600;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
47
- [data-bd-row]{display:flex;align-items:center;gap:10px;}
48
- [data-bd-grow]{flex:1;min-width:0;}
49
- [data-bd-label]{font-size:13px;}
50
- [data-bd-note]{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
51
- [data-bd-sw]{flex:none;width:38px;height:22px;border-radius:11px;border:0;cursor:pointer;position:relative;
52
- background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.35));transition:background .15s;padding:0;}
53
- [data-bd-sw][data-on]{background:#3b82f6;}
54
- [data-bd-sw] i{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;
55
- transition:left .15s;display:block;}
56
- [data-bd-sw][data-on] i{left:19px;}
57
- [data-bd-sw][disabled]{opacity:.45;cursor:default;}
58
- [data-bd-sub]{margin-left:14px;padding-left:12px;border-left:2px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));
59
- display:flex;flex-direction:column;gap:10px;}
60
- [data-bd-input]{width:100%;box-sizing:border-box;padding:7px 9px;border-radius:8px;font-size:12px;
61
- border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14));
62
- background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:inherit;font-family:inherit;}
63
- [data-bd-input]:focus{outline:none;border-color:#3b82f6;}
64
- [data-bd-num]{width:88px;text-align:center;}
65
- [data-bd-btn]{padding:7px 12px;border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit;
66
- border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));
67
- background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:inherit;}
68
- [data-bd-btn]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.24));}
69
- [data-bd-btn][data-primary]{background:#3b82f6;border-color:#3b82f6;color:#fff;}
70
- [data-bd-btn][disabled]{opacity:.5;cursor:default;}
71
- [data-bd-msg]{font-size:11px;line-height:16px;padding:7px 9px;border-radius:8px;word-break:break-all;
72
- background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:var(--dsw-alias-label-tertiary,#9aa0a6);}
73
- [data-bd-msg][data-warn]{color:#f59e0b;}
74
- [data-bd-msg][data-good]{color:#22c55e;}
75
- [data-bd-qr]{display:flex;gap:14px;align-items:flex-start;}
76
- [data-bd-qr] img{width:150px;height:150px;border-radius:8px;background:#fff;padding:6px;}
77
- [data-bd-pre]{margin:6px 0 0;padding:8px 9px;border-radius:8px;max-height:170px;overflow:auto;font-size:11px;
78
- line-height:15px;white-space:pre-wrap;word-break:break-all;
79
- background:var(--dsw-alias-bg-overlay,rgba(0,0,0,.25));color:var(--dsw-alias-label-secondary,#c9ccd1);}
80
- [data-bd-kv]{font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary,#9aa0a6);word-break:break-all;}
81
- [data-bd-badge]{font-size:11px;padding:2px 7px;border-radius:6px;
82
- background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:var(--dsw-alias-label-secondary,#c9ccd1);}
83
- `
84
-
85
- // ⚠️ 与 lib/state.mjs 的 WINDOWS 保持一致(官方口径:工作日 9–12、14–18 是高峰,其余都是谷时)
86
- const DEFAULT_WINDOWS = {
87
- noon: { label: '中午谷时', start: '12:00', end: '14:00' },
88
- night: { label: '晚上/夜间谷时', start: '18:00', end: '09:00' },
89
- }
90
-
91
- /* ────────────── 小组件 ────────────── */
92
-
93
- function Switch({ on, onChange, disabled }) {
94
- return h('button', {
95
- 'data-bd-sw': '',
96
- 'data-on': on ? '' : undefined,
97
- disabled: Boolean(disabled),
98
- title: on ? '已开' : '已关',
99
- onClick: () => { if (!disabled) onChange(!on) },
100
- }, h('i'))
101
- }
102
-
103
- function ToggleRow({ label, note, on, onChange, disabled }) {
104
- return h('div', { 'data-bd-row': '' },
105
- h('div', { 'data-bd-grow': '' },
106
- h('div', { 'data-bd-label': '' }, label),
107
- note ? h('div', { 'data-bd-note': '' }, note) : null),
108
- h(Switch, { on, onChange, disabled }))
109
- }
110
-
111
- function useStatus(intervalMs) {
112
- const [st, setSt] = React.useState(null)
113
- React.useEffect(() => {
114
- let alive = true
115
- const tick = async () => {
116
- try {
117
- const d = await jget('/status')
118
- if (alive && d?.ok) setSt(d)
119
- } catch { /* 网络抖动就下次再问 */ }
120
- }
121
- tick()
122
- const t = setInterval(tick, intervalMs || 5000)
123
- return () => { alive = false; clearInterval(t) }
124
- }, [intervalMs])
125
- return [st, setSt]
126
- }
127
-
128
- /* ────────────── 设置页正文 ────────────── */
129
-
130
- function Panel() {
131
- const [st, setSt] = useStatus(5000)
132
- const [busy, setBusy] = React.useState('')
133
- const [msg, setMsg] = React.useState('')
134
- const [msgTone, setMsgTone] = React.useState('')
135
- const [kws, setKws] = React.useState(null)
136
- const [ws, setWs] = React.useState(null)
137
- const [rl, setRl] = React.useState(null)
138
- const [newVer, setNewVer] = React.useState('') // 发现的新版本号(空 = 没发现) // 日报形态:full / brief / off
139
- // ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
140
- // ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
141
- const [perDay, setPerDay] = React.useState(null)
142
- const [perRound, setPerRound] = React.useState(null)
143
- const [replyPer, setReplyPer] = React.useState(null)
144
- const [mention, setMention] = React.useState(null)
145
- const [login, setLogin] = React.useState(null)
146
- const [runNote, setRunNote] = React.useState(null)
147
- const [report, setReport] = React.useState(null) // 非 null = 报告已展开 // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
148
- const [loginState, setLoginState] = React.useState('')
149
- const [loginTone, setLoginTone] = React.useState('')
150
-
151
- const cfg = st?.config
152
- React.useEffect(() => {
153
- if (cfg && kws === null) setKws((cfg.keywords || []).join('\n'))
154
- if (cfg && ws === null) setWs(cfg.workspace || '')
155
- if (cfg && rl === null) setRl(cfg.reportLevel || 'full')
156
- if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
157
- if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
158
- if (cfg && replyPer === null) setReplyPer(String(cfg.replyPerRound ?? 1))
159
- if (cfg && mention === null) setMention(cfg.mentionName || '')
160
- }, [cfg])
161
-
162
- const win = { ...DEFAULT_WINDOWS, ...(st?.windows || {}) }
163
-
164
- const say = (text, tone) => {
165
- setMsg(text); setMsgTone(tone || '')
166
- setRunNote(text ? { t: text, tone: tone || '' } : null) // ⭐ 同时镜像到顶部,免得提示掉到页面最底下看不见
167
- }
168
-
169
- const patch = async (p, what) => {
170
- setBusy(JSON.stringify(p))
171
- try {
172
- const r = await jpost('/config', p)
173
- if (r?.config) setSt((old) => ({ ...old, config: r.config }))
174
- say(what ? `${what}:已保存` : '已保存', 'good')
175
- } catch (e) { say(String(e?.message ?? e), 'warn') }
176
- finally { setBusy('') }
177
- }
178
-
179
- const onShowReport = async () => {
180
- if (report !== null) { setReport(null); return } // 再点一次收起
181
- setBusy('report')
182
- try {
183
- const r = await jget('/report')
184
- setReport(r && r.exists ? (r.text || '(报告是空的)') : '今天还没有报告 —— 跑一轮之后就有了')
185
- } catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
186
- }
187
-
188
- // ⭐ 复核评论:接口回"发送成功"≠ 公开可见(新号/风控会进审核,那时候连 @ 都不会推)
189
- // 所以让宿主真去拉一次评论列表 / 楼中楼,确认那条到底在不在。
190
- const onVerify = async () => {
191
- setBusy('verify')
192
- try {
193
- const r = await jpost('/verify', {})
194
- if (!r || !r.ok) { say('复核没跑成:' + ((r && r.error) || '未知原因'), 'warn'); return }
195
- const n = (r.checked || []).length
196
- const vis = (r.checked || []).filter((c) => c.visible === true).length
197
- if (!n) say(r.text || '没有需要复核的记录', 'good')
198
- else say(`复核完 ${n} 条:公开可见 ${vis} 条,其余「没查到」(可能还在审核 —— 看不到不等于没发出去)。结果也追加到今天的报告末尾了。`, vis ? 'good' : 'warn')
199
- if (r.report) setReport(null)
200
- } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
201
- }
202
-
203
- const copyText = async (text) => {
204
- const v = String(text || '')
205
- try {
206
- await navigator.clipboard.writeText(v)
207
- say('已复制:' + v, 'good')
208
- } catch {
209
- say('复制失败 —— 路径是:' + v) // 兜底:至少把路径显示出来,手动选中也能复制
210
- }
211
- }
212
-
213
- const onOpenFolder = async (what) => {
214
- try {
215
- const r = await jpost('/open', { what })
216
- if (r && r.ok) say('已请系统打开:' + (r.opened || '') + '(要是没看到窗口,用旁边的「复制报告路径」——那个一定成)', 'good')
217
- else say('打不开(' + ((r && r.error) || '未知原因') + ')—— 你可以手动打开这个路径:' + ((r && r.dir) || ''))
218
- } catch (e) { say(String(e?.message ?? e), 'warn') }
219
- }
220
-
221
- const onUpgrade = async () => {
222
- setBusy('upgrade')
223
- try {
224
- const r = await jpost('/upgrade', {})
225
- say((r && r.hint) || '正在升级', 'good')
226
- say('升级过程写在下面的「主依赖的日志」里 —— 完了记得重启一次 dsh(不重启还是旧版)', 'warn')
227
- } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
228
- }
229
-
230
- const onCheckUpdate = async () => {
231
- setBusy('update')
232
- try {
233
- const r = await jget('/update')
234
- if (r && r.ok && r.latest) {
235
- if (r.hasNew) {
236
- setNewVer(r.latest)
237
- say('有新版 v' + r.latest + '(你的是 v' + r.current + ')—— 点右边的「一键升级」,完了重启一次 dsh', 'warn')
238
- } else say('已经是最新版 v' + r.current, 'good')
239
- } else {
240
- say('查不到最新版(可能网络不通)—— 你现在的版本是 v' + ((r && r.current) || '?'))
241
- }
242
- } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
243
- }
244
-
245
- const onCheck = async () => {
246
- setBusy('check')
247
- say('')
248
- try {
249
- const r = await jpost('/check', {})
250
- if (r && r.ok) say('能用!' + (r.steps || []).join('、'))
251
- else say('还不能用:' + (r && r.error ? r.error : '没拿到结果'))
252
- } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
253
- }
254
-
255
- const onInstallAsr = async () => {
256
- setBusy('asr')
257
- say('')
258
- try {
259
- const r = await jpost('/install-asr', {})
260
- say(r?.started ? '开始装转写依赖 —— 进度显示在下面(要下模型,慢一点正常)' : (r?.hint || '没起来'),
261
- r?.started ? 'good' : 'warn')
262
- } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
263
- }
264
-
265
- const onInstall = async () => {
266
- setBusy('install')
267
- say('')
268
- try {
269
- const r = await jpost('/install', {})
270
- say(r?.hint || '已经在后台开始装了', '')
271
- } catch (e) { say(String(e?.message ?? e), 'warn') }
272
- finally { setBusy('') }
273
- }
274
-
275
- const onRun = async () => {
276
- setBusy('run')
277
- try {
278
- const r = await jpost('/run', {})
279
- say(r?.started ? '已经在后台跑一轮了,报告会写在 out/ 里' : '没起来,看 duty.log', r?.started ? 'good' : 'warn')
280
- } catch (e) { say(String(e?.message ?? e), 'warn') }
281
- finally { setBusy('') }
282
- }
283
-
284
- const onLoginStart = async () => {
285
- setBusy('login')
286
- setLoginTone('')
287
- setLoginState('正在出码…')
288
- try {
289
- const r = await jpost('/login/start', {})
290
- if (!r?.ok) { setLoginTone('warn'); setLoginState(r?.error || '出码失败'); return }
291
- setLogin({ key: r.key, qr: r.qr })
292
- if (!r.qr) { setLoginTone('warn'); setLoginState('没画出二维码 —— 先点上面的「重装依赖」(venv 里要有 qrcode)') }
293
- else setLoginState('用手机 B站 扫这张码')
294
- let n = 0
295
- const timer = setInterval(async () => {
296
- n += 1
297
- try {
298
- const p = await jpost('/login/poll', { key: r.key })
299
- if (p?.state === 'ok') {
300
- clearInterval(timer); setLogin(null)
301
- setLoginTone('good'); setLoginState('登录成功')
302
- setSt((old) => ({ ...old, loggedIn: true }))
303
- } else if (p?.state === 'scanned') { setLoginTone(''); setLoginState('扫到了,请在手机上确认') }
304
- else if (p?.state === 'expired') { clearInterval(timer); setLogin(null); setLoginTone('warn'); setLoginState('二维码过期了,再点一次「扫码登录」') }
305
- else { setLoginTone(''); setLoginState('等扫码…') }
306
- } catch { /* 抖动就下次再问 */ }
307
- if (n > 90) { clearInterval(timer); setLogin(null); setLoginTone('warn'); setLoginState('超时了,再点一次「扫码登录」') }
308
- }, 2000)
309
- } catch (e) { setLoginTone('warn'); setLoginState(String(e?.message ?? e)) }
310
- finally { setBusy('') }
311
- }
312
-
313
- const onSaveText = async () => {
314
- const list = String(kws || '').split('\n').map((s) => s.trim()).filter(Boolean)
315
- await patch({ keywords: list, workspace: String(ws || '').trim() }, '关键词与工作区')
316
- }
317
-
318
- if (!cfg) {
319
- return h('div', { 'data-bd-page': '' }, h('div', { 'data-bd-note': '' }, '读设置中…'))
320
- }
321
-
322
- const sw = (k) => Boolean(cfg[k])
323
-
324
- return h('div', { 'data-bd-page': '' },
325
-
326
- /* ── 今天 ── */
327
- h('div', { 'data-bd-row': '' },
328
- h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.ranToday} / ${cfg.perDay} 条`),
329
- h('span', { 'data-bd-badge': '' }, '环境' + (st.venvReady ? '已就绪' : (st.installing ? '正在装' : '未装'))),
330
- h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
331
- h('span', { 'data-bd-badge': '' }, 'v' + (st.version || '?')),
332
- h('div', { 'data-bd-grow': '' }),
333
- h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
334
- runNote ? h('div', { 'data-bd-msg': '', 'data-good': runNote.tone === 'good' ? '' : undefined,
335
- 'data-warn': runNote.tone === 'warn' ? '' : undefined }, runNote.t) : null,
336
-
337
- /* ── 定时 ── */
338
- h('div', { 'data-bd-sec': '' },
339
- h('div', { 'data-bd-h': '' }, '什么时候自己跑'),
340
- h(ToggleRow, {
341
- label: '峰谷时段自动刷',
342
- note: '只在谷时跑 —— 工作日 12:00–14:00 与 18:00 之后(整个周末也算谷时);关了就只能手动跑',
343
- on: sw('peakValley'),
344
- onChange: (v) => patch({ peakValley: v }, '峰谷时段自动刷'),
345
- }),
346
- sw('peakValley') ? h('div', { 'data-bd-sub': '' },
347
- ...['noon', 'night'].map((k) => h(ToggleRow, {
348
- key: k,
349
- label: win[k].label,
350
- note: `${win[k].start} – ${win[k].end}`,
351
- on: sw(k),
352
- onChange: (v) => patch({ [k]: v }, win[k].label),
353
- }))) : null),
354
-
355
- /* ── 一轮挑几条 ── */
356
- h('div', { 'data-bd-sec': '' },
357
- h('div', { 'data-bd-h': '' }, '一轮挑几条'),
358
- h('div', { 'data-bd-row': '' },
359
- h('div', { 'data-bd-grow': '' },
360
- h('div', { 'data-bd-label': '' }, '每天最多挑几条'),
361
- h('div', { 'data-bd-note': '' }, '填 0 = 不自动刷(还是能手动跑)')),
362
- h('input', {
363
- 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 0, max: 50,
364
- value: perDay ?? '',
365
- onChange: (e) => setPerDay(e.target.value),
366
- onBlur: () => { const n = Math.max(0, Math.min(Number(perDay) || 0, 50)); setPerDay(String(n)); patch({ perDay: n }, '每天挑几条') },
367
- })),
368
- st.lastRunAt ? h('div', { 'data-bd-note': '' }, `上次跑:${new Date(st.lastRunAt).toLocaleString('zh-CN')}`) : null),
369
-
370
- /* ── 发言与转写 ── */
371
- h('div', { 'data-bd-sec': '' },
372
- h('div', { 'data-bd-h': '' }, '它会不会替你说话'),
373
- h(ToggleRow, {
374
- label: '回复评论(只回楼中楼)',
375
- note: '默认关;开了之后它只回「直接回复到你那条评论下面的人」,不参与讨论',
376
- on: sw('replyToComments'),
377
- onChange: (v) => patch({ replyToComments: v }, '回复评论'),
378
- }),
379
- sw('replyToComments') ? h('div', { 'data-bd-sub': '' },
380
- h('div', { 'data-bd-row': '' },
381
- h('div', { 'data-bd-grow': '' },
382
- h('div', { 'data-bd-label': '' }, '每轮最多回几条'),
383
- h('div', { 'data-bd-note': '' }, '只回直接回在你那条评论下面的人')),
384
- h('input', {
385
- 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 1, max: 10,
386
- value: replyPer ?? '',
387
- onChange: (e) => setReplyPer(e.target.value),
388
- onBlur: () => { const n = Math.max(1, Math.min(Number(replyPer) || 1, 10)); setReplyPer(String(n)); patch({ replyPerRound: n }, '每轮几条回复') },
389
- }))) : null,
390
- h(ToggleRow, {
391
- label: '自动评论并 @ 你',
392
- note: '默认关;开了之后它会在挑中的视频下面留评论,并 @ 你填的那个人',
393
- on: sw('autoComment'),
394
- onChange: (v) => patch({ autoComment: v }, '自动评论'),
395
- }),
396
- sw('autoComment') ? h('div', { 'data-bd-sub': '' },
397
- h('div', { 'data-bd-row': '' },
398
- h('div', { 'data-bd-grow': '' },
399
- h('div', { 'data-bd-label': '' }, '每轮最多发几条评论'),
400
- h('div', { 'data-bd-note': '' }, '每条评论里 @ 一次下面那个人')),
401
- h('input', {
402
- 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 1, max: 20,
403
- value: perRound ?? '',
404
- onChange: (e) => setPerRound(e.target.value),
405
- onBlur: () => { const n = Math.max(1, Math.min(Number(perRound) || 1, 20)); setPerRound(String(n)); patch({ commentPerRound: n }, '每轮几条评论') },
406
- })),
407
- h('div', { 'data-bd-label': '' }, '要 @ 谁(B站 昵称)'),
408
- h('input', {
409
- 'data-bd-input': '', value: mention ?? '',
410
- placeholder: '填你要 @ 的那个人的 B站 昵称;留空 = 只写报告、不发评论',
411
- onChange: (e) => setMention(e.target.value),
412
- onBlur: () => { const v = String(mention || '').trim(); setMention(v); patch({ mentionName: v }, '要 @ 谁') },
413
- }),
414
- h('div', { 'data-bd-note': '' }, '留空的话它就不发评论了 —— 因为评论存在的意义就是 @ 那一下')) : null,
415
- h(ToggleRow, {
416
- label: '本地转写没字幕的视频',
417
- note: '默认关;开了以后,没字幕的视频会在本机转写一遍(不花 API 钱)',
418
- on: sw('transcribe'),
419
- onChange: (v) => patch({ transcribe: v }, '本地转写'),
420
- }),
421
- sw('transcribe') ? h('div', { 'data-bd-sub': '' },
422
- h('div', { 'data-bd-row': '' },
423
- h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
424
- st.asrReady ? '重装转写依赖' : '装转写依赖'),
425
- h('div', { 'data-bd-note': '' },
426
- !st.asrReady ? '还没装 —— 现在开着也没用:没字幕的视频会被跳过(报告里会写"因无字幕跳过 N 条")'
427
- : (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
428
- h('div', { 'data-bd-note': '' },
429
- '点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
430
- h('br'),
431
- '(模型默认 small,几百 MB,走国内镜像;装一次就够了)'),
432
- st.asrTail ? h('div', null,
433
- h('div', { 'data-bd-note': '' }, st.asrInstalling ? '正在装转写依赖(进度):' : '转写依赖的日志(末尾)—— 只有「没字幕的视频」用得到它:'),
434
- h('pre', { 'data-bd-pre': '' }, st.asrTail)) : null) : null),
435
-
436
- /* ── 关注什么 / 在哪儿刷 ── */
437
- h('div', { 'data-bd-sec': '' },
438
- h('div', { 'data-bd-h': '' }, '关注什么'),
439
- h('textarea', {
440
- 'data-bd-input': '', rows: 5, placeholder: '一行一个关键词;留空用内置那几组',
441
- value: kws ?? '', onChange: (e) => setKws(e.target.value),
442
- }),
443
- h('div', { 'data-bd-label': '' }, '工作区(它按这里的 AGENTS.md 认设定)'),
444
- h('input', {
445
- 'data-bd-input': '', value: ws ?? '', placeholder: '留空 = dsh 自己的工作目录',
446
- onChange: (e) => setWs(e.target.value),
447
- }),
448
- h('div', { 'data-bd-label': '' }, '日报写多细'),
449
- h('select', {
450
- 'data-bd-input': '', value: rl ?? 'full',
451
- onChange: (e) => { setRl(e.target.value); patch({ reportLevel: e.target.value }, '日报形态') },
452
- },
453
- h('option', { value: 'full' }, '完整 —— 每条:标题 / UP / 为什么值得(默认)'),
454
- h('option', { value: 'brief' }, '薄 —— 每条一两行,只留值不值得 + 链接'),
455
- h('option', { value: 'off' }, '不写 —— 只发评论,不生成日报文件')),
456
- h('div', { 'data-bd-row': '' },
457
- h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onSaveText }, '保存'),
458
- msg ? h('div', { 'data-bd-msg': '', 'data-good': msgTone === 'good' ? '' : undefined, 'data-warn': msgTone === 'warn' ? '' : undefined }, msg) : null)),
459
-
460
- /* ── 环境与账号 ── */
461
- h('div', { 'data-bd-sec': '' },
462
- h('div', { 'data-bd-h': '' }, '环境与账号'),
463
- h('div', { 'data-bd-row': '' },
464
- h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
465
- st.venvReady ? '重装主依赖' : '装主依赖(建 venv)'),
466
- h('button', { 'data-bd-btn': '', disabled: busy === 'check', onClick: onCheck }, '测一下'),
467
- h('button', { 'data-bd-btn': '', disabled: busy === 'update', onClick: onCheckUpdate }, '检查更新'),
468
- newVer ? h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: busy === 'upgrade', onClick: onUpgrade }, '一键升级到 v' + newVer) : null,
469
- h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
470
- h('div', { 'data-bd-note': '' },
471
- !st.venvReady ? (st.installFailed ? '上次没装成 → 看下面日志' : '还没装 → 先点「装主依赖」')
472
- : (st.loggedIn ? '装好了 → 下一步「扫码登录」' : '环境好了 → 点「扫码登录」'))),
473
- loginState ? h('div', { 'data-bd-msg': '', 'data-good': loginTone === 'good' ? '' : undefined, 'data-warn': loginTone === 'warn' ? '' : undefined }, loginState) : null,
474
- st.installTail ? h('div', null,
475
- h('div', { 'data-bd-note': '' }, st.installing ? '正在装主依赖(进度):' : '主依赖的日志(B站 搜索 / 字幕 / 评论 用得到,末尾):'),
476
- h('pre', { 'data-bd-pre': '' }, st.installTail)) : null,
477
- login?.qr ? h('div', { 'data-bd-qr': '' },
478
- h('img', { src: login.qr, alt: 'B站登录二维码' }),
479
- h('div', { 'data-bd-note': '' },
480
- '手机 B站 → 我的 → 扫一扫。',
481
- h('br'),
482
- '确认时选「记住我 / 长期」那张 —— 选 24 小时的话,明天就得重扫。')) : null),
483
-
484
- /* ── 它是怎么干活的(给他看的) ── */
485
- h('div', { 'data-bd-sec': '' },
486
- h('div', { 'data-bd-h': '' }, '它的地盘'),
487
- h('div', { 'data-bd-kv': '' },
488
- `家目录:${st.home}`,
489
- h('br'),
490
- `今天的报告:${st.report}`),
491
- h('div', { 'data-bd-row': '' },
492
- h('button', { 'data-bd-btn': '', disabled: busy === 'report', onClick: onShowReport },
493
- report !== null ? '收起报告' : '看今天的报告'),
494
- h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
495
- h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
496
- h('button', { 'data-bd-btn': '', disabled: busy === 'verify', onClick: onVerify },
497
- '复核评论'),
498
- h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
499
- report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
500
- )
501
- }
502
-
503
- return {
504
- inject: ['slots'],
505
- apply(ctx) {
506
- const style = document.createElement('style')
507
- style.setAttribute('data-plugin-css', 'dsh-bili-duty')
508
- style.textContent = CSS
509
- document.head.appendChild(style)
510
- ctx.effect(() => () => style.remove(), 'dsh-bili-duty: 设置页样式')
511
-
512
- // ⭐ 进「设置」:label 就是左侧导航里那一项(抄 dsh-meme 的写法)
513
- const slots = (typeof ctx.get === 'function' ? ctx.get('slots') : undefined) || ctx.slots
514
- if (!slots) return
515
- slots.inject('settings.section', () => slots.register(
516
- { name: 'settings.section', id: 'bili-scroll', order: 30, label: '刷B站' },
517
- () => h(Panel),
518
- ))
519
- },
520
- }
521
- },
522
- })
1
+ /**
2
+ * dsh-bili-scroll —— 浏览器半端(**设置在「设置」里的一页**)
3
+ * ============================================================================
4
+ * ⭐ **client bundle 不是 ESM,是自注册脚本**(2026-10-14 从官方包里挖到的约定):
5
+ *
6
+ * window.__ModuleLoader__.load({
7
+ * id: '<包名>', // 必须等于 package.json 的 name
8
+ * factory: (require) => { ... return { inject: [...], apply(ctx) {...} } },
9
+ * })
10
+ *
11
+ * ⭐ **怎么进「设置」**(抄第三方插件 `dsh-meme/client.js`,2026-10-14 使用者指出位置不对后才查到):
12
+ *
13
+ * const slots = ctx.get('slots')
14
+ * slots.inject('settings.section', () => slots.register(
15
+ * { name: 'settings.section', id: 'bili-scroll', order: 30, label: '刷B站' }, // label = 左侧导航那一项
16
+ * () => h(Panel),
17
+ * ))
18
+ *
19
+ * ⇒ **别自己糊一个标题栏按钮 + 全屏 overlay** ✗(第一版就是这么干的,使用者一眼看出"跟别的插件不一样")。
20
+ *
21
+ * 这一页要满足使用者 2026-10-14 说的形状:
22
+ * · **总开关 → 打开之后下面才展开子开关**(峰谷自动刷 → 中午 / 晚上)
23
+ * · **数字项要能自己填**(定量刷:每天几条)
24
+ * · **账号绑定必须在设置页里**(扫码登录 + 二维码直接显示在页里)
25
+ * · 会发东西出去的(自动评论)、要下大模型的(本地转写)**默认关**
26
+ * ============================================================================
27
+ */
28
+ window.__ModuleLoader__.load({
29
+ id: 'dsh-bili-scroll',
30
+ factory: (require) => {
31
+ const React = require('react')
32
+ const h = React.createElement
33
+
34
+ const API = '/api/bili-duty'
35
+ /* ⚠️ 原来 jget/jpost 直接 .json()、从不看 res.ok ⇒ HTTP 500 的 {ok:false,error}
36
+ * 会被调用方当成「已保存 / 成功」,绿色提示把故障盖掉。(2026-10-16 审查修) */
37
+ const asJson = async (res) => {
38
+ if (res.ok) return res.json()
39
+ const why = res.status === 401 ? '(未登录 / 没权限)' : res.status === 403 ? '(被宿主拦了)' : ''
40
+ throw new Error('HTTP ' + res.status + why)
41
+ }
42
+ const jget = async (p) => asJson(await fetch(API + p, { credentials: 'same-origin' }))
43
+ const jpost = async (p, body) => asJson(await fetch(API + p, {
44
+ method: 'POST',
45
+ credentials: 'same-origin',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify(body || {}),
48
+ }))
49
+
50
+ const CSS = `
51
+ [data-bd-page]{display:flex;flex-direction:column;gap:18px;padding:2px 2px 8px;max-width:640px;}
52
+ [data-bd-sec]{display:flex;flex-direction:column;gap:8px;}
53
+ [data-bd-h]{font-size:11px;font-weight:600;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
54
+ [data-bd-row]{display:flex;align-items:center;gap:10px;}
55
+ [data-bd-grow]{flex:1;min-width:0;}
56
+ [data-bd-label]{font-size:13px;}
57
+ [data-bd-note]{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
58
+ [data-bd-sw]{flex:none;width:38px;height:22px;border-radius:11px;border:0;cursor:pointer;position:relative;
59
+ background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.35));transition:background .15s;padding:0;}
60
+ [data-bd-sw][data-on]{background:#3b82f6;}
61
+ [data-bd-sw] i{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;
62
+ transition:left .15s;display:block;}
63
+ [data-bd-sw][data-on] i{left:19px;}
64
+ [data-bd-sw][disabled]{opacity:.45;cursor:default;}
65
+ [data-bd-sub]{margin-left:14px;padding-left:12px;border-left:2px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));
66
+ display:flex;flex-direction:column;gap:10px;}
67
+ [data-bd-input]{width:100%;box-sizing:border-box;padding:7px 9px;border-radius:8px;font-size:12px;
68
+ border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14));
69
+ background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:inherit;font-family:inherit;}
70
+ [data-bd-input]:focus{outline:none;border-color:#3b82f6;}
71
+ [data-bd-num]{width:88px;text-align:center;}
72
+ [data-bd-btn]{padding:7px 12px;border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit;
73
+ border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));
74
+ background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:inherit;}
75
+ [data-bd-btn]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.24));}
76
+ [data-bd-btn][data-primary]{background:#3b82f6;border-color:#3b82f6;color:#fff;}
77
+ [data-bd-btn][disabled]{opacity:.5;cursor:default;}
78
+ [data-bd-msg]{font-size:11px;line-height:16px;padding:7px 9px;border-radius:8px;word-break:break-all;
79
+ background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:var(--dsw-alias-label-tertiary,#9aa0a6);}
80
+ [data-bd-msg][data-warn]{color:#f59e0b;}
81
+ [data-bd-msg][data-good]{color:#22c55e;}
82
+ [data-bd-qr]{display:flex;gap:14px;align-items:flex-start;}
83
+ [data-bd-qr] img{width:150px;height:150px;border-radius:8px;background:#fff;padding:6px;}
84
+ [data-bd-pre]{margin:6px 0 0;padding:8px 9px;border-radius:8px;max-height:170px;overflow:auto;font-size:11px;
85
+ line-height:15px;white-space:pre-wrap;word-break:break-all;
86
+ background:var(--dsw-alias-bg-overlay,rgba(0,0,0,.25));color:var(--dsw-alias-label-secondary,#c9ccd1);}
87
+ [data-bd-kv]{font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary,#9aa0a6);word-break:break-all;}
88
+ [data-bd-badge]{font-size:11px;padding:2px 7px;border-radius:6px;
89
+ background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:var(--dsw-alias-label-secondary,#c9ccd1);}
90
+ `
91
+
92
+ // ⚠️ 与 lib/state.mjs 的 WINDOWS 保持一致(官方口径:工作日 9–12、14–18 是高峰,其余都是谷时)
93
+ const DEFAULT_WINDOWS = {
94
+ noon: { label: '中午谷时', start: '12:00', end: '14:00' },
95
+ night: { label: '晚上/夜间谷时', start: '18:00', end: '09:00' },
96
+ }
97
+
98
+ /* ────────────── 小组件 ────────────── */
99
+
100
+ function Switch({ on, onChange, disabled }) {
101
+ return h('button', {
102
+ 'data-bd-sw': '',
103
+ 'data-on': on ? '' : undefined,
104
+ disabled: Boolean(disabled),
105
+ title: on ? '已开' : '已关',
106
+ onClick: () => { if (!disabled) onChange(!on) },
107
+ }, h('i'))
108
+ }
109
+
110
+ function ToggleRow({ label, note, on, onChange, disabled }) {
111
+ return h('div', { 'data-bd-row': '' },
112
+ h('div', { 'data-bd-grow': '' },
113
+ h('div', { 'data-bd-label': '' }, label),
114
+ note ? h('div', { 'data-bd-note': '' }, note) : null),
115
+ h(Switch, { on, onChange, disabled }))
116
+ }
117
+
118
+ function useStatus(intervalMs) {
119
+ const [st, setSt] = React.useState(null)
120
+ React.useEffect(() => {
121
+ let alive = true
122
+ const tick = async () => {
123
+ try {
124
+ const d = await jget('/status')
125
+ if (alive && d?.ok) setSt(d)
126
+ } catch { /* 网络抖动就下次再问 */ }
127
+ }
128
+ tick()
129
+ const t = setInterval(tick, intervalMs || 5000)
130
+ return () => { alive = false; clearInterval(t) }
131
+ }, [intervalMs])
132
+ return [st, setSt]
133
+ }
134
+
135
+ /* ────────────── 设置页正文 ────────────── */
136
+
137
+ function Panel() {
138
+ const [st, setSt] = useStatus(5000)
139
+ const [busy, setBusy] = React.useState('')
140
+ const [msg, setMsg] = React.useState('')
141
+ const [msgTone, setMsgTone] = React.useState('')
142
+ const [kws, setKws] = React.useState(null)
143
+ const [ws, setWs] = React.useState(null)
144
+ const [rl, setRl] = React.useState(null)
145
+ const [newVer, setNewVer] = React.useState('') // 发现的新版本号(空 = 没发现) // 日报形态:full / brief / off
146
+ // ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
147
+ // ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
148
+ const [perDay, setPerDay] = React.useState(null)
149
+ const [perRound, setPerRound] = React.useState(null)
150
+ const [replyPer, setReplyPer] = React.useState(null)
151
+ const [dmUid, setDmUid] = React.useState(null) // 私信收件人 UID(本地草稿)
152
+ const [dmMode, setDmMode] = React.useState(null) // 投递方式:off / only / both
153
+ const [mention, setMention] = React.useState(null)
154
+ const [login, setLogin] = React.useState(null)
155
+ const [runNote, setRunNote] = React.useState(null)
156
+ const [report, setReport] = React.useState(null) // 非 null = 报告已展开 // 顶部那行状态(跑一轮/装依赖的反馈原来在最底下,看不见)
157
+ const [loginState, setLoginState] = React.useState('')
158
+ const [loginTone, setLoginTone] = React.useState('')
159
+ /* ⚠️ 扫码轮询的 timer 必须能停:切走设置页后还在跑的话会在已卸载的组件上 setState,
160
+ * 重复点「扫码登录」还会叠出第二个 timer。(2026-10-16 审查修) */
161
+ const loginTimer = React.useRef(null)
162
+ const alive = React.useRef(true)
163
+ React.useEffect(() => () => {
164
+ alive.current = false
165
+ if (loginTimer.current) { clearInterval(loginTimer.current); loginTimer.current = null }
166
+ }, [])
167
+
168
+ const cfg = st?.config
169
+ React.useEffect(() => {
170
+ if (cfg && kws === null) setKws((cfg.keywords || []).join('\n'))
171
+ if (cfg && ws === null) setWs(cfg.workspace || '')
172
+ if (cfg && rl === null) setRl(cfg.reportLevel || 'full')
173
+ if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
174
+ if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
175
+ if (cfg && replyPer === null) setReplyPer(String(cfg.replyPerRound ?? 1))
176
+ if (cfg && mention === null) setMention(cfg.mentionName || '')
177
+ if (cfg && dmUid === null) setDmUid(cfg.dmUid ? String(cfg.dmUid) : '')
178
+ if (cfg && dmMode === null) setDmMode(cfg.dmMode || 'off')
179
+ }, [cfg])
180
+
181
+ const win = { ...DEFAULT_WINDOWS, ...(st?.windows || {}) }
182
+
183
+ const say = (text, tone) => {
184
+ setMsg(text); setMsgTone(tone || '')
185
+ setRunNote(text ? { t: text, tone: tone || '' } : null) // ⭐ 同时镜像到顶部,免得提示掉到页面最底下看不见
186
+ }
187
+
188
+ const patch = async (p, what) => {
189
+ setBusy(JSON.stringify(p))
190
+ try {
191
+ const r = await jpost('/config', p)
192
+ if (r?.config) setSt((old) => ({ ...old, config: r.config }))
193
+ // ⭐ DSH 原生设置页里显式设过的键由宿主说了算 ⇒ 我们这份也得写进去;写失败要说出来,别假装"已保存"
194
+ if (r?.host && r.host.ok === false) say(`${what || '设置'}:插件自己那份存了,但 DSH 设置页那份没写进去(${r.host.error})`, 'warn')
195
+ else say(what ? `${what}:已保存` : '已保存', 'good')
196
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
197
+ finally { setBusy('') }
198
+ }
199
+
200
+ const onShowReport = async () => {
201
+ if (report !== null) { setReport(null); return } // 再点一次收起
202
+ setBusy('report')
203
+ try {
204
+ const r = await jget('/report')
205
+ setReport(r && r.exists ? (r.text || '(报告是空的)') : '今天还没有报告 —— 跑一轮之后就有了')
206
+ } catch (e) { setReport('读不到报告:' + String(e?.message ?? e)) } finally { setBusy('') }
207
+ }
208
+
209
+ // ⭐ 复核评论:接口回"发送成功"≠ 公开可见(新号/风控会进审核,那时候连 @ 都不会推)
210
+ // 所以让宿主真去拉一次评论列表 / 楼中楼,确认那条到底在不在。
211
+ const onVerify = async () => {
212
+ setBusy('verify')
213
+ try {
214
+ const r = await jpost('/verify', {})
215
+ if (!r || !r.ok) { say('复核没跑成:' + ((r && r.error) || '未知原因'), 'warn'); return }
216
+ const n = (r.checked || []).length
217
+ const vis = (r.checked || []).filter((c) => c.visible === true).length
218
+ if (!n) say(r.text || '没有需要复核的记录', 'good')
219
+ else say(`复核完 ${n} 条:公开可见 ${vis} 条,其余「没查到」(可能还在审核 —— 看不到不等于没发出去)。结果也追加到今天的报告末尾了。`, vis ? 'good' : 'warn')
220
+ if (r.report) setReport(null)
221
+ } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
222
+ }
223
+
224
+ const copyText = async (text) => {
225
+ const v = String(text || '')
226
+ try {
227
+ await navigator.clipboard.writeText(v)
228
+ say('已复制:' + v, 'good')
229
+ } catch {
230
+ say('复制失败 —— 路径是:' + v) // 兜底:至少把路径显示出来,手动选中也能复制
231
+ }
232
+ }
233
+
234
+ const onOpenFolder = async (what) => {
235
+ try {
236
+ const r = await jpost('/open', { what })
237
+ if (r && r.ok) say('已请系统打开:' + (r.opened || '') + '(要是没看到窗口,用旁边的「复制报告路径」——那个一定成)', 'good')
238
+ else say('打不开(' + ((r && r.error) || '未知原因') + ')—— 你可以手动打开这个路径:' + ((r && r.dir) || ''))
239
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
240
+ }
241
+
242
+ const onUpgrade = async () => {
243
+ setBusy('upgrade')
244
+ try {
245
+ const r = await jpost('/upgrade', {})
246
+ // 原来这两句分两次 say,第二句把服务端的 hint 冲掉了 —— 合成一句。(2026-10-16 审查修)
247
+ say(((r && r.hint) || '正在升级') + ' —— 升级过程写在下面的「主依赖的日志」里;完了记得重启一次 dsh(不重启还是旧版)', 'warn')
248
+ } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
249
+ }
250
+
251
+ const onCheckUpdate = async () => {
252
+ setBusy('update')
253
+ try {
254
+ const r = await jget('/update')
255
+ if (r && r.ok && r.latest) {
256
+ if (r.hasNew) {
257
+ setNewVer(r.latest)
258
+ say('有新版 v' + r.latest + '(你的是 v' + r.current + ')—— 点右边的「一键升级」,完了重启一次 dsh', 'warn')
259
+ } else say('已经是最新版 v' + r.current, 'good')
260
+ } else {
261
+ say('查不到最新版(可能网络不通)—— 你现在的版本是 v' + ((r && r.current) || '?'))
262
+ }
263
+ } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
264
+ }
265
+
266
+ const onCheck = async () => {
267
+ setBusy('check')
268
+ say('')
269
+ try {
270
+ const r = await jpost('/check', {})
271
+ if (r && r.ok) say('能用!' + (r.steps || []).join('、'))
272
+ else say('还不能用:' + (r && r.error ? r.error : '没拿到结果'))
273
+ } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
274
+ }
275
+
276
+ const onInstallAsr = async () => {
277
+ setBusy('asr')
278
+ say('')
279
+ try {
280
+ const r = await jpost('/install-asr', {})
281
+ say(r?.started ? '开始装转写依赖 —— 进度显示在下面(要下模型,慢一点正常)' : (r?.hint || '没起来'),
282
+ r?.started ? 'good' : 'warn')
283
+ } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
284
+ }
285
+
286
+ const onInstall = async () => {
287
+ setBusy('install')
288
+ say('')
289
+ try {
290
+ const r = await jpost('/install', {})
291
+ say(r?.hint || '已经在后台开始装了', '')
292
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
293
+ finally { setBusy('') }
294
+ }
295
+
296
+ const onRun = async () => {
297
+ setBusy('run')
298
+ try {
299
+ const r = await jpost('/run', {})
300
+ say(r?.started ? '已经在后台跑一轮了,报告会写在 out/ 里' : '没起来,看 duty.log', r?.started ? 'good' : 'warn')
301
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
302
+ finally { setBusy('') }
303
+ }
304
+
305
+ const onLoginStart = async () => {
306
+ setBusy('login')
307
+ setLoginTone('')
308
+ setLoginState('正在出码…')
309
+ try {
310
+ const r = await jpost('/login/start', {})
311
+ if (!r?.ok) { setLoginTone('warn'); setLoginState(r?.error || '出码失败'); return }
312
+ setLogin({ key: r.key, qr: r.qr })
313
+ if (!r.qr) { setLoginTone('warn'); setLoginState('没画出二维码 —— 先点上面的「重装依赖」(venv 里要有 qrcode)') }
314
+ else setLoginState('用手机 B站 扫这张码')
315
+ if (loginTimer.current) { clearInterval(loginTimer.current); loginTimer.current = null } // 重复点:先把上一轮停掉
316
+ let n = 0
317
+ const stop = () => { clearInterval(timer); if (loginTimer.current === timer) loginTimer.current = null }
318
+ const timer = setInterval(async () => {
319
+ if (!alive.current) { stop(); return } // 已经切走设置页了:别再问、别再 setState
320
+ n += 1
321
+ try {
322
+ const p = await jpost('/login/poll', { key: r.key })
323
+ if (p?.state === 'ok') {
324
+ stop(); setLogin(null)
325
+ setLoginTone('good'); setLoginState('登录成功')
326
+ setSt((old) => ({ ...old, loggedIn: true }))
327
+ } else if (p?.state === 'scanned') { setLoginTone(''); setLoginState('扫到了,请在手机上确认') }
328
+ else if (p?.state === 'expired') { stop(); setLogin(null); setLoginTone('warn'); setLoginState('二维码过期了,再点一次「扫码登录」') }
329
+ // ⚠️ 扫码成功但响应里没有 SESSDATA 时服务端回 state='unknown':原来没这个分支
330
+ // ⇒ 界面一直显示「等扫码…」,用户白等 3 分钟才超时。(2026-10-16 审查修)
331
+ else if (p?.state === 'unknown') {
332
+ stop(); setLogin(null); setLoginTone('warn')
333
+ setLoginState('没登上:' + (p?.message || '响应里没有 SESSDATA —— 再点一次「扫码登录」'))
334
+ }
335
+ else { setLoginTone(''); setLoginState('等扫码…') }
336
+ } catch { /* 抖动就下次再问 */ }
337
+ if (n > 90) { stop(); setLogin(null); setLoginTone('warn'); setLoginState('超时了,再点一次「扫码登录」') }
338
+ }, 2000)
339
+ loginTimer.current = timer
340
+ } catch (e) { setLoginTone('warn'); setLoginState(String(e?.message ?? e)) }
341
+ finally { setBusy('') }
342
+ }
343
+
344
+ const onSaveText = async () => {
345
+ const list = String(kws || '').split('\n').map((s) => s.trim()).filter(Boolean)
346
+ await patch({ keywords: list, workspace: String(ws || '').trim() }, '关键词与工作区')
347
+ }
348
+
349
+ if (!cfg) {
350
+ return h('div', { 'data-bd-page': '' }, h('div', { 'data-bd-note': '' }, '读设置中…'))
351
+ }
352
+
353
+ const sw = (k) => Boolean(cfg[k])
354
+
355
+ return h('div', { 'data-bd-page': '' },
356
+
357
+ /* ── 今天 ── */
358
+ h('div', { 'data-bd-row': '' },
359
+ h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.pickedToday ?? st.ranToday ?? 0} / ${cfg.perDay} 条`),
360
+ h('span', { 'data-bd-badge': '' }, '环境' + (st.venvReady ? '已就绪' : (st.installing ? '正在装' : '未装'))),
361
+ h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
362
+ h('span', { 'data-bd-badge': '' }, 'v' + (st.version || '?')),
363
+ h('div', { 'data-bd-grow': '' }),
364
+ h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
365
+ runNote ? h('div', { 'data-bd-msg': '', 'data-good': runNote.tone === 'good' ? '' : undefined,
366
+ 'data-warn': runNote.tone === 'warn' ? '' : undefined }, runNote.t) : null,
367
+
368
+ /* ── 定时 ── */
369
+ h('div', { 'data-bd-sec': '' },
370
+ h('div', { 'data-bd-h': '' }, '什么时候自己跑'),
371
+ h(ToggleRow, {
372
+ label: '峰谷时段自动刷',
373
+ note: '只在谷时跑 —— 工作日 12:00–14:00 与 18:00 之后(整个周末也算谷时);关了就只能手动跑',
374
+ on: sw('peakValley'),
375
+ onChange: (v) => patch({ peakValley: v }, '峰谷时段自动刷'),
376
+ }),
377
+ sw('peakValley') ? h('div', { 'data-bd-sub': '' },
378
+ ...['noon', 'night'].map((k) => h(ToggleRow, {
379
+ key: k,
380
+ label: win[k].label,
381
+ note: `${win[k].start} – ${win[k].end}`,
382
+ on: sw(k),
383
+ onChange: (v) => patch({ [k]: v }, win[k].label),
384
+ }))) : null),
385
+
386
+ /* ── 一轮挑几条 ── */
387
+ h('div', { 'data-bd-sec': '' },
388
+ h('div', { 'data-bd-h': '' }, '一轮挑几条'),
389
+ h('div', { 'data-bd-row': '' },
390
+ h('div', { 'data-bd-grow': '' },
391
+ h('div', { 'data-bd-label': '' }, '每天最多挑几条'),
392
+ h('div', { 'data-bd-note': '' }, '填 0 = 不自动刷(还是能手动跑)')),
393
+ h('input', {
394
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 0, max: 50,
395
+ value: perDay ?? '',
396
+ onChange: (e) => setPerDay(e.target.value),
397
+ onBlur: () => { const n = Math.max(0, Math.min(Number(perDay) || 0, 50)); setPerDay(String(n)); patch({ perDay: n }, '每天挑几条') },
398
+ })),
399
+ st.lastRunAt ? h('div', { 'data-bd-note': '' }, `上次跑:${new Date(st.lastRunAt).toLocaleString('zh-CN')}`) : null),
400
+
401
+ /* ── 发言与转写 ── */
402
+ h('div', { 'data-bd-sec': '' },
403
+ h('div', { 'data-bd-h': '' }, '它会不会替你说话'),
404
+ h(ToggleRow, {
405
+ label: '回复评论(只回楼中楼)',
406
+ note: '默认关;开了之后它只回「直接回复到你那条评论下面的人」,不参与讨论',
407
+ on: sw('replyToComments'),
408
+ onChange: (v) => patch({ replyToComments: v }, '回复评论'),
409
+ }),
410
+ sw('replyToComments') ? h('div', { 'data-bd-sub': '' },
411
+ h('div', { 'data-bd-row': '' },
412
+ h('div', { 'data-bd-grow': '' },
413
+ h('div', { 'data-bd-label': '' }, '每轮最多回几条'),
414
+ h('div', { 'data-bd-note': '' }, '只回直接回在你那条评论下面的人')),
415
+ h('input', {
416
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 1, max: 10,
417
+ value: replyPer ?? '',
418
+ onChange: (e) => setReplyPer(e.target.value),
419
+ onBlur: () => { const n = Math.max(1, Math.min(Number(replyPer) || 1, 10)); setReplyPer(String(n)); patch({ replyPerRound: n }, '每轮几条回复') },
420
+ }))) : null,
421
+ h(ToggleRow, {
422
+ label: '自动评论并 @ 你',
423
+ note: '默认关;开了之后它会在挑中的视频下面留评论,并 @ 你填的那个人',
424
+ on: sw('autoComment'),
425
+ onChange: (v) => patch({ autoComment: v }, '自动评论'),
426
+ }),
427
+ sw('autoComment') ? h('div', { 'data-bd-sub': '' },
428
+ h('div', { 'data-bd-row': '' },
429
+ h('div', { 'data-bd-grow': '' },
430
+ h('div', { 'data-bd-label': '' }, '每轮最多发几条评论'),
431
+ h('div', { 'data-bd-note': '' }, '每条评论里 @ 一次下面那个人;填 0 = 这一轮不发评论')),
432
+ h('input', {
433
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 0, max: 20,
434
+ value: perRound ?? '',
435
+ onChange: (e) => setPerRound(e.target.value),
436
+ // ⭐ 0 是合法值(= 这一轮不发评论);范围与 index.js 的 clampPatch 一致(0–20)
437
+ onBlur: () => { const n = Math.max(0, Math.min(Number(perRound) || 0, 20)); setPerRound(String(n)); patch({ commentPerRound: n }, '每轮几条评论') },
438
+ })),
439
+ h('div', { 'data-bd-label': '' }, '要 @ 谁(B站 昵称)'),
440
+ h('input', {
441
+ 'data-bd-input': '', value: mention ?? '',
442
+ placeholder: '填你要 @ 的那个人的 B站 昵称;留空 = 只写报告、不发评论',
443
+ onChange: (e) => setMention(e.target.value),
444
+ onBlur: () => { const v = String(mention || '').trim(); setMention(v); patch({ mentionName: v }, '要 @ 谁') },
445
+ }),
446
+ h('div', { 'data-bd-note': '' }, '留空的话它就不发评论了 —— 因为评论存在的意义就是 @ 那一下')) : null,
447
+ h('div', { 'data-bd-label': '' }, '投递方式(评论 还是 私信)'),
448
+ h('select', {
449
+ 'data-bd-input': '', value: dmMode ?? 'off',
450
+ onChange: (e) => { setDmMode(e.target.value); patch({ dmMode: e.target.value }, '投递方式') },
451
+ },
452
+ h('option', { value: 'off' }, '只发评论 —— 在视频下面 @ 你(默认)'),
453
+ h('option', { value: 'only' }, '只私信 —— 一条公开评论都不发,最不打扰别人'),
454
+ h('option', { value: 'both' }, '评论 + 私信 —— 两边都发')),
455
+ (dmMode && dmMode !== 'off') ? h('div', { 'data-bd-sub': '' },
456
+ h('div', { 'data-bd-label': '' }, '私信发给谁(B站 UID,数字)'),
457
+ h('input', {
458
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 0, value: dmUid ?? '',
459
+ placeholder: '对方的数字 UID;比如你自己的另一个号',
460
+ onChange: (e) => setDmUid(e.target.value),
461
+ onBlur: () => { const n = Math.max(0, Number(dmUid) || 0); setDmUid(n ? String(n) : ''); patch({ dmUid: n }, '私信收件人') },
462
+ }),
463
+ h('div', { 'data-bd-note': '' }, '私信内容 = 本轮每条的「标题 + 链接 + 一句话」,一轮合并成一条发(不受 150 字限制)'),
464
+ h('div', { 'data-bd-note': '' }, '⚠️ 建议先让这个号和收件人互关:B站 对「陌生人私信」会限流,还可能不弹提醒')) : null,
465
+ h(ToggleRow, {
466
+ label: '本地转写没字幕的视频',
467
+ note: '默认关;开了以后,没字幕的视频会在本机转写一遍(不花 API 钱)',
468
+ on: sw('transcribe'),
469
+ onChange: (v) => patch({ transcribe: v }, '本地转写'),
470
+ }),
471
+ sw('transcribe') ? h('div', { 'data-bd-sub': '' },
472
+ h('div', { 'data-bd-row': '' },
473
+ h('button', { 'data-bd-btn': '', disabled: busy === 'asr', onClick: onInstallAsr },
474
+ st.asrReady ? '重装转写依赖' : '装转写依赖'),
475
+ h('div', { 'data-bd-note': '' },
476
+ !st.asrReady ? '还没装 —— 现在开着也没用:没字幕的视频会被跳过(报告里会写"因无字幕跳过 N 条")'
477
+ : (st.asrModelReady ? '环境和模型都好了' : '环境好了;模型会在第一次转写时自动下(几百 MB)'))),
478
+ h('div', { 'data-bd-note': '' },
479
+ '点一下就好:建一个独立环境、装 faster-whisper + yt-dlp,并把 Whisper 模型也下好',
480
+ h('br'),
481
+ '(模型默认 small,几百 MB,走国内镜像;装一次就够了)'),
482
+ st.asrTail ? h('div', null,
483
+ h('div', { 'data-bd-note': '' }, st.asrInstalling ? '正在装转写依赖(进度):' : '转写依赖的日志(末尾)—— 只有「没字幕的视频」用得到它:'),
484
+ h('pre', { 'data-bd-pre': '' }, st.asrTail)) : null) : null),
485
+
486
+ /* ── 关注什么 / 在哪儿刷 ── */
487
+ h('div', { 'data-bd-sec': '' },
488
+ h('div', { 'data-bd-h': '' }, '关注什么'),
489
+ h('textarea', {
490
+ 'data-bd-input': '', rows: 5, placeholder: '一行一个关键词;留空用内置那几组',
491
+ value: kws ?? '', onChange: (e) => setKws(e.target.value),
492
+ }),
493
+ h('div', { 'data-bd-label': '' }, '工作区(它按这里的 AGENTS.md 认设定)'),
494
+ h('input', {
495
+ 'data-bd-input': '', value: ws ?? '',
496
+ placeholder: '填你自己的工作区路径(留空 = 不读人设,按中性说话)',
497
+ onChange: (e) => setWs(e.target.value),
498
+ }),
499
+ h('div', { 'data-bd-note': '' },
500
+ '⭐ 想让它按你的设定说话,这里要填你自己平时用的那个工作区(放 AGENTS.md 的那个目录);',
501
+ h('br'),
502
+ '留空不等于"用你的工作区" —— 那是不读设定、按中性说话(以前会去猜 dsh 进程自己的目录,容易读错人设)。'),
503
+ h('div', { 'data-bd-label': '' }, '日报写多细'),
504
+ h('select', {
505
+ 'data-bd-input': '', value: rl ?? 'full',
506
+ onChange: (e) => { setRl(e.target.value); patch({ reportLevel: e.target.value }, '日报形态') },
507
+ },
508
+ h('option', { value: 'full' }, '完整 —— 每条:标题 / UP / 为什么值得(默认)'),
509
+ h('option', { value: 'brief' }, '薄 —— 每条一两行,只留值不值得 + 链接'),
510
+ h('option', { value: 'off' }, '不写 —— 只发评论,不生成日报文件')),
511
+ h('div', { 'data-bd-row': '' },
512
+ h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onSaveText }, '保存'),
513
+ msg ? h('div', { 'data-bd-msg': '', 'data-good': msgTone === 'good' ? '' : undefined, 'data-warn': msgTone === 'warn' ? '' : undefined }, msg) : null)),
514
+
515
+ /* ── 环境与账号 ── */
516
+ h('div', { 'data-bd-sec': '' },
517
+ h('div', { 'data-bd-h': '' }, '环境与账号'),
518
+ h('div', { 'data-bd-row': '' },
519
+ h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
520
+ st.venvReady ? '重装主依赖' : '装主依赖(建 venv)'),
521
+ h('button', { 'data-bd-btn': '', disabled: busy === 'check', onClick: onCheck }, '测一下'),
522
+ h('button', { 'data-bd-btn': '', disabled: busy === 'update', onClick: onCheckUpdate }, '检查更新'),
523
+ newVer ? h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: busy === 'upgrade', onClick: onUpgrade }, '一键升级到 v' + newVer) : null,
524
+ h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
525
+ h('div', { 'data-bd-note': '' },
526
+ !st.venvReady ? (st.installFailed ? '上次没装成 → 看下面日志' : '还没装 → 先点「装主依赖」')
527
+ : (st.loggedIn ? '装好了 → 下一步「扫码登录」' : '环境好了 → 点「扫码登录」'))),
528
+ loginState ? h('div', { 'data-bd-msg': '', 'data-good': loginTone === 'good' ? '' : undefined, 'data-warn': loginTone === 'warn' ? '' : undefined }, loginState) : null,
529
+ st.installTail ? h('div', null,
530
+ h('div', { 'data-bd-note': '' }, st.installing ? '正在装主依赖(进度):' : '主依赖的日志(B站 搜索 / 字幕 / 评论 用得到,末尾):'),
531
+ h('pre', { 'data-bd-pre': '' }, st.installTail)) : null,
532
+ login?.qr ? h('div', { 'data-bd-qr': '' },
533
+ h('img', { src: login.qr, alt: 'B站登录二维码' }),
534
+ h('div', { 'data-bd-note': '' },
535
+ '手机 B站 → 我的 → 扫一扫。',
536
+ h('br'),
537
+ '确认时选「记住我 / 长期」那张 —— 选 24 小时的话,明天就得重扫。')) : null),
538
+
539
+ /* ── 它是怎么干活的(给他看的) ── */
540
+ h('div', { 'data-bd-sec': '' },
541
+ h('div', { 'data-bd-h': '' }, '它的地盘'),
542
+ h('div', { 'data-bd-kv': '' },
543
+ `家目录:${st.home}`,
544
+ h('br'),
545
+ `今天的报告:${st.report}`),
546
+ h('div', { 'data-bd-row': '' },
547
+ h('button', { 'data-bd-btn': '', disabled: busy === 'report', onClick: onShowReport },
548
+ report !== null ? '收起报告' : '看今天的报告'),
549
+ h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('out') }, '打开报告文件夹'),
550
+ h('button', { 'data-bd-btn': '', onClick: () => onOpenFolder('home') }, '打开家目录'),
551
+ h('button', { 'data-bd-btn': '', disabled: busy === 'verify', onClick: onVerify },
552
+ '复核评论'),
553
+ h('button', { 'data-bd-btn': '', onClick: () => copyText(st.report) }, '复制报告路径')),
554
+ report !== null ? h('pre', { 'data-bd-pre': '' }, report) : null),
555
+ )
556
+ }
557
+
558
+ return {
559
+ inject: ['slots'],
560
+ apply(ctx) {
561
+ const style = document.createElement('style')
562
+ style.setAttribute('data-plugin-css', 'dsh-bili-scroll')
563
+ style.textContent = CSS
564
+ document.head.appendChild(style)
565
+ ctx.effect(() => () => style.remove(), 'dsh-bili-scroll: 设置页样式')
566
+
567
+ // ⭐ 进「设置」:label 就是左侧导航里那一项(抄 dsh-meme 的写法)
568
+ const slots = (typeof ctx.get === 'function' ? ctx.get('slots') : undefined) || ctx.slots
569
+ if (!slots) return
570
+ slots.inject('settings.section', () => slots.register(
571
+ { name: 'settings.section', id: 'bili-scroll', order: 30, label: '刷B站' },
572
+ () => h(Panel),
573
+ ))
574
+ },
575
+ }
576
+ },
577
+ })