dsh-foxy-weibo 0.1.0 → 0.2.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/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. 旧模式需要浏览他人博文时,再关闭「主模式」并配置关注 UID;
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,11 @@ 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} 条`,
149
226
  )
150
227
  } catch (e) { setError(e.message) } finally { setBusy('') }
151
228
  }
@@ -168,23 +245,26 @@ window.__ModuleLoader__.load({
168
245
  ),
169
246
 
170
247
  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) })),
248
+ h(SectionHead, { title: '账号与登录', description: '扫码登录后即可回复评论;Cookie 输入保留为备用方式。' }),
174
249
  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 }),
250
+ h(Field, { label: '当前账号', hint: '扫码成功后自动显示' },
251
+ h('input', { value: settings.accountName ? `${settings.accountName}(${settings.accountUid || '?'})` : settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
179
252
  ),
253
+ h(Field, { label: '自己的微博 UID', hint: '主功能会检查这个账号的新博文评论' },
254
+ h('input', { value: settings.selfUid || '', placeholder: '例如 1234567890', onChange: (e) => setField('selfUid', e.target.value) })),
180
255
  ),
181
256
  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
- } }, '退出登录'),
257
+ h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: startLogin }, busy === 'login' ? '获取二维码…' : qr ? '刷新二维码' : '扫码登录'),
258
+ h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick: logout }, busy === 'logout' ? '退出中…' : '退出登录'),
187
259
  ),
260
+ qr
261
+ ? h('div', { className: 'fw-qr' },
262
+ h('img', { src: qr, alt: '微博登录二维码', width: 180, height: 180 }),
263
+ h('p', null, qrState === 'scanned' ? '已扫码,请在手机上确认登录' : qrState === 'expired' ? '二维码已过期,请刷新' : '请用微博 App 扫码'),
264
+ )
265
+ : null,
266
+ h(Field, { label: '微博 Cookie(备用)', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
267
+ h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
188
268
  ),
189
269
 
190
270
  h('section', { className: 'fw-card' },
@@ -200,22 +280,32 @@ window.__ModuleLoader__.load({
200
280
  ),
201
281
 
202
282
  h('section', { className: 'fw-card' },
203
- h(SectionHead, { title: '浏览与评论', description: '控制轮询频率、每轮阅读数量,以及是否允许公开评论。' }),
204
- h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动浏览', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
283
+ h(SectionHead, { title: '回复自己的博文', description: '主功能:每天按设定时间检查自己的新博文,只回复未回复的他人评论。' }),
284
+ h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动检查', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
285
+ h(Toggle, { checked: settings.replyOwnPosts, onChange: (v) => setField('replyOwnPosts', v), label: '主模式:只看自己的博文评论', hint: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
205
286
  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' },
287
+ h(Field, { label: '每日检查时间', hint: 'HH:MM,多个用逗号分隔,最多 6 个,例如 08:30, 20:00' },
288
+ h('input', { value: scheduleText, placeholder: '20:00', onChange: (e) => setScheduleText(e.target.value) })),
289
+ h(Field, { label: '回复模式', hint: '自动才会发布回复;建议先仅记录' },
290
+ h('select', { value: settings.replyMode, onChange: (e) => setField('replyMode', e.target.value) },
291
+ h('option', { value: 'off' }, '仅检查,不回复'),
292
+ h('option', { value: 'auto' }, '自动回复'),
293
+ )),
294
+ h(Field, { label: '每轮检查博文数', hint: '范围 1–20' },
209
295
  h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
210
- h(Field, { label: '评论模式', hint: '自动评论会使用当前账号发布内容' },
296
+ h(Field, { label: '每日回复上限', hint: '0–10,同时限制评论' },
297
+ h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
298
+ ),
299
+ h('div', { className: 'fw-grid' },
300
+ h(Field, { label: '时间线轮询(旧模式备用)', hint: '关闭主模式时使用,分钟' },
301
+ h('input', { type: 'number', min: 5, max: 1440, value: settings.intervalMinutes, onChange: (e) => setField('intervalMinutes', e.target.value) })),
302
+ h(Field, { label: '旧模式评论模式', hint: '浏览他人博文时是否发一级评论' },
211
303
  h('select', { value: settings.commentMode, onChange: (e) => setField('commentMode', e.target.value) },
212
304
  h('option', { value: 'off' }, '仅记录'),
213
305
  h('option', { value: 'auto' }, '自动评论'),
214
306
  )),
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
307
  ),
218
- h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '模型可从评论区选择一条评论回复;关闭时只发一级评论。' }),
308
+ h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
219
309
  h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
220
310
  h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
221
311
  h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
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,9 @@ 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 LOGIN_START_ROUTE = '/api/dsh-foxy-weibo/login/start'
15
+ export const LOGIN_POLL_ROUTE = '/api/dsh-foxy-weibo/login/poll'
16
+ export const LOGOUT_ROUTE = '/api/dsh-foxy-weibo/logout'
13
17
 
14
18
  const ACTION_LABELS = { skip: '跳过', remember: '记录', comment: '评论', error: '失败' }
15
19
 
@@ -22,6 +26,8 @@ function loadState() {
22
26
  const parsed = JSON.parse(readFileSync(statePath(), 'utf8'))
23
27
  return {
24
28
  seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
29
+ commentSeen:
30
+ parsed.commentSeen && typeof parsed.commentSeen === 'object' ? parsed.commentSeen : {},
25
31
  days: parsed.days && typeof parsed.days === 'object' ? parsed.days : {},
26
32
  history: Array.isArray(parsed.history) ? parsed.history.slice(0, 50) : [],
27
33
  debug: Array.isArray(parsed.debug) ? parsed.debug.slice(0, 80) : [],
@@ -29,7 +35,7 @@ function loadState() {
29
35
  lastResult: parsed.lastResult || null,
30
36
  }
31
37
  } catch {
32
- return { seen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
38
+ return { seen: {}, commentSeen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
33
39
  }
34
40
  }
35
41
 
@@ -120,6 +126,176 @@ async function remember(ctx, config, state, post, decision, extraText = '') {
120
126
  }
121
127
  }
122
128
 
129
+ async function rememberReply(ctx, config, state, post, comment, replyText) {
130
+ if (!config.enableMemory) return { ok: false, reason: 'disabled' }
131
+ const provider = memoryProvider(ctx)
132
+ if (!provider?.record) return { ok: false, reason: 'no-provider' }
133
+ const person = comment.author?.name || comment.author?.uid || '一位网友'
134
+ const text = `我在微博回复了 ${person} 在自己博文「${firstLine(post.text, 60)}」下的评论:${replyText}`
135
+ try {
136
+ await provider.record({
137
+ role: 'assistant',
138
+ text,
139
+ sessionId: `weibo-comment:${post.id}:${comment.cid}`,
140
+ presetId: config.memoryPresetId || 'foxy3(Character)',
141
+ platform: 'weibo',
142
+ sourceLabel: '微博评论回复',
143
+ eventType: 'weibo_reply',
144
+ channelType: 'weibo_comment',
145
+ channelId: String(comment.cid),
146
+ title: firstLine(post.text, 120),
147
+ url: post.url,
148
+ scene: 'weibo',
149
+ })
150
+ debugLog(state, config, { kind: 'memory', post: post.id, memory: 'written', text: firstLine(text, 300) })
151
+ return { ok: true, reason: 'written', text }
152
+ } catch (error) {
153
+ const reason = error?.message || String(error)
154
+ debugLog(state, config, { kind: 'memory', post: post.id, memory: 'error', reason })
155
+ return { ok: false, reason }
156
+ }
157
+ }
158
+
159
+ async function scanOwnComments(ctx, config, state, log, force = false) {
160
+ if (!config.enabled && !force) return { skipped: 'disabled' }
161
+ const canReply = config.replyMode === 'auto'
162
+ const selfUid = String(config.selfUid || config.accountUid || '').trim()
163
+ if (!selfUid) return { skipped: 'no-self' }
164
+ const maxPosts = Math.max(1, Math.min(20, Number(config.maxPostsPerRun) || 5))
165
+ const maxReplies = Math.max(1, Math.min(8, Number(config.maxCommentsPerDay) || 1))
166
+ const result = {
167
+ mode: 'own-comments',
168
+ checked: 0,
169
+ replied: 0,
170
+ previewed: 0,
171
+ skipped: 0,
172
+ errors: 0,
173
+ items: [],
174
+ }
175
+ let posts = []
176
+ try {
177
+ posts = await fetchTimeline(selfUid, config)
178
+ } catch (error) {
179
+ return { ...result, errors: 1, error: error?.message || String(error) }
180
+ }
181
+
182
+ for (const post of posts.slice(0, maxPosts)) {
183
+ if (!post.id) continue
184
+ let comments = []
185
+ try {
186
+ comments = await fetchComments(post.mid, config, 30)
187
+ } catch (error) {
188
+ result.errors += 1
189
+ log(`[foxy-weibo] 读取评论失败 ${post.id}:${error?.message || error}`)
190
+ continue
191
+ }
192
+ for (const comment of comments) {
193
+ if (canReply && result.replied >= maxReplies) break
194
+ if (!comment.cid || state.commentSeen[comment.cid]) continue
195
+ if (String(comment.author?.uid || '') === selfUid) {
196
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'self' }
197
+ continue
198
+ }
199
+ const limit = Math.max(0, Number(config.maxCommentsPerDay) || 0)
200
+ if (canReply && commentCount(state) >= limit) break
201
+ result.checked += 1
202
+ let entry
203
+ try {
204
+ const decision = await judgePost(config, {
205
+ summary: postSummary(post),
206
+ comments,
207
+ commentsText: commentsTextOf(comments),
208
+ targetComment: {
209
+ cid: String(comment.cid),
210
+ text: comment.text,
211
+ author: comment.author,
212
+ },
213
+ mayComment: true,
214
+ mayReply: true,
215
+ })
216
+ debugLog(state, config, {
217
+ kind: 'judge-own-comment',
218
+ post: post.id,
219
+ cid: String(comment.cid),
220
+ action: decision.action,
221
+ raw: String(decision.raw || '').slice(0, 500),
222
+ })
223
+ if (decision.action !== 'comment' || !decision.comment) {
224
+ result.skipped += 1
225
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'skipped' }
226
+ entry = {
227
+ at: Date.now(),
228
+ post: post.id,
229
+ cid: String(comment.cid),
230
+ author: comment.author?.name || comment.author?.uid,
231
+ title: firstLine(post.text, 100),
232
+ action: 'skip',
233
+ reason: decision.mood || '模型判断不需要回复',
234
+ memory: 'skipped',
235
+ }
236
+ } else if (!canReply) {
237
+ result.previewed += 1
238
+ entry = {
239
+ at: Date.now(),
240
+ post: post.id,
241
+ cid: String(comment.cid),
242
+ author: comment.author?.name || comment.author?.uid,
243
+ title: firstLine(post.text, 100),
244
+ action: 'preview',
245
+ comment: decision.comment,
246
+ reason: '回复模式为仅检查,未发布',
247
+ memory: 'skipped',
248
+ }
249
+ log(`[foxy-weibo] 仅检查 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
250
+ } else {
251
+ await postComment({
252
+ mid: post.mid,
253
+ text: decision.comment,
254
+ cid: String(comment.cid),
255
+ config,
256
+ })
257
+ bumpComments(state)
258
+ result.replied += 1
259
+ state.commentSeen[comment.cid] = { at: Date.now(), status: 'replied' }
260
+ const memory = await rememberReply(ctx, config, state, post, comment, decision.comment)
261
+ entry = {
262
+ at: Date.now(),
263
+ post: post.id,
264
+ cid: String(comment.cid),
265
+ author: comment.author?.name || comment.author?.uid,
266
+ title: firstLine(post.text, 100),
267
+ action: 'reply',
268
+ comment: decision.comment,
269
+ reason: firstLine(decision.comment, 140),
270
+ memory: memory.ok ? 'written' : memory.reason,
271
+ }
272
+ log(`[foxy-weibo] 回复 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
273
+ }
274
+ } catch (error) {
275
+ result.errors += 1
276
+ entry = {
277
+ at: Date.now(),
278
+ post: post.id,
279
+ cid: String(comment.cid),
280
+ author: comment.author?.name || comment.author?.uid,
281
+ title: firstLine(post.text, 100),
282
+ action: 'error',
283
+ reason: '回复评论时出错',
284
+ error: error?.message || String(error),
285
+ }
286
+ log(`[foxy-weibo] 回复评论 ${comment.cid} 失败:${error?.message || error}`)
287
+ }
288
+ if (entry) {
289
+ pushHistory(state, entry)
290
+ result.items.push(entry)
291
+ }
292
+ }
293
+ }
294
+ state.lastRun = Date.now()
295
+ state.lastResult = result
296
+ return result
297
+ }
298
+
123
299
  async function scanOnce(ctx, config, state, log, force = false) {
124
300
  if (!config.enabled && !force) return { skipped: 'disabled' }
125
301
  if (!config.targetUids.length) return { skipped: 'no-targets' }
@@ -282,7 +458,10 @@ export function apply(ctx, rawConfig = {}) {
282
458
  running = true
283
459
  try {
284
460
  log(`[foxy-weibo] 开始运行(${reason})`)
285
- const result = await scanOnce(ctx, config, state, log, force)
461
+ const result =
462
+ config.replyOwnPosts === false
463
+ ? await scanOnce(ctx, config, state, log, force)
464
+ : await scanOwnComments(ctx, config, state, log, force)
286
465
  saveState(state)
287
466
  log(`[foxy-weibo] 运行结束 ${JSON.stringify(result)}`)
288
467
  return result
@@ -298,14 +477,22 @@ export function apply(ctx, rawConfig = {}) {
298
477
  const status = () => ({
299
478
  enabled: config.enabled === true,
300
479
  running,
480
+ mode: config.replyOwnPosts === false ? 'timeline' : 'own-comments',
481
+ selfUid: config.selfUid || config.accountUid || '',
482
+ scheduleTimes: config.scheduleTimes,
483
+ nextRunAt,
484
+ replyMode: config.replyMode,
301
485
  lastRun: state.lastRun,
302
486
  lastResult: state.lastResult,
303
487
  recent: state.history.slice(0, 12),
304
488
  debugEnabled: config.debug === true,
305
489
  debug: state.debug.slice(0, 40),
306
490
  seen: Object.keys(state.seen).length,
491
+ commentsSeen: Object.keys(state.commentSeen || {}).length,
307
492
  commentsToday: commentCount(state),
308
493
  cookieSet: Boolean(config.cookie),
494
+ accountName: config.accountName || '',
495
+ accountUid: config.accountUid || '',
309
496
  apiKeySet: Boolean(config.llmApiKey),
310
497
  targetCount: config.targetUids.length,
311
498
  })
@@ -315,14 +502,37 @@ export function apply(ctx, rawConfig = {}) {
315
502
  foxy?.registerCapability?.('weibo', async () => capability)
316
503
  ctx.provide?.('foxyWeibo', capability)
317
504
 
505
+ let nextRunAt = 0
506
+ const nextDelay = () => {
507
+ const times = Array.isArray(config.scheduleTimes) ? config.scheduleTimes : []
508
+ if (!times.length) return Math.max(5, Number(config.intervalMinutes) || 60) * 60 * 1000
509
+ const now = new Date()
510
+ let best = Infinity
511
+ for (const text of times) {
512
+ const [hour, minute] = String(text).split(':').map(Number)
513
+ if (!Number.isInteger(hour) || !Number.isInteger(minute)) continue
514
+ for (let day = 0; day <= 1; day += 1) {
515
+ const at = new Date(now)
516
+ at.setDate(now.getDate() + day)
517
+ at.setHours(hour, minute, 0, 0)
518
+ const delay = at.getTime() - now.getTime()
519
+ if (delay > 0 && delay < best) best = delay
520
+ }
521
+ }
522
+ return Number.isFinite(best) ? best : 60 * 60 * 1000
523
+ }
318
524
  const restartTimer = () => {
319
- clearInterval(timer)
320
- const minutes = Math.max(5, Number(config.intervalMinutes) || 60)
321
- timer = setInterval(() => run('timer').catch(() => {}), minutes * 60 * 1000)
525
+ clearTimeout(timer)
526
+ const delay = nextDelay()
527
+ nextRunAt = Date.now() + delay
528
+ timer = setTimeout(async () => {
529
+ await run('schedule').catch(() => {})
530
+ restartTimer()
531
+ }, delay)
322
532
  timer.unref?.()
323
533
  }
324
534
  restartTimer()
325
- ctx.effect?.(() => () => clearInterval(timer), 'foxy-weibo: scan timer')
535
+ ctx.effect?.(() => () => clearTimeout(timer), 'foxy-weibo: scan timer')
326
536
 
327
537
  if (config.runOnStart) {
328
538
  startTimer = setTimeout(() => run('start').catch(() => {}), 20_000)
@@ -361,6 +571,29 @@ export function apply(ctx, rawConfig = {}) {
361
571
  }
362
572
  return json({ ok: true, settings: publicSettings(config), status: status() })
363
573
  })
574
+ route(LOGIN_START_ROUTE, ['POST'], async () => json({ ok: true, ...(await loginStart()) }))
575
+
576
+ route(LOGIN_POLL_ROUTE, ['POST'], async (request) => {
577
+ const { qrid } = await readJsonBody(request)
578
+ const result = await loginPoll(qrid)
579
+ if (result.state === 'ok' && result.cookie) {
580
+ config.cookie = result.cookie
581
+ config.accountName = result.account?.screenName || ''
582
+ config.accountUid = result.account?.uid || ''
583
+ if (!config.selfUid && result.account?.uid) config.selfUid = result.account.uid
584
+ saveSettings(config)
585
+ }
586
+ return json({ ok: true, ...result, settings: publicSettings(config), status: status() })
587
+ })
588
+
589
+ route(LOGOUT_ROUTE, ['POST'], async () => {
590
+ config.cookie = ''
591
+ config.accountName = ''
592
+ config.accountUid = ''
593
+ saveSettings(config)
594
+ return json({ ok: true, settings: publicSettings(config), status: status() })
595
+ })
596
+
364
597
  route(RUN_ROUTE, ['POST'], async () => {
365
598
  const result = await run('manual', true)
366
599
  return json({ ok: true, result, status: status() })
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.0",
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",