dsh-foxy-weibo 0.1.0 → 0.2.1

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
@@ -6,12 +6,13 @@ Foxy 的微博子插件:按 foxy3 预设关注微博账号、阅读博文、
6
6
 
7
7
  ## 功能
8
8
 
9
- - 关注一个或多个微博 UID,定时读取最新博文;
10
- - 读取评论区,按 foxy3 预设判断 `skip / remember / comment`;
11
- - 可选自动评论,支持回复评论区中的某条评论(`replyOthers`);
12
- - 评论前检查每日配额,默认关闭自动评论;
13
- - 浏览、评论事件写入 Foxy 记忆,字段包含 `platform: weibo`、`channelType: weibo_post`、`eventType: weibo_browse|weibo_comment`;
14
- - 设置页提供 Cookie、关注 UID、模型、间隔、评论模式、调试日志与立即运行。
9
+ - **主模式**:检查自己的微博 UID,读取自己新博文下的评论,只回复未回复的他人评论;
10
+ - 可设置每日检查时间(`HH:MM`,最多 6 个),例如 `08:30, 20:00`;
11
+ - 回复前按 foxy3 预设判断 `skip / comment`,不设最少字数,最多 80 字;
12
+ - 旧模式保留:关注一个或多个微博 UID,定时读取时间线并评论;
13
+ - 可选自动回复/评论,支持 `replyOthers`、每日上限;
14
+ - 浏览、评论、回复事件写入 Foxy 记忆,字段包含 `platform: weibo`、`channelType: weibo_post|weibo_comment`、`eventType: weibo_browse|weibo_comment|weibo_reply`;
15
+ - 设置页提供扫码登录、Cookie 备用、自己的 UID、关注 UID、模型、每日时间、回复模式、调试日志与立即运行。
15
16
 
16
17
  ## 安装
17
18
 
@@ -23,9 +24,20 @@ dsh plugin --profile desktop add file:D:\Documents\deepseek-harness\default-work
23
24
 
24
25
  ## 配置
25
26
 
27
+ ### 扫码登录
28
+
29
+ 设置页点「扫码登录」,用微博 App 扫码确认即可。插件会:
30
+
31
+ 1. 获取 `login.sina.com.cn` 的二维码;
32
+ 2. 轮询扫码状态;
33
+ 3. 登录成功后合并 `SUB/SUBP/XSRF-TOKEN` 等 Set-Cookie;
34
+ 4. 自动填写账号 UID,并把 Cookie 保存到本机。
35
+
36
+ 二维码过期时点「刷新二维码」。也可以继续手动填 Cookie 作为备用。
37
+
26
38
  ### 微博 Cookie
27
39
 
28
- 读取公开时间线在部分网络环境会触发微博风控(HTTP 432),因此建议填写登录 Cookie:
40
+ 读取公开时间线在部分网络环境会触发微博风控(HTTP 432),因此建议登录:
29
41
 
30
42
  - `SUB`
31
43
  - `SUBP`
@@ -33,9 +45,11 @@ dsh plugin --profile desktop add file:D:\Documents\deepseek-harness\default-work
33
45
 
34
46
  Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前端。插件优先使用 `m.weibo.cn`,失败时用 `weibo.com/ajax/statuses/mymblog` 作为备用接口。
35
47
 
36
- ### 关注 UID
48
+ ### 自己的 UID / 关注 UID
49
+
50
+ `自己的微博 UID` 是主模式使用的账号,插件只检查它的新博文和评论。
37
51
 
38
- 多个 UID 用逗号分隔,最多 20 个。UID 是微博用户主页 URL 里的数字 ID,例如 `https://weibo.com/u/1234567890`。
52
+ `关注 UID` 是旧模式使用,多个 UID 用逗号分隔,最多 20 个。UID 是微博用户主页 URL 里的数字 ID,例如 `https://weibo.com/u/1234567890`。
39
53
 
40
54
  ### AI 模型
41
55
 
@@ -49,10 +63,10 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
49
63
 
50
64
  ## 建议顺序
51
65
 
52
- 1. 先填 Cookie 和关注 UID,`enabled: true`、`commentMode: off`;
53
- 2. 点「立即运行」,看日志和「调试」里的模型决策;
54
- 3. 确认判断正常后,再开启 `commentMode: auto`,每日上限先设为 1;
55
- 4. 需要回复他人评论时再打开 `replyOthers`;
66
+ 1. 扫码登录,填自己的微博 UID,`enabled: true`、`replyMode: off`;
67
+ 2. 设置每日检查时间,或先点「立即运行」,看日志和「调试」里的模型决策;
68
+ 3. 确认判断正常后,再开启 `replyMode: auto`,每日上限先设为 1;
69
+ 4. 「立即检查」只判断不发布,用来预览回复;「立即回复」会真实发布一条,适合测试;
56
70
  5. 微博评论有账号风控,不要高频、不要对陌生人全自动回复。
57
71
 
58
72
  ## 记忆事件
@@ -61,6 +75,7 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
61
75
 
62
76
  - 浏览:`我在微博看到 <博主> 的博文「...」,觉得...`
63
77
  - 评论:`我在微博评论了 <博主> 的博文「...」:...`
78
+ - 回复:`我在微博回复了 <网友> 在自己博文「...」下的评论:...`
64
79
 
65
80
  事件字段:
66
81
 
package/cordis.patch.yml CHANGED
@@ -10,12 +10,17 @@
10
10
  config:
11
11
  enabled: false
12
12
  cookie: ''
13
+ selfUid: ''
13
14
  targetUids: []
15
+ replyOwnPosts: true
16
+ replyMode: 'off'
17
+ scheduleTimes:
18
+ - '20:00'
14
19
  intervalMinutes: 60
15
20
  maxPostsPerRun: 5
16
21
  commentMode: 'off'
17
22
  maxCommentsPerDay: 1
18
- replyOthers: false
23
+ replyOthers: true
19
24
  enableMemory: true
20
25
  memoryPresetId: 'foxy3(Character)'
21
26
  llmBaseUrl: 'https://api.deepseek.com'
package/lib/client.js CHANGED
@@ -43,6 +43,9 @@ window.__ModuleLoader__.load({
43
43
  .fw-toggle strong { display: block; font-size: 12px; }
44
44
  .fw-toggle small { display: block; margin-top: 2px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
45
45
  .fw-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
46
+ .fw-qr { display: flex; align-items: center; gap: 14px; margin-top: 12px; padding: 10px; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 8px; }
47
+ .fw-qr img { display: block; flex: none; width: 180px; height: 180px; background: #fff; }
48
+ .fw-qr p { margin: 0; color: var(--dsw-alias-label-secondary, #4b5563); font-size: 12px; }
46
49
  .fw-btn { border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 7px; padding: 7px 12px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
47
50
  .fw-btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
48
51
  .fw-btn:disabled { opacity: .55; cursor: default; }
@@ -97,6 +100,10 @@ window.__ModuleLoader__.load({
97
100
  const [cookieDraft, setCookieDraft] = React.useState('')
98
101
  const [apiKeyDraft, setApiKeyDraft] = React.useState('')
99
102
  const [targetText, setTargetText] = React.useState('')
103
+ const [scheduleText, setScheduleText] = React.useState('')
104
+ const [qr, setQr] = React.useState('')
105
+ const [qrState, setQrState] = React.useState('')
106
+ const qrTimer = React.useRef(null)
100
107
  const [busy, setBusy] = React.useState('')
101
108
  const [error, setError] = React.useState('')
102
109
  const [notice, setNotice] = React.useState('')
@@ -108,12 +115,16 @@ window.__ModuleLoader__.load({
108
115
  setSettings(data.settings)
109
116
  setStatus(data.status)
110
117
  setTargetText((data.settings?.targetUids || []).join(', '))
118
+ setScheduleText((data.settings?.scheduleTimes || []).join(', '))
111
119
  }, [])
112
120
 
113
121
  React.useEffect(() => {
114
122
  mounted.current = true
115
123
  refresh().catch((e) => setError(e.message))
116
- return () => { mounted.current = false }
124
+ return () => {
125
+ mounted.current = false
126
+ if (qrTimer.current) clearTimeout(qrTimer.current)
127
+ }
117
128
  }, [refresh])
118
129
 
119
130
  const setField = (key, value) => setSettings((current) => (current ? { ...current, [key]: value } : current))
@@ -122,7 +133,7 @@ window.__ModuleLoader__.load({
122
133
  if (!settings) return
123
134
  setBusy('save'); setError(''); setNotice('')
124
135
  try {
125
- const patch = { ...settings, targetUids: targetText }
136
+ const patch = { ...settings, targetUids: targetText, scheduleTimes: scheduleText }
126
137
  if (cookieDraft.trim()) patch.cookie = cookieDraft.trim()
127
138
  if (apiKeyDraft.trim()) patch.llmApiKey = apiKeyDraft.trim()
128
139
  const data = await request('/settings', { method: 'POST', body: JSON.stringify(patch) })
@@ -134,6 +145,68 @@ window.__ModuleLoader__.load({
134
145
  } catch (e) { setError(e.message) } finally { setBusy('') }
135
146
  }
136
147
 
148
+ const pollLogin = async (qrid, attempt = 0) => {
149
+ if (!mounted.current || attempt > 90) return
150
+ try {
151
+ const data = await request('/login/poll', {
152
+ method: 'POST',
153
+ body: JSON.stringify({ qrid }),
154
+ })
155
+ if (!mounted.current) return
156
+ if (data.state === 'ok') {
157
+ setQrState('ok')
158
+ setQr('')
159
+ setSettings(data.settings)
160
+ setStatus(data.status)
161
+ setNotice(`已登录${data.account?.screenName ? `:${data.account.screenName}` : ''}`)
162
+ return
163
+ }
164
+ if (data.state === 'expired') {
165
+ setQrState('expired')
166
+ setQr('')
167
+ return
168
+ }
169
+ setQrState(data.state)
170
+ qrTimer.current = setTimeout(() => pollLogin(qrid, attempt + 1), 2000)
171
+ } catch (e) {
172
+ if (mounted.current) setError(e.message)
173
+ }
174
+ }
175
+
176
+ const startLogin = async () => {
177
+ if (qrTimer.current) clearTimeout(qrTimer.current)
178
+ setBusy('login')
179
+ setError('')
180
+ setNotice('')
181
+ try {
182
+ const data = await request('/login/start', { method: 'POST', body: '{}' })
183
+ setQr(data.qr)
184
+ setQrState('waiting')
185
+ pollLogin(data.qrid)
186
+ } catch (e) {
187
+ setError(e.message)
188
+ } finally {
189
+ setBusy('')
190
+ }
191
+ }
192
+
193
+ const logout = async () => {
194
+ setBusy('logout')
195
+ setError('')
196
+ setNotice('')
197
+ try {
198
+ const data = await request('/logout', { method: 'POST', body: '{}' })
199
+ setSettings(data.settings)
200
+ setStatus(data.status)
201
+ setQr('')
202
+ setNotice('已退出微博登录')
203
+ } catch (e) {
204
+ setError(e.message)
205
+ } finally {
206
+ setBusy('')
207
+ }
208
+ }
209
+
137
210
  const runNow = async () => {
138
211
  setBusy('run'); setError(''); setNotice('')
139
212
  try {
@@ -145,7 +218,45 @@ window.__ModuleLoader__.load({
145
218
  ? '未开始:自动浏览已关闭'
146
219
  : result.skipped === 'busy'
147
220
  ? '正在运行,请稍候'
148
- : `运行完成:检查 ${result.checked || 0} 条,记录 ${result.remembered || 0} 条,评论 ${result.commented || 0} 条`,
221
+ : result.skipped === 'no-self'
222
+ ? '未开始:请先填写自己的微博 UID'
223
+ : `运行完成:检查 ${result.checked || 0} 条,回复 ${result.replied || 0} 条` +
224
+ (result.previewed ? `,预览 ${result.previewed} 条` : '') +
225
+ `,跳过 ${result.skipped || 0} 条`,
226
+ )
227
+ } catch (e) { setError(e.message) } finally { setBusy('') }
228
+ }
229
+
230
+ const checkNow = async () => {
231
+ setBusy('check'); setError(''); setNotice('')
232
+ try {
233
+ const data = await request('/check-now', { method: 'POST', body: '{}' })
234
+ setStatus(data.status)
235
+ const result = data.result || {}
236
+ setNotice(
237
+ result.skipped === 'no-self'
238
+ ? '未开始:请先扫码登录或填写自己的微博 UID'
239
+ : result.skipped === 'busy'
240
+ ? '正在运行,请稍候'
241
+ : `检查完成:检查 ${result.checked || 0} 条评论,预览 ${result.previewed || 0} 条,跳过 ${result.skipped || 0} 条` +
242
+ (result.errors ? `,失败 ${result.errors} 条` : ''),
243
+ )
244
+ } catch (e) { setError(e.message) } finally { setBusy('') }
245
+ }
246
+
247
+ const replyNow = async () => {
248
+ if (!window.confirm('立即回复会真实发布到微博,确定继续?')) return
249
+ setBusy('reply'); setError(''); setNotice('')
250
+ try {
251
+ const data = await request('/reply-now', { method: 'POST', body: '{}' })
252
+ setStatus(data.status)
253
+ const result = data.result || {}
254
+ setNotice(
255
+ result.replied > 0
256
+ ? `已立即回复 ${result.replied} 条,可在下方日志查看`
257
+ : result.skipped === 'no-self'
258
+ ? '未开始:请先扫码登录或填写自己的微博 UID'
259
+ : '没有可立即回复的评论(可能已处理或模型选择跳过)',
149
260
  )
150
261
  } catch (e) { setError(e.message) } finally { setBusy('') }
151
262
  }
@@ -168,23 +279,26 @@ window.__ModuleLoader__.load({
168
279
  ),
169
280
 
170
281
  h('section', { className: 'fw-card' },
171
- h(SectionHead, { title: '账号与目标', description: '填写微博 Cookie 后即可评论;读取公开博文通常不需要登录。' }),
172
- h(Field, { label: '微博 Cookie', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
173
- h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
282
+ h(SectionHead, { title: '账号与登录', description: '扫码登录后即可回复评论;Cookie 输入保留为备用方式。' }),
174
283
  h('div', { className: 'fw-grid' },
175
- h(Field, { label: '关注 UID', hint: '多个 UID 用逗号分隔,最多 20 个' },
176
- h('input', { value: targetText, placeholder: '例如 1234567890, 9876543210', onChange: (e) => setTargetText(e.target.value) })),
177
- h(Field, { label: '当前状态', hint: '登录与目标数量' },
178
- h('input', { value: settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
284
+ h(Field, { label: '当前账号', hint: '扫码成功后自动显示' },
285
+ h('input', { value: settings.accountName ? `${settings.accountName}(${settings.accountUid || '?'})` : settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
179
286
  ),
287
+ h(Field, { label: '自己的微博 UID', hint: '主功能会检查这个账号的新博文评论' },
288
+ h('input', { value: settings.selfUid || '', placeholder: '例如 1234567890', onChange: (e) => setField('selfUid', e.target.value) })),
180
289
  ),
181
290
  h('div', { className: 'fw-actions' },
182
- h('button', { className: 'fw-btn', disabled: !!busy, onClick: save }, busy === 'save' ? '保存中…' : '保存设置'),
183
- h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick: async () => {
184
- setBusy('logout'); setError(''); setNotice('')
185
- try { const data = await request('/settings', { method: 'POST', body: JSON.stringify({ clearCookie: true }) }); setSettings(data.settings); setStatus(data.status); setNotice('已清除 Cookie') } catch (e) { setError(e.message) } finally { setBusy('') }
186
- } }, '退出登录'),
291
+ h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: startLogin }, busy === 'login' ? '获取二维码…' : qr ? '刷新二维码' : '扫码登录'),
292
+ h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick: logout }, busy === 'logout' ? '退出中…' : '退出登录'),
187
293
  ),
294
+ qr
295
+ ? h('div', { className: 'fw-qr' },
296
+ h('img', { src: qr, alt: '微博登录二维码', width: 180, height: 180 }),
297
+ h('p', null, qrState === 'scanned' ? '已扫码,请在手机上确认登录' : qrState === 'expired' ? '二维码已过期,请刷新' : '请用微博 App 扫码'),
298
+ )
299
+ : null,
300
+ h(Field, { label: '微博 Cookie(备用)', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
301
+ h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
188
302
  ),
189
303
 
190
304
  h('section', { className: 'fw-card' },
@@ -200,27 +314,39 @@ window.__ModuleLoader__.load({
200
314
  ),
201
315
 
202
316
  h('section', { className: 'fw-card' },
203
- h(SectionHead, { title: '浏览与评论', description: '控制轮询频率、每轮阅读数量,以及是否允许公开评论。' }),
204
- h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动浏览', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
317
+ h(SectionHead, { title: '回复自己的博文', description: '主功能:每天按设定时间检查自己的新博文,只回复未回复的他人评论。' }),
318
+ h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动检查', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
319
+ h(Toggle, { checked: settings.replyOwnPosts, onChange: (v) => setField('replyOwnPosts', v), label: '主模式:只看自己的博文评论', hint: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
205
320
  h('div', { className: 'fw-grid' },
206
- h(Field, { label: '浏览间隔', hint: '分钟,范围 5–1440' },
207
- h('input', { type: 'number', min: 5, max: 1440, value: settings.intervalMinutes, onChange: (e) => setField('intervalMinutes', e.target.value) })),
208
- h(Field, { label: '每轮阅读数量', hint: '范围 1–20' },
321
+ h(Field, { label: '每日检查时间', hint: 'HH:MM,多个用逗号分隔,最多 6 个,例如 08:30, 20:00' },
322
+ h('input', { value: scheduleText, placeholder: '20:00', onChange: (e) => setScheduleText(e.target.value) })),
323
+ h(Field, { label: '回复模式', hint: '自动才会发布回复;建议先仅记录' },
324
+ h('select', { value: settings.replyMode, onChange: (e) => setField('replyMode', e.target.value) },
325
+ h('option', { value: 'off' }, '仅检查,不回复'),
326
+ h('option', { value: 'auto' }, '自动回复'),
327
+ )),
328
+ h(Field, { label: '每轮检查博文数', hint: '范围 1–20' },
209
329
  h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
210
- h(Field, { label: '评论模式', hint: '自动评论会使用当前账号发布内容' },
330
+ h(Field, { label: '每日回复上限', hint: '0–10,同时限制评论' },
331
+ h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
332
+ ),
333
+ h('div', { className: 'fw-grid' },
334
+ h(Field, { label: '时间线轮询(旧模式备用)', hint: '关闭主模式时使用,分钟' },
335
+ h('input', { type: 'number', min: 5, max: 1440, value: settings.intervalMinutes, onChange: (e) => setField('intervalMinutes', e.target.value) })),
336
+ h(Field, { label: '旧模式评论模式', hint: '浏览他人博文时是否发一级评论' },
211
337
  h('select', { value: settings.commentMode, onChange: (e) => setField('commentMode', e.target.value) },
212
338
  h('option', { value: 'off' }, '仅记录'),
213
339
  h('option', { value: 'auto' }, '自动评论'),
214
340
  )),
215
- h(Field, { label: '每日评论上限', hint: '0–10,仅限制自动评论' },
216
- h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
217
341
  ),
218
- h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '模型可从评论区选择一条评论回复;关闭时只发一级评论。' }),
342
+ h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
219
343
  h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
220
344
  h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
221
345
  h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
222
346
  h('div', { className: 'fw-actions' },
223
347
  h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: runNow }, busy === 'run' ? '正在运行…' : '立即运行'),
348
+ h('button', { className: 'fw-btn', disabled: !!busy, onClick: checkNow }, busy === 'check' ? '检查中…' : '立即检查'),
349
+ h('button', { className: 'fw-btn', disabled: !!busy, onClick: replyNow }, busy === 'reply' ? '回复中…' : '立即回复'),
224
350
  h('button', { className: 'fw-btn', disabled: !!busy, onClick: save }, busy === 'save' ? '保存中…' : '保存设置'),
225
351
  ),
226
352
  h('div', { className: 'fw-stats' },
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import { fetchComments, fetchTimeline, postComment, postSummary, WeiboError } from './weibo.js'
3
+ import { fetchComments, fetchTimeline, postComment, postSummary } from './weibo.js'
4
4
  import { judgePost } from './judge.js'
5
+ import { fetchAccount, loginPoll, loginStart } from './login.js'
5
6
  import { DEFAULTS, foxyHome, loadSettings, normalizePatch, publicSettings, saveSettings } from './settings.js'
6
7
 
7
8
  export const name = 'dsh-foxy-weibo'
@@ -10,6 +11,11 @@ export const inject = ['connection']
10
11
  export const STATUS_ROUTE = '/api/dsh-foxy-weibo/status'
11
12
  export const SETTINGS_ROUTE = '/api/dsh-foxy-weibo/settings'
12
13
  export const RUN_ROUTE = '/api/dsh-foxy-weibo/run'
14
+ export const CHECK_NOW_ROUTE = '/api/dsh-foxy-weibo/check-now'
15
+ export const REPLY_NOW_ROUTE = '/api/dsh-foxy-weibo/reply-now'
16
+ export const LOGIN_START_ROUTE = '/api/dsh-foxy-weibo/login/start'
17
+ export const LOGIN_POLL_ROUTE = '/api/dsh-foxy-weibo/login/poll'
18
+ export const LOGOUT_ROUTE = '/api/dsh-foxy-weibo/logout'
13
19
 
14
20
  const ACTION_LABELS = { skip: '跳过', remember: '记录', comment: '评论', error: '失败' }
15
21
 
@@ -22,6 +28,8 @@ function loadState() {
22
28
  const parsed = JSON.parse(readFileSync(statePath(), 'utf8'))
23
29
  return {
24
30
  seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
31
+ commentSeen:
32
+ parsed.commentSeen && typeof parsed.commentSeen === 'object' ? parsed.commentSeen : {},
25
33
  days: parsed.days && typeof parsed.days === 'object' ? parsed.days : {},
26
34
  history: Array.isArray(parsed.history) ? parsed.history.slice(0, 50) : [],
27
35
  debug: Array.isArray(parsed.debug) ? parsed.debug.slice(0, 80) : [],
@@ -29,7 +37,7 @@ function loadState() {
29
37
  lastResult: parsed.lastResult || null,
30
38
  }
31
39
  } catch {
32
- return { seen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
40
+ return { seen: {}, commentSeen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
33
41
  }
34
42
  }
35
43
 
@@ -120,6 +128,181 @@ async function remember(ctx, config, state, post, decision, extraText = '') {
120
128
  }
121
129
  }
122
130
 
131
+ async function rememberReply(ctx, config, state, post, comment, replyText) {
132
+ if (!config.enableMemory) return { ok: false, reason: 'disabled' }
133
+ const provider = memoryProvider(ctx)
134
+ if (!provider?.record) return { ok: false, reason: 'no-provider' }
135
+ const person = comment.author?.name || comment.author?.uid || '一位网友'
136
+ const text = `我在微博回复了 ${person} 在自己博文「${firstLine(post.text, 60)}」下的评论:${replyText}`
137
+ try {
138
+ await provider.record({
139
+ role: 'assistant',
140
+ text,
141
+ sessionId: `weibo-comment:${post.id}:${comment.cid}`,
142
+ presetId: config.memoryPresetId || 'foxy3(Character)',
143
+ platform: 'weibo',
144
+ sourceLabel: '微博评论回复',
145
+ eventType: 'weibo_reply',
146
+ channelType: 'weibo_comment',
147
+ channelId: String(comment.cid),
148
+ title: firstLine(post.text, 120),
149
+ url: post.url,
150
+ scene: 'weibo',
151
+ })
152
+ debugLog(state, config, { kind: 'memory', post: post.id, memory: 'written', text: firstLine(text, 300) })
153
+ return { ok: true, reason: 'written', text }
154
+ } catch (error) {
155
+ const reason = error?.message || String(error)
156
+ debugLog(state, config, { kind: 'memory', post: post.id, memory: 'error', reason })
157
+ return { ok: false, reason }
158
+ }
159
+ }
160
+
161
+ async function scanOwnComments(ctx, config, state, log, force = false, options = {}) {
162
+ if (!config.enabled && !force) return { skipped: 'disabled' }
163
+ const previewOnly = options.previewOnly === true
164
+ const forceReply = options.forceReply === true
165
+ const canReply = !previewOnly && (forceReply || config.replyMode === 'auto')
166
+ const targetCid = String(options.cid || '').trim()
167
+ const selfUid = String(config.selfUid || config.accountUid || '').trim()
168
+ if (!selfUid) return { skipped: 'no-self' }
169
+ const maxPosts = Math.max(1, Math.min(20, Number(config.maxPostsPerRun) || 5))
170
+ const maxReplies = Math.max(1, Math.min(8, Number(options.maxReplies ?? config.maxCommentsPerDay) || 1))
171
+ const ignoreQuota = forceReply
172
+ const result = {
173
+ mode: options.mode || 'own-comments',
174
+ checked: 0,
175
+ replied: 0,
176
+ previewed: 0,
177
+ skipped: 0,
178
+ errors: 0,
179
+ items: [],
180
+ }
181
+ let posts = []
182
+ try {
183
+ posts = await fetchTimeline(selfUid, config)
184
+ } catch (error) {
185
+ return { ...result, errors: 1, error: error?.message || String(error) }
186
+ }
187
+
188
+ for (const post of posts.slice(0, maxPosts)) {
189
+ if (!post.id) continue
190
+ let comments = []
191
+ try {
192
+ comments = await fetchComments(post.mid, config, 30)
193
+ } catch (error) {
194
+ result.errors += 1
195
+ log(`[foxy-weibo] 读取评论失败 ${post.id}:${error?.message || error}`)
196
+ continue
197
+ }
198
+ for (const comment of comments) {
199
+ if (targetCid && String(comment.cid) !== targetCid) continue
200
+ if (canReply && result.replied >= maxReplies) break
201
+ if (!comment.cid || state.commentSeen[comment.cid]) continue
202
+ if (String(comment.author?.uid || '') === selfUid) {
203
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'self' }
204
+ continue
205
+ }
206
+ const limit = Math.max(0, Number(config.maxCommentsPerDay) || 0)
207
+ if (canReply && !ignoreQuota && commentCount(state) >= limit) break
208
+ result.checked += 1
209
+ let entry
210
+ try {
211
+ const decision = await judgePost(config, {
212
+ summary: postSummary(post),
213
+ comments,
214
+ commentsText: commentsTextOf(comments),
215
+ targetComment: {
216
+ cid: String(comment.cid),
217
+ text: comment.text,
218
+ author: comment.author,
219
+ },
220
+ mayComment: true,
221
+ mayReply: true,
222
+ })
223
+ debugLog(state, config, {
224
+ kind: 'judge-own-comment',
225
+ post: post.id,
226
+ cid: String(comment.cid),
227
+ action: decision.action,
228
+ raw: String(decision.raw || '').slice(0, 500),
229
+ })
230
+ if (decision.action !== 'comment' || !decision.comment) {
231
+ result.skipped += 1
232
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'skipped' }
233
+ entry = {
234
+ at: Date.now(),
235
+ post: post.id,
236
+ cid: String(comment.cid),
237
+ author: comment.author?.name || comment.author?.uid,
238
+ title: firstLine(post.text, 100),
239
+ action: 'skip',
240
+ reason: decision.mood || '模型判断不需要回复',
241
+ memory: 'skipped',
242
+ }
243
+ } else if (!canReply) {
244
+ result.previewed += 1
245
+ entry = {
246
+ at: Date.now(),
247
+ post: post.id,
248
+ cid: String(comment.cid),
249
+ author: comment.author?.name || comment.author?.uid,
250
+ title: firstLine(post.text, 100),
251
+ action: 'preview',
252
+ comment: decision.comment,
253
+ reason: '回复模式为仅检查,未发布',
254
+ memory: 'skipped',
255
+ }
256
+ log(`[foxy-weibo] 仅检查 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
257
+ } else {
258
+ await postComment({
259
+ mid: post.mid,
260
+ text: decision.comment,
261
+ cid: String(comment.cid),
262
+ config,
263
+ })
264
+ bumpComments(state)
265
+ result.replied += 1
266
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'replied' }
267
+ const memory = await rememberReply(ctx, config, state, post, comment, decision.comment)
268
+ entry = {
269
+ at: Date.now(),
270
+ post: post.id,
271
+ cid: String(comment.cid),
272
+ author: comment.author?.name || comment.author?.uid,
273
+ title: firstLine(post.text, 100),
274
+ action: 'reply',
275
+ comment: decision.comment,
276
+ reason: firstLine(decision.comment, 140),
277
+ memory: memory.ok ? 'written' : memory.reason,
278
+ }
279
+ log(`[foxy-weibo] 回复 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
280
+ }
281
+ } catch (error) {
282
+ result.errors += 1
283
+ entry = {
284
+ at: Date.now(),
285
+ post: post.id,
286
+ cid: String(comment.cid),
287
+ author: comment.author?.name || comment.author?.uid,
288
+ title: firstLine(post.text, 100),
289
+ action: 'error',
290
+ reason: '回复评论时出错',
291
+ error: error?.message || String(error),
292
+ }
293
+ log(`[foxy-weibo] 回复评论 ${comment.cid} 失败:${error?.message || error}`)
294
+ }
295
+ if (entry) {
296
+ pushHistory(state, entry)
297
+ result.items.push(entry)
298
+ }
299
+ }
300
+ }
301
+ state.lastRun = Date.now()
302
+ state.lastResult = result
303
+ return result
304
+ }
305
+
123
306
  async function scanOnce(ctx, config, state, log, force = false) {
124
307
  if (!config.enabled && !force) return { skipped: 'disabled' }
125
308
  if (!config.targetUids.length) return { skipped: 'no-targets' }
@@ -282,7 +465,10 @@ export function apply(ctx, rawConfig = {}) {
282
465
  running = true
283
466
  try {
284
467
  log(`[foxy-weibo] 开始运行(${reason})`)
285
- const result = await scanOnce(ctx, config, state, log, force)
468
+ const result =
469
+ config.replyOwnPosts === false
470
+ ? await scanOnce(ctx, config, state, log, force)
471
+ : await scanOwnComments(ctx, config, state, log, force)
286
472
  saveState(state)
287
473
  log(`[foxy-weibo] 运行结束 ${JSON.stringify(result)}`)
288
474
  return result
@@ -295,17 +481,69 @@ export function apply(ctx, rawConfig = {}) {
295
481
  }
296
482
  }
297
483
 
484
+ const checkNow = async () => {
485
+ if (running) return { skipped: 'busy' }
486
+ running = true
487
+ try {
488
+ log('[foxy-weibo] 开始立即检查')
489
+ const result = await scanOwnComments(ctx, config, state, log, true, {
490
+ previewOnly: true,
491
+ mode: 'own-comments-check',
492
+ })
493
+ saveState(state)
494
+ log(`[foxy-weibo] 立即检查结束 ${JSON.stringify(result)}`)
495
+ return result
496
+ } catch (error) {
497
+ saveState(state)
498
+ ctx.logger?.warn?.(`[foxy-weibo] check failed: ${error?.message || error}`)
499
+ return { error: String(error?.message || error) }
500
+ } finally {
501
+ running = false
502
+ }
503
+ }
504
+
505
+ const replyNow = async (cid = '') => {
506
+ if (running) return { skipped: 'busy' }
507
+ running = true
508
+ try {
509
+ log(`[foxy-weibo] 开始立即回复${cid ? ` cid=${cid}` : ''}`)
510
+ const result = await scanOwnComments(ctx, config, state, log, true, {
511
+ forceReply: true,
512
+ maxReplies: 1,
513
+ cid,
514
+ mode: 'own-comments-reply-now',
515
+ })
516
+ saveState(state)
517
+ log(`[foxy-weibo] 立即回复结束 ${JSON.stringify(result)}`)
518
+ return result
519
+ } catch (error) {
520
+ saveState(state)
521
+ ctx.logger?.warn?.(`[foxy-weibo] reply-now failed: ${error?.message || error}`)
522
+ return { error: String(error?.message || error) }
523
+ } finally {
524
+ running = false
525
+ }
526
+ }
527
+
298
528
  const status = () => ({
299
529
  enabled: config.enabled === true,
300
530
  running,
531
+ mode: config.replyOwnPosts === false ? 'timeline' : 'own-comments',
532
+ selfUid: config.selfUid || config.accountUid || '',
533
+ scheduleTimes: config.scheduleTimes,
534
+ nextRunAt,
535
+ replyMode: config.replyMode,
301
536
  lastRun: state.lastRun,
302
537
  lastResult: state.lastResult,
303
538
  recent: state.history.slice(0, 12),
304
539
  debugEnabled: config.debug === true,
305
540
  debug: state.debug.slice(0, 40),
306
541
  seen: Object.keys(state.seen).length,
542
+ commentsSeen: Object.keys(state.commentSeen || {}).length,
307
543
  commentsToday: commentCount(state),
308
544
  cookieSet: Boolean(config.cookie),
545
+ accountName: config.accountName || '',
546
+ accountUid: config.accountUid || '',
309
547
  apiKeySet: Boolean(config.llmApiKey),
310
548
  targetCount: config.targetUids.length,
311
549
  })
@@ -315,14 +553,37 @@ export function apply(ctx, rawConfig = {}) {
315
553
  foxy?.registerCapability?.('weibo', async () => capability)
316
554
  ctx.provide?.('foxyWeibo', capability)
317
555
 
556
+ let nextRunAt = 0
557
+ const nextDelay = () => {
558
+ const times = Array.isArray(config.scheduleTimes) ? config.scheduleTimes : []
559
+ if (!times.length) return Math.max(5, Number(config.intervalMinutes) || 60) * 60 * 1000
560
+ const now = new Date()
561
+ let best = Infinity
562
+ for (const text of times) {
563
+ const [hour, minute] = String(text).split(':').map(Number)
564
+ if (!Number.isInteger(hour) || !Number.isInteger(minute)) continue
565
+ for (let day = 0; day <= 1; day += 1) {
566
+ const at = new Date(now)
567
+ at.setDate(now.getDate() + day)
568
+ at.setHours(hour, minute, 0, 0)
569
+ const delay = at.getTime() - now.getTime()
570
+ if (delay > 0 && delay < best) best = delay
571
+ }
572
+ }
573
+ return Number.isFinite(best) ? best : 60 * 60 * 1000
574
+ }
318
575
  const restartTimer = () => {
319
- clearInterval(timer)
320
- const minutes = Math.max(5, Number(config.intervalMinutes) || 60)
321
- timer = setInterval(() => run('timer').catch(() => {}), minutes * 60 * 1000)
576
+ clearTimeout(timer)
577
+ const delay = nextDelay()
578
+ nextRunAt = Date.now() + delay
579
+ timer = setTimeout(async () => {
580
+ await run('schedule').catch(() => {})
581
+ restartTimer()
582
+ }, delay)
322
583
  timer.unref?.()
323
584
  }
324
585
  restartTimer()
325
- ctx.effect?.(() => () => clearInterval(timer), 'foxy-weibo: scan timer')
586
+ ctx.effect?.(() => () => clearTimeout(timer), 'foxy-weibo: scan timer')
326
587
 
327
588
  if (config.runOnStart) {
328
589
  startTimer = setTimeout(() => run('start').catch(() => {}), 20_000)
@@ -361,10 +622,44 @@ export function apply(ctx, rawConfig = {}) {
361
622
  }
362
623
  return json({ ok: true, settings: publicSettings(config), status: status() })
363
624
  })
625
+ route(LOGIN_START_ROUTE, ['POST'], async () => json({ ok: true, ...(await loginStart()) }))
626
+
627
+ route(LOGIN_POLL_ROUTE, ['POST'], async (request) => {
628
+ const { qrid } = await readJsonBody(request)
629
+ const result = await loginPoll(qrid)
630
+ if (result.state === 'ok' && result.cookie) {
631
+ config.cookie = result.cookie
632
+ config.accountName = result.account?.screenName || ''
633
+ config.accountUid = result.account?.uid || ''
634
+ if (!config.selfUid && result.account?.uid) config.selfUid = result.account.uid
635
+ saveSettings(config)
636
+ }
637
+ return json({ ok: true, ...result, settings: publicSettings(config), status: status() })
638
+ })
639
+
640
+ route(LOGOUT_ROUTE, ['POST'], async () => {
641
+ config.cookie = ''
642
+ config.accountName = ''
643
+ config.accountUid = ''
644
+ saveSettings(config)
645
+ return json({ ok: true, settings: publicSettings(config), status: status() })
646
+ })
647
+
364
648
  route(RUN_ROUTE, ['POST'], async () => {
365
649
  const result = await run('manual', true)
366
650
  return json({ ok: true, result, status: status() })
367
651
  })
652
+
653
+ route(CHECK_NOW_ROUTE, ['POST'], async () => {
654
+ const result = await checkNow()
655
+ return json({ ok: true, result, status: status() })
656
+ })
657
+
658
+ route(REPLY_NOW_ROUTE, ['POST'], async (request) => {
659
+ const body = await readJsonBody(request)
660
+ const result = await replyNow(body?.cid)
661
+ return json({ ok: true, result, status: status() })
662
+ })
368
663
  })
369
664
 
370
665
  log(
package/lib/judge.js CHANGED
@@ -123,7 +123,8 @@ function finalize(parsed, input, fallbackText) {
123
123
  if (input.forceComment && comment) action = 'comment'
124
124
  if (action === 'comment' && !input.mayComment) action = 'remember'
125
125
  if (action === 'comment' && !comment) action = 'remember'
126
- const replyTo = input.mayReply ? String(parsed.replyTo || '0') : '0'
126
+ let replyTo = input.mayReply ? String(parsed.replyTo || '0') : '0'
127
+ if (input.targetComment?.cid) replyTo = String(input.targetComment.cid)
127
128
  return {
128
129
  action,
129
130
  comment: comment.slice(0, 300),
@@ -144,16 +145,24 @@ export async function judgePost(config, input) {
144
145
  '- comment:值得在评论区说一句,才评论。',
145
146
  input.mayComment ? '本次允许评论。' : '本次不允许评论,只能 skip 或 remember。',
146
147
  '',
147
- '评论要求:像 Foxy 自己刷到后随口说的,中文第一人称,不超过 100 字;不要客服腔,不要复述原文,不要写 @。',
148
+ '评论要求:像 Foxy 自己刷到后随口说的,中文第一人称;不设最少字数,能一句说清就一句,最多 80 字;不要客服腔,不要复述原文,不要写 @。',
148
149
  '记忆要求:如果值得记,用真实事件写第一人称,例如“我在微博看到 xx 说……,觉得……”。不要把所有事件都写成“我刷到”。不超过 180 字。',
149
150
  '',
150
- input.mayReply && input.comments.length
151
+ input.targetComment
151
152
  ? [
152
- '回复规则:可以回复下面某条评论。replyTo 填要回复的 cid;只发一级评论时填 0。',
153
- '不要回复自己,不要回复广告/引战/辱骂/诈骗。',
154
- ...commentLines(input.comments),
153
+ '你正在回复自己微博下的一条评论,不是在写新博文评论:',
154
+ `对方 ${input.targetComment.author?.name || input.targetComment.author?.uid || '网友'} 说:${firstLine(input.targetComment.text, 300)}`,
155
+ '如果是在正常交流,输出 action=comment 并直接回复这句话;不设最少字数,1–2 句,最多 80 字。',
156
+ '如果是广告、引战、辱骂、诈骗或无意义内容,输出 action=skip。',
157
+ 'replyTo 填这条评论的 cid,不要回复其他人。',
155
158
  ].join('\n')
156
- : '本次不回复他人评论;replyTo 必须为 0。',
159
+ : input.mayReply && input.comments.length
160
+ ? [
161
+ '回复规则:可以回复下面某条评论。replyTo 填要回复的 cid;只发一级评论时填 0。',
162
+ '不要回复自己,不要回复广告/引战/辱骂/诈骗。',
163
+ ...commentLines(input.comments),
164
+ ].join('\n')
165
+ : '本次不回复他人评论;replyTo 必须为 0。',
157
166
  '',
158
167
  '只输出一个 JSON 对象,不要 Markdown:',
159
168
  '{"action":"skip|remember|comment","comment":"...","memory":"...","replyTo":0,"mood":"..."}',
package/lib/login.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Weibo QR login.
3
+ *
4
+ * The QR endpoint returns JSONP, and the actual login cookies are spread over
5
+ * several redirects. Keep a tiny cookie jar instead of taking only the last
6
+ * Set-Cookie header.
7
+ */
8
+
9
+ const UA =
10
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126 Safari/537.36'
11
+ const MOBILE_UA =
12
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'
13
+
14
+ function parseJsonp(text) {
15
+ const raw = String(text || '')
16
+ const start = raw.indexOf('{')
17
+ const end = raw.lastIndexOf('}')
18
+ if (start < 0 || end < start) throw new Error('微博登录接口返回格式异常')
19
+ return JSON.parse(raw.slice(start, end + 1))
20
+ }
21
+
22
+ function setCookies(response) {
23
+ if (typeof response.headers.getSetCookie === 'function') {
24
+ return response.headers.getSetCookie()
25
+ }
26
+ const raw = response.headers.get('set-cookie')
27
+ return raw ? [raw] : []
28
+ }
29
+
30
+ class CookieJar {
31
+ constructor(initial = '') {
32
+ this.values = new Map()
33
+ this.add(initial)
34
+ }
35
+
36
+ add(raw) {
37
+ for (const part of String(raw || '').split(/;\s*(?=[^;=]+=)/)) {
38
+ const text = part.trim()
39
+ if (!text || text.includes('\n')) {
40
+ for (const line of text.split('\n')) this.addOne(line)
41
+ } else {
42
+ this.addOne(text)
43
+ }
44
+ }
45
+ }
46
+
47
+ addOne(raw) {
48
+ const text = String(raw || '').split(';')[0].trim()
49
+ const index = text.indexOf('=')
50
+ if (index <= 0) return
51
+ const key = text.slice(0, index).trim()
52
+ const value = text.slice(index + 1).trim()
53
+ if (key && value) this.values.set(key, value)
54
+ }
55
+
56
+ absorb(response) {
57
+ for (const cookie of setCookies(response)) this.addOne(cookie)
58
+ }
59
+
60
+ header() {
61
+ return [...this.values.entries()].map(([key, value]) => `${key}=${value}`).join('; ')
62
+ }
63
+ }
64
+
65
+ async function request(url, { jar, mobile = false, ...options } = {}) {
66
+ const headers = {
67
+ accept: 'application/json, text/plain, */*',
68
+ 'user-agent': mobile ? MOBILE_UA : UA,
69
+ referer: mobile ? 'https://m.weibo.cn/' : 'https://weibo.com/',
70
+ ...(options.headers || {}),
71
+ }
72
+ if (jar?.header()) headers.cookie = jar.header()
73
+ const response = await fetch(url, { ...options, headers, redirect: 'manual' })
74
+ jar?.absorb(response)
75
+ return response
76
+ }
77
+
78
+ async function follow(response, jar, mobile = false, depth = 0) {
79
+ if (depth > 5) return response
80
+ const location = response.headers.get('location')
81
+ if (!location) return response
82
+ let next
83
+ try {
84
+ next = new URL(location, response.url).toString()
85
+ } catch {
86
+ return response
87
+ }
88
+ const redirected = await request(next, { jar, mobile })
89
+ return follow(redirected, jar, mobile, depth + 1)
90
+ }
91
+
92
+ export async function loginStart() {
93
+ const stamp = Date.now()
94
+ const url =
95
+ `https://login.sina.com.cn/sso/qrcode/image?entry=weibo&size=180&callback=STK_${stamp}`
96
+ const jar = new CookieJar()
97
+ const response = await request(url, { jar })
98
+ if (!response.ok) throw new Error(`获取微博二维码失败:HTTP ${response.status}`)
99
+ const data = parseJsonp(await response.text())
100
+ const row = data?.data || {}
101
+ if (!row.qrid || !row.image) throw new Error(data?.msg || '微博没有返回二维码')
102
+ const image = String(row.image).startsWith('//') ? `https:${row.image}` : String(row.image)
103
+ // Returning a data URL keeps the QR usable even if the DSH page CSP blocks
104
+ // hotlinked images; fall back to the remote URL if the image cannot be read.
105
+ let qr = image
106
+ try {
107
+ const imageResponse = await fetch(image, {
108
+ headers: { 'user-agent': UA, referer: 'https://weibo.com/' },
109
+ })
110
+ if (imageResponse.ok) {
111
+ const type = imageResponse.headers.get('content-type') || 'image/png'
112
+ const bytes = Buffer.from(await imageResponse.arrayBuffer())
113
+ if (bytes.length && type.startsWith('image/')) {
114
+ qr = `data:${type};base64,${bytes.toString('base64')}`
115
+ }
116
+ }
117
+ } catch {
118
+ // remote URL fallback
119
+ }
120
+ return { qrid: String(row.qrid), qr, retcode: data.retcode }
121
+ }
122
+
123
+ export async function loginPoll(qrid) {
124
+ const key = String(qrid || '').trim()
125
+ if (!key) throw new Error('缺少微博二维码 qrid')
126
+ const stamp = Date.now()
127
+ const url =
128
+ `https://login.sina.com.cn/sso/qrcode/check?entry=weibo&qrid=${encodeURIComponent(key)}` +
129
+ `&callback=STK_${stamp}`
130
+ const jar = new CookieJar()
131
+ const response = await request(url, { jar })
132
+ const data = parseJsonp(await response.text())
133
+ const retcode = Number(data.retcode)
134
+ if (retcode === 50114001) return { state: 'waiting', retcode }
135
+ if (retcode === 50114002) return { state: 'scanned', retcode }
136
+ if (retcode === 50114004) return { state: 'expired', retcode }
137
+ const alt = String(data?.data?.alt || data?.data?.url || '').trim()
138
+ if (!alt) return { state: 'waiting', retcode, raw: data }
139
+
140
+ const loginUrl =
141
+ `https://login.sina.com.cn/sso/login.php?entry=weibo&returntype=TEXT&crossdomain=1` +
142
+ `&cdult=3&domain=weibo.com&alt=${encodeURIComponent(alt)}&savestate=30`
143
+ let login = await request(loginUrl, { jar })
144
+ login = await follow(login, jar, false)
145
+ await request('https://weibo.com/', { jar })
146
+ const mobile = await request('https://m.weibo.cn/', { jar, mobile: true })
147
+ await follow(mobile, jar, true, 0).catch(() => {})
148
+ const cookie = jar.header()
149
+ if (!cookie.includes('SUB=') && !cookie.includes('SUBP=')) {
150
+ throw new Error('微博登录未返回 SUB/SUBP Cookie,请重试')
151
+ }
152
+ return { state: 'ok', cookie, account: await fetchAccount(cookie) }
153
+ }
154
+
155
+ export async function fetchAccount(cookie) {
156
+ try {
157
+ const response = await fetch('https://m.weibo.cn/api/config', {
158
+ headers: {
159
+ accept: 'application/json, text/plain, */*',
160
+ 'user-agent': MOBILE_UA,
161
+ referer: 'https://m.weibo.cn/',
162
+ cookie,
163
+ },
164
+ })
165
+ const data = await response.json()
166
+ return {
167
+ uid: String(data?.data?.uid || ''),
168
+ screenName: String(data?.data?.screen_name || ''),
169
+ }
170
+ } catch {
171
+ return { uid: '', screenName: '' }
172
+ }
173
+ }
174
+
175
+ export function maskCookie(cookie) {
176
+ return String(cookie || '')
177
+ .split(';')
178
+ .map((part) => {
179
+ const index = part.indexOf('=')
180
+ if (index < 0) return part.trim()
181
+ return `${part.slice(0, index).trim()}=***`
182
+ })
183
+ .filter(Boolean)
184
+ .join('; ')
185
+ }
package/lib/settings.js CHANGED
@@ -5,12 +5,16 @@ import { dirname, join } from 'node:path'
5
5
  export const DEFAULTS = {
6
6
  enabled: false,
7
7
  cookie: '',
8
+ selfUid: '',
8
9
  targetUids: [],
10
+ replyOwnPosts: true,
11
+ replyMode: 'off', // off | auto
12
+ scheduleTimes: ['20:00'],
9
13
  intervalMinutes: 60,
10
14
  maxPostsPerRun: 5,
11
15
  commentMode: 'off',
12
16
  maxCommentsPerDay: 1,
13
- replyOthers: false,
17
+ replyOthers: true,
14
18
  enableMemory: true,
15
19
  memoryPresetId: 'foxy3(Character)',
16
20
  llmBaseUrl: 'https://api.deepseek.com',
@@ -22,7 +26,11 @@ export const DEFAULTS = {
22
26
 
23
27
  export const SETTING_KEYS = [
24
28
  'enabled',
29
+ 'selfUid',
25
30
  'targetUids',
31
+ 'replyOwnPosts',
32
+ 'replyMode',
33
+ 'scheduleTimes',
26
34
  'intervalMinutes',
27
35
  'maxPostsPerRun',
28
36
  'commentMode',
@@ -37,13 +45,16 @@ export const SETTING_KEYS = [
37
45
  ]
38
46
 
39
47
  const TEXT_KEYS = new Set([
48
+ 'selfUid',
40
49
  'commentMode',
50
+ 'replyMode',
41
51
  'memoryPresetId',
42
52
  'llmBaseUrl',
43
53
  'llmModel',
44
54
  ])
45
55
  const BOOLEAN_KEYS = new Set([
46
56
  'enabled',
57
+ 'replyOwnPosts',
47
58
  'replyOthers',
48
59
  'enableMemory',
49
60
  'runOnStart',
@@ -70,6 +81,18 @@ export function normalizeUids(value) {
70
81
  return out
71
82
  }
72
83
 
84
+ export function normalizeScheduleTimes(value) {
85
+ const raw = Array.isArray(value) ? value : String(value || '').split(/[\s,,]+/)
86
+ const out = []
87
+ for (const item of raw) {
88
+ const text = String(item || '').trim()
89
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(text) || out.includes(text)) continue
90
+ out.push(text)
91
+ if (out.length >= 6) break
92
+ }
93
+ return out.sort()
94
+ }
95
+
73
96
  function clampNumber(key, value, fallback) {
74
97
  const n = Number(value)
75
98
  if (!Number.isFinite(n)) return fallback
@@ -82,13 +105,14 @@ function clampNumber(key, value, fallback) {
82
105
  export function normalizePatch(current = {}, patch = {}) {
83
106
  const next = { ...DEFAULTS, ...current }
84
107
  for (const key of SETTING_KEYS) {
85
- if (patch[key] === undefined || key === 'targetUids') continue
108
+ if (patch[key] === undefined || key === 'targetUids' || key === 'scheduleTimes') continue
86
109
  if (BOOLEAN_KEYS.has(key)) next[key] = patch[key] === true
87
110
  else if (key === 'commentMode') next[key] = patch[key] === 'auto' ? 'auto' : 'off'
88
111
  else if (TEXT_KEYS.has(key)) next[key] = String(patch[key] ?? '').trim()
89
112
  else next[key] = clampNumber(key, patch[key], next[key])
90
113
  }
91
114
  if (patch.targetUids !== undefined) next.targetUids = normalizeUids(patch.targetUids)
115
+ if (patch.scheduleTimes !== undefined) next.scheduleTimes = normalizeScheduleTimes(patch.scheduleTimes)
92
116
  if (patch.cookie !== undefined) next.cookie = String(patch.cookie || '')
93
117
  if (patch.clearCookie === true) next.cookie = ''
94
118
  if (typeof patch.accountName === 'string') next.accountName = patch.accountName.trim()
@@ -98,9 +122,12 @@ export function normalizePatch(current = {}, patch = {}) {
98
122
  }
99
123
  if (patch.clearLlmApiKey === true) next.llmApiKey = ''
100
124
  next.enabled = next.enabled === true
101
- next.replyOthers = next.replyOthers === true
125
+ next.replyOwnPosts = next.replyOwnPosts !== false
126
+ next.replyOthers = next.replyOthers !== false
102
127
  next.enableMemory = next.enableMemory !== false
128
+ next.replyMode = next.replyMode === 'auto' ? 'auto' : 'off'
103
129
  next.targetUids = normalizeUids(next.targetUids)
130
+ next.scheduleTimes = normalizeScheduleTimes(next.scheduleTimes)
104
131
  return next
105
132
  }
106
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-foxy-weibo",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Foxy's Weibo transport for DeepSeek Harness: follow users, read posts, comment, reply, and remember events.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",