dsh-bili-scroll 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,31 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-bili-duty contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---------------------------------------------------------------------------
24
+
25
+ 本插件内含第三方代码:`vendor/bilibili-mcp/`
26
+ · 项目:bilibili-mcp
27
+ · 许可证:MIT,Copyright (c) 2026 AGI_Ananas
28
+ · 完整许可见该目录下的 LICENSE 文件
29
+
30
+ (如果你要改版权署名 —— 比如换成你自己的名字或 GitHub 用户名 —— 改上面第一行那个
31
+ "dsh-bili-duty contributors" 就行。)
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # dsh-bili-scroll | 刷B站
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > DSH 插件:**每天替你刷一轮 B站** —— 它自己搜、**读字幕原文**判断、把值得的写成报告,特别值得的会留一条评论 **@ 你**。
4
+ > **你收到的每一条 @,都是它先筛过一遍的。**
5
+
6
+ ## 它做什么
7
+
8
+ - 按你填的关键词(留空则用内置那几组)搜近几天的视频
9
+ - **逐条读字幕原文再判断**(不看摘要);没字幕的直接跳过 —— 除非你开了「本地转写」
10
+ - 写一份 Markdown 报告到 `out/<日期>.md`:每条含「为什么值得」+「它自己的一句话」
11
+ - 开着「自动评论」时,每轮最多发一条评论、**@ 你**一次
12
+ - 可以在**峰谷时段自动跑**(DeepSeek 的优惠时段),省 token
13
+
14
+ ## 装
15
+
16
+ ```bash
17
+ dsh plugin --profile web add dsh-bili-duty # 从 npm
18
+ ```
19
+
20
+ ⚠️ 装完**必须重启一次 dsh**(bundle 层要重启才组装)。然后进 **设置 → 刷B站**。
21
+
22
+ ## 首次使用(两步)
23
+
24
+ 1. **装依赖** —— 设置页点「装依赖(建 venv)」:在插件自己的家目录建一个独立 venv,装 3 个包
25
+ (`bilibili-api-python` / `mcp<2` / `qrcode`)。**走国内镜像 + uv,通常几十秒**(进度写 `install.log`,不卡页面)。
26
+ 2. **扫码登录** —— 点「扫码登录」,用手机 B站 扫页面上那张码。
27
+ ⚠️ **确认时选「记住我 / 长期」那张**(选 24 小时的话,明天就得重扫)。
28
+ 凭证只落在你本机的插件家目录:`<DSH_HOME>/bili-duty/bili_credential.json`,不上传任何地方。
29
+
30
+ ## 设置页里有什么
31
+
32
+ | 项 | 说明 |
33
+ |---|---|
34
+ | **峰谷时段自动刷** | 总开关;**打开之后才展开**「中午谷时 12:00–14:00」「晚上/夜间谷时 18:00–09:00」两个子开关。依据 DeepSeek 官方定价:**工作日 9:00–12:00、14:00–18:00 是高峰,其余(含整个周末)都是谷时** |
35
+ | **每天最多挑几条** | 数字自己填;填 0 = 不自动刷(但还能手动跑) |
36
+ | **自动评论并 @ 你** | **默认关**;打开后展开两栏:**每轮最多发几条评论**、**要 @ 谁(B站 昵称)** —— ⚠️ **留空就不发评论**(评论的意义就是 @ 那一下) |
37
+ | **本地转写没字幕的视频** | **默认关** —— 要在本机装 Whisper、下几百 MB 模型 |
38
+ | **关注关键词** | 一行一个;留空用内置那几组 |
39
+ | **工作区** | 它按这里的 `AGENTS.md` 认「设定」⇒ **你有设定就按你的来** |
40
+ | 现在跑一轮 / 装依赖 / 扫码登录 | |
41
+
42
+ ## 红线(写死在提示词里,不靠自觉)
43
+
44
+ - 不透露你的隐私:账号、机器路径、你聊过的话 —— 一个字都不进评论
45
+ - 每轮最多一条评论;同一视频只评论一次;不刷屏、不吵架
46
+ - 不确定的事**不编**(报告里如实写「没查到」)
47
+
48
+ ## 它是怎么跑的
49
+
50
+ - 插件**内部**起一个 Python MCP(下面那个开源项目,已内置),把它的能力注册成 DSH 工具
51
+ - 定时到点 → 起一个 headless dsh 会话,把 `prompts/duty.md`(它一轮要做的事)+ 当天设置交给它
52
+ - 那一轮的**工作目录 = 你填的工作区** ⇒ 那里的 `AGENTS.md` 会自动加载 ⇒ **它按你的设定说话**,
53
+ 而不是用一套通用人格
54
+
55
+ ## 依赖与致谢
56
+
57
+ - 内置 [**bilibili-mcp**](https://github.com/adoresever/bilibili-mcp)(MIT,Copyright (c) 2026 AGI_Ananas)——
58
+ 搜索 / 字幕 / 评论 / 发评论这些能力来自它;本插件只做「**挑什么、什么时候挑、要不要说话**」这一层
59
+ - 需要本机有 **Python 3.10+**(会用 `uv` 或 `python -m venv` 建独立环境),不需要它已经进 PATH
60
+ - Python 依赖只有 3 个(见 `vendor/bilibili-mcp/requirements.txt`);`mcp` **必须锁 `<2`**(2.x 起不来)
61
+
62
+ ## 路线图
63
+
64
+ - **v0.1(当前)**:内置 MCP + 扫码登录 + 章程 + 设置页(峰谷级联开关 / 定量刷 / 自动评论 / 工作区)+ 谷时定时器 + 报告
65
+ - **v0.2**:判重表文档化、报告归档、失败重试与日志查看
66
+ - **v0.3**:**用 JS 重写那几个接口 ⇒ 不再需要 Python**(零安装)
67
+
68
+ ## 许可证
69
+
70
+ MIT(内置的 `vendor/bilibili-mcp/` 另见该目录下的 LICENSE)
package/client.js ADDED
@@ -0,0 +1,370 @@
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
+ // ⚠️ 输入框必须绑"本地草稿":页面每 5 秒轮询 /status,服务端那份会把 config 盖掉
138
+ // ⇒ 直接绑 cfg 的话,用户打字打到一半就被冲没了 ✗(2026-10-14 陌生人验收时抓到)
139
+ const [perDay, setPerDay] = React.useState(null)
140
+ const [perRound, setPerRound] = React.useState(null)
141
+ const [mention, setMention] = React.useState(null)
142
+ const [login, setLogin] = React.useState(null)
143
+ const [loginState, setLoginState] = React.useState('')
144
+ const [loginTone, setLoginTone] = React.useState('')
145
+
146
+ const cfg = st?.config
147
+ React.useEffect(() => {
148
+ if (cfg && kws === null) setKws((cfg.keywords || []).join('\n'))
149
+ if (cfg && ws === null) setWs(cfg.workspace || '')
150
+ if (cfg && perDay === null) setPerDay(String(cfg.perDay ?? 5))
151
+ if (cfg && perRound === null) setPerRound(String(cfg.commentPerRound ?? 1))
152
+ if (cfg && mention === null) setMention(cfg.mentionName || '')
153
+ }, [cfg])
154
+
155
+ const win = { ...DEFAULT_WINDOWS, ...(st?.windows || {}) }
156
+
157
+ const say = (text, tone) => { setMsg(text); setMsgTone(tone || '') }
158
+
159
+ const patch = async (p, what) => {
160
+ setBusy(JSON.stringify(p))
161
+ try {
162
+ const r = await jpost('/config', p)
163
+ if (r?.config) setSt((old) => ({ ...old, config: r.config }))
164
+ say(what ? `${what}:已保存` : '已保存', 'good')
165
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
166
+ finally { setBusy('') }
167
+ }
168
+
169
+ const onInstall = async () => {
170
+ setBusy('install')
171
+ say('')
172
+ try {
173
+ const r = await jpost('/install', {})
174
+ say(r?.hint || '已经在后台开始装了', '')
175
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
176
+ finally { setBusy('') }
177
+ }
178
+
179
+ const onRun = async () => {
180
+ setBusy('run')
181
+ try {
182
+ const r = await jpost('/run', {})
183
+ say(r?.started ? '已经在后台跑一轮了,报告会写在 out/ 里' : '没起来,看 duty.log', r?.started ? 'good' : 'warn')
184
+ } catch (e) { say(String(e?.message ?? e), 'warn') }
185
+ finally { setBusy('') }
186
+ }
187
+
188
+ const onLoginStart = async () => {
189
+ setBusy('login')
190
+ setLoginTone('')
191
+ setLoginState('正在出码…')
192
+ try {
193
+ const r = await jpost('/login/start', {})
194
+ if (!r?.ok) { setLoginTone('warn'); setLoginState(r?.error || '出码失败'); return }
195
+ setLogin({ key: r.key, qr: r.qr })
196
+ if (!r.qr) { setLoginTone('warn'); setLoginState('没画出二维码 —— 先点上面的「重装依赖」(venv 里要有 qrcode)') }
197
+ else setLoginState('用手机 B站 扫这张码')
198
+ let n = 0
199
+ const timer = setInterval(async () => {
200
+ n += 1
201
+ try {
202
+ const p = await jpost('/login/poll', { key: r.key })
203
+ if (p?.state === 'ok') {
204
+ clearInterval(timer); setLogin(null)
205
+ setLoginTone('good'); setLoginState('登录成功')
206
+ setSt((old) => ({ ...old, loggedIn: true }))
207
+ } else if (p?.state === 'scanned') { setLoginTone(''); setLoginState('扫到了,请在手机上确认') }
208
+ else if (p?.state === 'expired') { clearInterval(timer); setLogin(null); setLoginTone('warn'); setLoginState('二维码过期了,再点一次「扫码登录」') }
209
+ else { setLoginTone(''); setLoginState('等扫码…') }
210
+ } catch { /* 抖动就下次再问 */ }
211
+ if (n > 90) { clearInterval(timer); setLogin(null); setLoginTone('warn'); setLoginState('超时了,再点一次「扫码登录」') }
212
+ }, 2000)
213
+ } catch (e) { setLoginTone('warn'); setLoginState(String(e?.message ?? e)) }
214
+ finally { setBusy('') }
215
+ }
216
+
217
+ const onSaveText = async () => {
218
+ const list = String(kws || '').split('\n').map((s) => s.trim()).filter(Boolean)
219
+ await patch({ keywords: list, workspace: String(ws || '').trim() }, '关键词与工作区')
220
+ }
221
+
222
+ if (!cfg) {
223
+ return h('div', { 'data-bd-page': '' }, h('div', { 'data-bd-note': '' }, '读设置中…'))
224
+ }
225
+
226
+ const sw = (k) => Boolean(cfg[k])
227
+
228
+ return h('div', { 'data-bd-page': '' },
229
+
230
+ /* ── 今天 ── */
231
+ h('div', { 'data-bd-row': '' },
232
+ h('span', { 'data-bd-badge': '' }, `今天已挑 ${st.ranToday} / ${cfg.perDay} 条`),
233
+ h('span', { 'data-bd-badge': '' }, `venv ${st.venvReady ? '就绪' : '未装'}${st.installing ? '(正在装)' : ''}`),
234
+ h('span', { 'data-bd-badge': '' }, st.loggedIn ? 'B站已登录' : 'B站未登录'),
235
+ h('div', { 'data-bd-grow': '' }),
236
+ h('button', { 'data-bd-btn': '', disabled: busy === 'run', onClick: onRun }, '现在跑一轮')),
237
+
238
+ /* ── 定时 ── */
239
+ h('div', { 'data-bd-sec': '' },
240
+ h('div', { 'data-bd-h': '' }, '什么时候自己跑'),
241
+ h(ToggleRow, {
242
+ label: '峰谷时段自动刷',
243
+ note: '只在谷时跑 —— 工作日 12:00–14:00 与 18:00 之后(整个周末也算谷时);关了就只能手动跑',
244
+ on: sw('peakValley'),
245
+ onChange: (v) => patch({ peakValley: v }, '峰谷时段自动刷'),
246
+ }),
247
+ sw('peakValley') ? h('div', { 'data-bd-sub': '' },
248
+ ...['noon', 'night'].map((k) => h(ToggleRow, {
249
+ key: k,
250
+ label: win[k].label,
251
+ note: `${win[k].start} – ${win[k].end}`,
252
+ on: sw(k),
253
+ onChange: (v) => patch({ [k]: v }, win[k].label),
254
+ }))) : null),
255
+
256
+ /* ── 一轮挑几条 ── */
257
+ h('div', { 'data-bd-sec': '' },
258
+ h('div', { 'data-bd-h': '' }, '一轮挑几条'),
259
+ h('div', { 'data-bd-row': '' },
260
+ h('div', { 'data-bd-grow': '' },
261
+ h('div', { 'data-bd-label': '' }, '每天最多挑几条'),
262
+ h('div', { 'data-bd-note': '' }, '填 0 = 不自动刷(还是能手动跑)')),
263
+ h('input', {
264
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 0, max: 50,
265
+ value: perDay ?? '',
266
+ onChange: (e) => setPerDay(e.target.value),
267
+ onBlur: () => { const n = Math.max(0, Math.min(Number(perDay) || 0, 50)); setPerDay(String(n)); patch({ perDay: n }, '每天挑几条') },
268
+ })),
269
+ st.lastRunAt ? h('div', { 'data-bd-note': '' }, `上次跑:${new Date(st.lastRunAt).toLocaleString('zh-CN')}`) : null),
270
+
271
+ /* ── 发言与转写 ── */
272
+ h('div', { 'data-bd-sec': '' },
273
+ h('div', { 'data-bd-h': '' }, '它会不会替你说话'),
274
+ h(ToggleRow, {
275
+ label: '自动评论并 @ 你',
276
+ note: '默认关;开了之后它会在挑中的视频下面留评论,并 @ 你填的那个人',
277
+ on: sw('autoComment'),
278
+ onChange: (v) => patch({ autoComment: v }, '自动评论'),
279
+ }),
280
+ sw('autoComment') ? h('div', { 'data-bd-sub': '' },
281
+ h('div', { 'data-bd-row': '' },
282
+ h('div', { 'data-bd-grow': '' },
283
+ h('div', { 'data-bd-label': '' }, '每轮最多发几条评论'),
284
+ h('div', { 'data-bd-note': '' }, '每条评论里 @ 一次下面那个人')),
285
+ h('input', {
286
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', min: 1, max: 20,
287
+ value: perRound ?? '',
288
+ onChange: (e) => setPerRound(e.target.value),
289
+ onBlur: () => { const n = Math.max(1, Math.min(Number(perRound) || 1, 20)); setPerRound(String(n)); patch({ commentPerRound: n }, '每轮几条评论') },
290
+ })),
291
+ h('div', { 'data-bd-label': '' }, '要 @ 谁(B站 昵称)'),
292
+ h('input', {
293
+ 'data-bd-input': '', value: mention ?? '',
294
+ placeholder: '填你要 @ 的那个人的 B站 昵称;留空 = 只写报告、不发评论',
295
+ onChange: (e) => setMention(e.target.value),
296
+ onBlur: () => { const v = String(mention || '').trim(); setMention(v); patch({ mentionName: v }, '要 @ 谁') },
297
+ }),
298
+ h('div', { 'data-bd-note': '' }, '留空的话它就不发评论了 —— 因为评论存在的意义就是 @ 那一下')) : null,
299
+ h(ToggleRow, {
300
+ label: '本地转写没字幕的视频',
301
+ note: '要在本机装 Whisper、下几百 MB 模型;默认关,没字幕的直接跳过',
302
+ on: sw('transcribe'),
303
+ onChange: (v) => patch({ transcribe: v }, '本地转写'),
304
+ })),
305
+
306
+ /* ── 关注什么 / 在哪儿刷 ── */
307
+ h('div', { 'data-bd-sec': '' },
308
+ h('div', { 'data-bd-h': '' }, '关注什么'),
309
+ h('textarea', {
310
+ 'data-bd-input': '', rows: 5, placeholder: '一行一个关键词;留空用内置那几组',
311
+ value: kws ?? '', onChange: (e) => setKws(e.target.value),
312
+ }),
313
+ h('div', { 'data-bd-label': '' }, '工作区(它按这里的 AGENTS.md 认设定)'),
314
+ h('input', {
315
+ 'data-bd-input': '', value: ws ?? '', placeholder: '留空 = dsh 自己的工作目录',
316
+ onChange: (e) => setWs(e.target.value),
317
+ }),
318
+ h('div', { 'data-bd-row': '' },
319
+ h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onSaveText }, '保存'),
320
+ msg ? h('div', { 'data-bd-msg': '', 'data-good': msgTone === 'good' ? '' : undefined, 'data-warn': msgTone === 'warn' ? '' : undefined }, msg) : null)),
321
+
322
+ /* ── 环境与账号 ── */
323
+ h('div', { 'data-bd-sec': '' },
324
+ h('div', { 'data-bd-h': '' }, '环境与账号'),
325
+ h('div', { 'data-bd-row': '' },
326
+ h('button', { 'data-bd-btn': '', disabled: busy === 'install', onClick: onInstall },
327
+ st.venvReady ? '重装依赖' : '装依赖(建 venv)'),
328
+ h('button', { 'data-bd-btn': '', disabled: busy === 'login', onClick: onLoginStart }, '扫码登录'),
329
+ h('div', { 'data-bd-note': '' }, st.loggedIn ? '已登录' : '还没登录 B站')),
330
+ loginState ? h('div', { 'data-bd-msg': '', 'data-good': loginTone === 'good' ? '' : undefined, 'data-warn': loginTone === 'warn' ? '' : undefined }, loginState) : null,
331
+ st.installTail ? h('div', null,
332
+ h('div', { 'data-bd-note': '' }, st.installing ? '正在装依赖(进度):' : '装依赖的日志(末尾):'),
333
+ h('pre', { 'data-bd-pre': '' }, st.installTail)) : null,
334
+ login?.qr ? h('div', { 'data-bd-qr': '' },
335
+ h('img', { src: login.qr, alt: 'B站登录二维码' }),
336
+ h('div', { 'data-bd-note': '' },
337
+ '手机 B站 → 我的 → 扫一扫。',
338
+ h('br'),
339
+ '确认时选「记住我 / 长期」那张 —— 选 24 小时的话,明天就得重扫。')) : null),
340
+
341
+ /* ── 它是怎么干活的(给他看的) ── */
342
+ h('div', { 'data-bd-sec': '' },
343
+ h('div', { 'data-bd-h': '' }, '它的地盘'),
344
+ h('div', { 'data-bd-kv': '' },
345
+ `家目录:${st.home}`,
346
+ h('br'),
347
+ `今天的报告:${st.report}`)),
348
+ )
349
+ }
350
+
351
+ return {
352
+ inject: ['slots'],
353
+ apply(ctx) {
354
+ const style = document.createElement('style')
355
+ style.setAttribute('data-plugin-css', 'dsh-bili-duty')
356
+ style.textContent = CSS
357
+ document.head.appendChild(style)
358
+ ctx.effect(() => () => style.remove(), 'dsh-bili-duty: 设置页样式')
359
+
360
+ // ⭐ 进「设置」:label 就是左侧导航里那一项(抄 dsh-meme 的写法)
361
+ const slots = (typeof ctx.get === 'function' ? ctx.get('slots') : undefined) || ctx.slots
362
+ if (!slots) return
363
+ slots.inject('settings.section', () => slots.register(
364
+ { name: 'settings.section', id: 'bili-scroll', order: 30, label: '刷B站' },
365
+ () => h(Panel),
366
+ ))
367
+ },
368
+ }
369
+ },
370
+ })
@@ -0,0 +1,20 @@
1
+ # ============================================================================
2
+ # dsh-bili-duty —— DSH bundle patch(插件**自带**的挂载声明)
3
+ # ============================================================================
4
+ # 这就是"标准 DSH 插件"的样子:包自己声明要往 profile 的配置树里插什么。
5
+ # 装上之后,profile 只要在 `dsh.profile.bundles` 里列出本包名即生效,
6
+ # 不用再手改 profile 自己的 cordis.patch.yml:
7
+ #
8
+ # dsh plugin --profile web add dsh-bili-scroll
9
+ # (本地开发时用:dsh plugin --profile web add link:<你自己的插件目录>)
10
+ #
11
+ # 组成顺序:每个 bundle 的 patch 按 `dsh.profile.bundles` 顺序叠
12
+ # → 再叠 profile 自己那一层的 cordis.patch.yml(只有那层是热重载)
13
+ # ============================================================================
14
+
15
+ - insert:
16
+ - id: bili-scroll
17
+ name: dsh-bili-scroll
18
+ config:
19
+ # 真正的用户配置在插件自己的 config 里(设置页可改),这里只放默认值
20
+ enabled: true