dsh-foxy-weibo 0.2.3 → 0.3.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,13 +6,13 @@ Foxy 的微博子插件:按 foxy3 预设关注微博账号、阅读博文、
6
6
 
7
7
  ## 功能
8
8
 
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、模型、每日时间、回复模式、调试日志与立即运行。
9
+ - **阅读目标账号博文**:按设定时间阅读监控账号的博文,每篇写入一条“目标账号博文”记忆,按博文 ID 去重;
10
+ - 可选对博文发表一级评论;可设置评论起始时间(`replyAfter`),只评论该时间之后的博文;
11
+ - 评论前按 foxy3 预设判断 `skip / comment`,不设最少字数,最多 80 字;
12
+ - 自检去重:已评论/已跳过的博文写入 `postSeen`,不会重复评论同一篇;
13
+ - 旧模式保留:关注一个或多个微博 UID,定时读取时间线并评论(`replyOwnPosts: false`);
14
+ - 阅读、评论事件写入 Foxy 记忆,字段包含 `platform: weibo`、`channelType: weibo_post`、`eventType: weibo_owner_post|weibo_comment`;
15
+ - 设置页提供扫码登录、Cookie 备用、监控账号 UID、关注 UID、模型、每日时间、评论模式、评论起始时间、记忆开关、调试日志与立即检查/立即回复。
16
16
 
17
17
  ## 安装
18
18
 
@@ -47,7 +47,7 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
47
47
 
48
48
  ### 自己的 UID / 关注 UID
49
49
 
50
- `监控账号 UID` 是要检查博文评论的目标账号(通常是主人账号);`当前账号` 是扫码登录的机器人账号,两者可以不同。
50
+ `监控账号 UID` 是被阅读、被评论的目标账号(通常是主人账号);`当前账号` 是扫码登录的机器人账号,两者可以不同。插件不会把登录的机器人账号当成监控账号。
51
51
 
52
52
  `关注 UID` 是旧模式使用,多个 UID 用逗号分隔,最多 20 个。UID 是微博用户主页 URL 里的数字 ID,例如 `https://weibo.com/u/1234567890`。
53
53
 
@@ -64,18 +64,17 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
64
64
  ## 建议顺序
65
65
 
66
66
  1. 扫码登录,填监控账号 UID,`enabled: true`、`replyMode: off`;
67
- 2. 设置每日检查时间,或先点「立即运行」,看日志和「调试」里的模型决策;
67
+ 2. 设置每日检查时间,或先点「立即检查」,看日志和「调试」里的模型决策;
68
68
  3. 确认判断正常后,再开启 `replyMode: auto`,每日上限先设为 1;
69
- 4. 「立即检查」只判断不发布,用来预览回复;「立即回复」会真实发布一条,适合测试;
70
- 5. 微博评论有账号风控,不要高频、不要对陌生人全自动回复。
69
+ 4. 「立即检查」只预览、不评论;「立即回复」会真实评论一篇,适合测试;
70
+ 5. 评论有账号风控,不要高频;阅读往期博文会写入记忆,但按博文 ID 去重。
71
71
 
72
72
  ## 记忆事件
73
73
 
74
74
  微博插件写入的示例:
75
75
 
76
- - 浏览:`我在微博看到 <博主> 的博文「...」,觉得...`
77
- - 评论:`我在微博评论了 <博主> 的博文「...」:...`
78
- - 回复:`我在微博回复了 <网友> 在自己博文「...」下的评论:...`
76
+ - 阅读目标博文:`我看到 <博主> 发了一条微博:「...」`
77
+ - 评论博文:`我在微博评论了 <博主> 的博文「...」:...`
79
78
 
80
79
  事件字段:
81
80
 
package/cordis.patch.yml CHANGED
@@ -14,10 +14,12 @@
14
14
  targetUids: []
15
15
  replyOwnPosts: true
16
16
  replyMode: 'off'
17
+ replyAfter: ''
18
+ learnPosts: true
17
19
  scheduleTimes:
18
20
  - '20:00'
19
21
  intervalMinutes: 60
20
- maxPostsPerRun: 5
22
+ maxPostsPerRun: 20
21
23
  commentMode: 'off'
22
24
  maxCommentsPerDay: 1
23
25
  replyOthers: true
package/lib/client.js CHANGED
@@ -224,9 +224,11 @@ window.__ModuleLoader__.load({
224
224
  ? '正在运行,请稍候'
225
225
  : result.skipped === 'no-target'
226
226
  ? '未开始:请先填写监控账号 UID'
227
- : `运行完成:检查 ${result.checked || 0} 条,回复 ${result.replied || 0} 条` +
227
+ : `运行完成:博文 ${result.posts || 0} 篇,读取记忆 ${result.memories || 0} 条,` +
228
+ `评论 ${result.commented || 0} 条` +
228
229
  (result.previewed ? `,预览 ${result.previewed} 条` : '') +
229
- `,跳过 ${result.skipped || 0} 条`,
230
+ `,跳过 ${result.skipped || 0} 条` +
231
+ (result.errors ? `,失败 ${result.errors} 条` : ''),
230
232
  )
231
233
  } catch (e) { setError(e.message) } finally { setBusy('') }
232
234
  }
@@ -246,7 +248,8 @@ window.__ModuleLoader__.load({
246
248
  ? '未开始:请先填写监控账号 UID'
247
249
  : result.skipped === 'busy'
248
250
  ? '正在运行,请稍候'
249
- : `检查完成:检查 ${result.checked || 0} 条评论,预览 ${result.previewed || 0} 条,跳过 ${result.skipped || 0} 条` +
251
+ : `检查完成:博文 ${result.posts || 0} 篇,可评论 ${result.previewed || 0} 条,` +
252
+ `读取记忆 ${result.memories || 0} 条,跳过 ${result.skipped || 0} 条` +
250
253
  (result.errors ? `,失败 ${result.errors} 条` : ''),
251
254
  )
252
255
  } catch (e) { setError(e.message) } finally { setBusy('') }
@@ -264,11 +267,11 @@ window.__ModuleLoader__.load({
264
267
  return
265
268
  }
266
269
  setNotice(
267
- result.replied > 0
268
- ? `已立即回复 ${result.replied} 条,可在下方日志查看`
270
+ result.commented > 0
271
+ ? `已评论 ${result.commented} 篇博文,可在下方日志查看`
269
272
  : result.skipped === 'no-target'
270
273
  ? '未开始:请先填写监控账号 UID'
271
- : '没有可立即回复的评论(可能已处理或模型选择跳过)',
274
+ : `已浏览 ${result.posts || 0} 篇博文,但没有可立即评论的新博文(可能已处理或模型选择跳过)`,
272
275
  )
273
276
  } catch (e) { setError(e.message) } finally { setBusy('') }
274
277
  }
@@ -326,21 +329,28 @@ window.__ModuleLoader__.load({
326
329
  ),
327
330
 
328
331
  h('section', { className: 'fw-card' },
329
- h(SectionHead, { title: '博文评论回复', description: '每日按设定时间检查目标账号的新博文,并回复其评论区中尚未处理的他人评论。' }),
332
+ h(SectionHead, { title: '博文阅读与评论', description: '阅读目标账号的博文并写入记忆;仅对设定时间之后的博文发表评论。' }),
330
333
  h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动检查', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
331
334
  h(Toggle, { checked: settings.replyOwnPosts, onChange: (v) => setField('replyOwnPosts', v), label: '仅监控目标账号的博文', hint: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
332
335
  h('div', { className: 'fw-grid' },
333
336
  h(Field, { label: '每日检查时间', hint: 'HH:MM,多个用逗号分隔,最多 6 个,例如 08:30, 20:00' },
334
337
  h('input', { value: scheduleText, placeholder: '20:00', onChange: (e) => setScheduleText(e.target.value) })),
335
- h(Field, { label: '回复模式', hint: '自动才会发布回复;建议先仅记录' },
338
+ h(Field, { label: '评论模式', hint: '自动才会发布博文评论;建议先仅检查' },
336
339
  h('select', { value: settings.replyMode, onChange: (e) => setField('replyMode', e.target.value) },
337
340
  h('option', { value: 'off' }, '仅检查,不回复'),
338
341
  h('option', { value: 'auto' }, '自动回复'),
339
342
  )),
340
- h(Field, { label: '每轮检查博文数', hint: '范围 1–20' },
341
- h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
342
- h(Field, { label: '每日回复上限', hint: '0–10,同时限制评论' },
343
+ h(Field, { label: '浏览范围(博文条数)', hint: '按时间倒序检查;1–100,填 100 即尽量浏览全部' },
344
+ h('input', { type: 'number', min: 1, max: 100, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
345
+ h(Field, { label: '每日评论上限', hint: '0–10,仅统计自动评论' },
343
346
  h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
347
+ h(Field, { label: '评论起始时间', hint: '只评论此时间之后的博文;留空表示不限制' },
348
+ h('input', { type: 'datetime-local', value: settings.replyAfter || '', onChange: (e) => setField('replyAfter', e.target.value) })),
349
+ h(Field, { label: '阅读后写入记忆', hint: '每篇未读博文写一条“目标账号博文”记忆,按博文去重' },
350
+ h('select', { value: settings.learnPosts === false ? 'off' : 'on', onChange: (e) => setField('learnPosts', e.target.value === 'on') },
351
+ h('option', { value: 'on' }, '写入'),
352
+ h('option', { value: 'off' }, '不写入'),
353
+ )),
344
354
  ),
345
355
  h('div', { className: 'fw-grid' },
346
356
  h(Field, { label: '时间线轮询(旧模式备用)', hint: '关闭“仅监控目标账号的博文”时使用,分钟' },
@@ -351,8 +361,7 @@ window.__ModuleLoader__.load({
351
361
  h('option', { value: 'auto' }, '自动评论'),
352
362
  )),
353
363
  ),
354
- h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
355
- h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
364
+ h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '启用 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
356
365
  h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
357
366
  h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
358
367
  h('div', { className: 'fw-actions' },
package/lib/index.js CHANGED
@@ -30,6 +30,9 @@ function loadState() {
30
30
  seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
31
31
  commentSeen:
32
32
  parsed.commentSeen && typeof parsed.commentSeen === 'object' ? parsed.commentSeen : {},
33
+ postSeen: parsed.postSeen && typeof parsed.postSeen === 'object' ? parsed.postSeen : {},
34
+ postMemory:
35
+ parsed.postMemory && typeof parsed.postMemory === 'object' ? parsed.postMemory : {},
33
36
  days: parsed.days && typeof parsed.days === 'object' ? parsed.days : {},
34
37
  history: Array.isArray(parsed.history) ? parsed.history.slice(0, 50) : [],
35
38
  debug: Array.isArray(parsed.debug) ? parsed.debug.slice(0, 80) : [],
@@ -37,7 +40,17 @@ function loadState() {
37
40
  lastResult: parsed.lastResult || null,
38
41
  }
39
42
  } catch {
40
- return { seen: {}, commentSeen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
43
+ return {
44
+ seen: {},
45
+ commentSeen: {},
46
+ postSeen: {},
47
+ postMemory: {},
48
+ days: {},
49
+ history: [],
50
+ debug: [],
51
+ lastRun: 0,
52
+ lastResult: null,
53
+ }
41
54
  }
42
55
  }
43
56
 
@@ -65,6 +78,13 @@ function firstLine(text, limit = 120) {
65
78
  return String(text || '').replace(/\s+/g, ' ').trim().slice(0, limit)
66
79
  }
67
80
 
81
+ function entryReason(decision) {
82
+ return (
83
+ String(decision?.mood || decision?.memory || decision?.reason || '').trim() ||
84
+ '模型判断不需要评论'
85
+ )
86
+ }
87
+
68
88
  function pushHistory(state, entry) {
69
89
  state.history = [entry, ...(Array.isArray(state.history) ? state.history : [])].slice(0, 50)
70
90
  }
@@ -158,6 +178,200 @@ async function rememberReply(ctx, config, state, post, comment, replyText) {
158
178
  }
159
179
  }
160
180
 
181
+ async function rememberPost(ctx, config, state, post) {
182
+ if (!config.enableMemory || config.learnPosts === false) {
183
+ return { ok: false, reason: 'disabled' }
184
+ }
185
+ if (state.postMemory?.[post.id]) return { ok: false, reason: 'already' }
186
+ const provider = memoryProvider(ctx)
187
+ if (!provider?.record) return { ok: false, reason: 'no-provider' }
188
+ const excerpt = firstLine(post.text, 180)
189
+ const text = `我看到 ${post.author.name || '目标账号'} 发了一条微博:「${excerpt}」`
190
+ try {
191
+ await provider.record({
192
+ role: 'assistant',
193
+ text,
194
+ sessionId: `weibo-post:${post.id}`,
195
+ presetId: config.memoryPresetId || 'foxy3(Character)',
196
+ platform: 'weibo',
197
+ sourceLabel: '目标账号博文',
198
+ eventType: 'weibo_owner_post',
199
+ channelType: 'weibo_post',
200
+ channelId: post.id,
201
+ title: excerpt,
202
+ url: post.url,
203
+ scene: 'weibo',
204
+ })
205
+ state.postMemory = state.postMemory || {}
206
+ state.postMemory[post.id] = Date.now()
207
+ debugLog(state, config, {
208
+ kind: 'memory',
209
+ post: post.id,
210
+ memory: 'written',
211
+ eventType: 'weibo_owner_post',
212
+ text: firstLine(text, 300),
213
+ })
214
+ return { ok: true, reason: 'written', text }
215
+ } catch (error) {
216
+ const reason = error?.message || String(error)
217
+ debugLog(state, config, { kind: 'memory', post: post.id, memory: 'error', reason })
218
+ return { ok: false, reason }
219
+ }
220
+ }
221
+
222
+ async function scanTargetPosts(ctx, config, state, log, force = false, options = {}) {
223
+ if (!config.enabled && !force) return { skipped: 'disabled' }
224
+ const previewOnly = options.previewOnly === true
225
+ const forceReply = options.forceReply === true
226
+ const canComment = !previewOnly && (forceReply || config.replyMode === 'auto')
227
+ const targetUid = String(config.targetUid || '').trim()
228
+ const result = {
229
+ mode: options.mode || 'target-posts',
230
+ posts: 0,
231
+ checked: 0,
232
+ commented: 0,
233
+ previewed: 0,
234
+ skipped: 0,
235
+ errors: 0,
236
+ memories: 0,
237
+ items: [],
238
+ }
239
+ if (!targetUid) return { ...result, skipped: 'no-target' }
240
+ if (!config.cookie) return { ...result, errors: 1, error: '请先扫码登录微博账号' }
241
+ const account = await fetchAccount(config.cookie)
242
+ if (!account.uid) return { ...result, errors: 1, error: '微博登录态无效,请重新扫码登录后再检查' }
243
+ const loginUid = String(config.accountUid || account.uid || '')
244
+ const maxPosts = Math.max(1, Math.min(100, Number(options.maxPosts ?? config.maxPostsPerRun) || 5))
245
+ const cutoff = config.replyAfter ? Date.parse(config.replyAfter) : 0
246
+ const maxComments = Math.max(1, Math.min(10, Number(config.maxCommentsPerDay) || 1))
247
+ let posts = []
248
+ try {
249
+ posts = await fetchTimeline(targetUid, config, {
250
+ maxPosts,
251
+ maxPages: Math.ceil(maxPosts / 10) + 1,
252
+ })
253
+ result.posts = posts.length
254
+ } catch (error) {
255
+ return { ...result, errors: 1, error: error?.message || String(error) }
256
+ }
257
+ debugLog(state, config, {
258
+ kind: 'timeline',
259
+ targetUid,
260
+ posts: posts.length,
261
+ replyAfter: config.replyAfter || '',
262
+ })
263
+
264
+ for (const post of posts) {
265
+ if (!post.id) continue
266
+ const memory = await rememberPost(ctx, config, state, post)
267
+ if (memory.ok) result.memories += 1
268
+ if (state.postSeen?.[post.id]) continue
269
+ if (cutoff && post.createdAtMs && post.createdAtMs < cutoff) continue
270
+ if (!canComment && config.replyMode === 'off' && !previewOnly) {
271
+ // Still preview in check mode; normal runs with reply off only learn.
272
+ }
273
+ if (canComment && commentCount(state) >= maxComments) {
274
+ result.skipped += 1
275
+ debugLog(state, config, {
276
+ kind: 'quota',
277
+ post: post.id,
278
+ message: '每日评论额度已用完',
279
+ })
280
+ continue
281
+ }
282
+ result.checked += 1
283
+ let entry
284
+ try {
285
+ const decision = await judgePost(config, {
286
+ summary: postSummary(post),
287
+ comments: [],
288
+ commentsText: '',
289
+ targetPost: post,
290
+ mayComment: true,
291
+ mayReply: false,
292
+ forceComment: forceReply,
293
+ })
294
+ const comment = String(decision.comment || '').trim()
295
+ if (decision.action === 'comment' && comment && canComment) {
296
+ const published = await postComment({ mid: post.mid || post.id, text: comment, config })
297
+ result.commented += 1
298
+ bumpComments(state)
299
+ state.postSeen[post.id] = { status: 'commented', at: Date.now(), commentId: published.cid }
300
+ entry = {
301
+ at: Date.now(),
302
+ kind: 'weibo-post',
303
+ postId: post.id,
304
+ title: firstLine(post.text, 80),
305
+ url: post.url,
306
+ author: post.author.name,
307
+ action: 'comment',
308
+ comment,
309
+ commentId: published.cid,
310
+ memory: memory.ok ? 'written' : memory.reason,
311
+ }
312
+ ctx.logger?.info?.(`[foxy-weibo] 评论博文 ${post.id}:${firstLine(comment, 80)}`)
313
+ } else if (decision.action === 'comment' && comment) {
314
+ result.previewed += 1
315
+ entry = {
316
+ at: Date.now(),
317
+ kind: 'weibo-post',
318
+ postId: post.id,
319
+ title: firstLine(post.text, 80),
320
+ url: post.url,
321
+ author: post.author.name,
322
+ action: 'preview',
323
+ comment,
324
+ memory: memory.ok ? 'written' : memory.reason,
325
+ }
326
+ } else {
327
+ result.skipped += 1
328
+ state.postSeen[post.id] = { status: 'skipped', at: Date.now() }
329
+ entry = {
330
+ at: Date.now(),
331
+ kind: 'weibo-post',
332
+ postId: post.id,
333
+ title: firstLine(post.text, 80),
334
+ url: post.url,
335
+ author: post.author.name,
336
+ action: 'skip',
337
+ reason: entryReason(decision),
338
+ memory: memory.ok ? 'written' : memory.reason,
339
+ }
340
+ }
341
+ debugLog(state, config, {
342
+ kind: 'target-judge',
343
+ post: post.id,
344
+ action: decision.action,
345
+ memory: memory.ok ? 'written' : memory.reason,
346
+ raw: String(decision.raw || '').slice(0, 500),
347
+ })
348
+ } catch (error) {
349
+ result.errors += 1
350
+ entry = {
351
+ at: Date.now(),
352
+ kind: 'weibo-post',
353
+ postId: post.id,
354
+ title: firstLine(post.text, 80),
355
+ action: 'error',
356
+ error: error?.message || String(error),
357
+ }
358
+ }
359
+ if (entry) {
360
+ pushHistory(state, entry)
361
+ result.items.push(entry)
362
+ log(
363
+ `[foxy-weibo] 博文 ${entry.postId} 《${entry.title}》 -> ${ACTION_LABELS[entry.action] || entry.action}` +
364
+ (entry.error ? `(${firstLine(entry.error, 100)})` : ''),
365
+ )
366
+ }
367
+ if (result.commented >= maxComments) break
368
+ await new Promise((resolve) => setTimeout(resolve, 600))
369
+ }
370
+ state.lastRun = Date.now()
371
+ state.lastResult = result
372
+ return result
373
+ }
374
+
161
375
  async function scanOwnComments(ctx, config, state, log, force = false, options = {}) {
162
376
  if (!config.enabled && !force) return { skipped: 'disabled' }
163
377
  const previewOnly = options.previewOnly === true
@@ -171,6 +385,8 @@ async function scanOwnComments(ctx, config, state, log, force = false, options =
171
385
  previewed: 0,
172
386
  skipped: 0,
173
387
  errors: 0,
388
+ posts: 0,
389
+ comments: 0,
174
390
  items: [],
175
391
  }
176
392
  const targetUid = String(config.targetUid || '').trim()
@@ -189,12 +405,16 @@ async function scanOwnComments(ctx, config, state, log, force = false, options =
189
405
  } else {
190
406
  return { ...result, errors: 1, error: '请先扫码登录微博账号' }
191
407
  }
192
- const maxPosts = Math.max(1, Math.min(20, Number(config.maxPostsPerRun) || 5))
408
+ const maxPosts = Math.max(1, Math.min(100, Number(config.maxPostsPerRun) || 5))
193
409
  const maxReplies = Math.max(1, Math.min(8, Number(options.maxReplies ?? config.maxCommentsPerDay) || 1))
194
410
  const ignoreQuota = forceReply
195
411
  let posts = []
196
412
  try {
197
- posts = await fetchTimeline(targetUid, config)
413
+ posts = await fetchTimeline(targetUid, config, {
414
+ maxPosts,
415
+ maxPages: Math.ceil(maxPosts / 10) + 1,
416
+ })
417
+ result.posts = posts.length
198
418
  } catch (error) {
199
419
  return { ...result, errors: 1, error: error?.message || String(error) }
200
420
  }
@@ -209,6 +429,7 @@ async function scanOwnComments(ctx, config, state, log, force = false, options =
209
429
  log(`[foxy-weibo] 读取评论失败 ${post.id}:${error?.message || error}`)
210
430
  continue
211
431
  }
432
+ result.comments += comments.length
212
433
  for (const comment of comments) {
213
434
  if (targetCid && String(comment.cid) !== targetCid) continue
214
435
  if (canReply && result.replied >= maxReplies) break
@@ -482,7 +703,7 @@ export function apply(ctx, rawConfig = {}) {
482
703
  const result =
483
704
  config.replyOwnPosts === false
484
705
  ? await scanOnce(ctx, config, state, log, force)
485
- : await scanOwnComments(ctx, config, state, log, force)
706
+ : await scanTargetPosts(ctx, config, state, log, force)
486
707
  saveState(state)
487
708
  log(`[foxy-weibo] 运行结束 ${JSON.stringify(result)}`)
488
709
  return result
@@ -500,9 +721,9 @@ export function apply(ctx, rawConfig = {}) {
500
721
  running = true
501
722
  try {
502
723
  log('[foxy-weibo] 开始立即检查')
503
- const result = await scanOwnComments(ctx, config, state, log, true, {
724
+ const result = await scanTargetPosts(ctx, config, state, log, true, {
504
725
  previewOnly: true,
505
- mode: 'own-comments-check',
726
+ mode: 'target-posts-check',
506
727
  })
507
728
  saveState(state)
508
729
  log(`[foxy-weibo] 立即检查结束 ${JSON.stringify(result)}`)
@@ -521,11 +742,10 @@ export function apply(ctx, rawConfig = {}) {
521
742
  running = true
522
743
  try {
523
744
  log(`[foxy-weibo] 开始立即回复${cid ? ` cid=${cid}` : ''}`)
524
- const result = await scanOwnComments(ctx, config, state, log, true, {
745
+ const result = await scanTargetPosts(ctx, config, state, log, true, {
525
746
  forceReply: true,
526
- maxReplies: 1,
527
- cid,
528
- mode: 'own-comments-reply-now',
747
+ maxPosts: 100,
748
+ mode: 'target-posts-reply-now',
529
749
  })
530
750
  saveState(state)
531
751
  log(`[foxy-weibo] 立即回复结束 ${JSON.stringify(result)}`)
package/lib/judge.js CHANGED
@@ -125,6 +125,7 @@ function finalize(parsed, input, fallbackText) {
125
125
  if (action === 'comment' && !comment) action = 'remember'
126
126
  let replyTo = input.mayReply ? String(parsed.replyTo || '0') : '0'
127
127
  if (input.targetComment?.cid) replyTo = String(input.targetComment.cid)
128
+ if (input.targetPost) replyTo = '0'
128
129
  return {
129
130
  action,
130
131
  comment: comment.slice(0, 300),
@@ -139,14 +140,20 @@ export async function judgePost(config, input) {
139
140
  const persona = readFoxyPersona(config)
140
141
  const prompt = [
141
142
  '你正在浏览微博,不是回复 QQ/微信,也不是客服。',
142
- '下面是一个微博账号的博文与评论。请用 Foxy 的判断,决定:',
143
+ input.targetPost
144
+ ? '下面是一条目标账号的博文。请阅读后判断是否值得在博文下发表一条评论:'
145
+ : '下面是一个微博账号的博文与评论。请用 Foxy 的判断,决定:',
143
146
  '- skip:广告、无意义、纯转发抽奖、引战、没信息量,跳过;',
144
147
  '- remember:有点意思,但不必公开评论,自己记一笔;',
145
148
  '- comment:值得在评论区说一句,才评论。',
146
149
  input.mayComment ? '本次允许评论。' : '本次不允许评论,只能 skip 或 remember。',
147
150
  '',
148
- '评论要求:像 Foxy 自己刷到后随口说的,中文第一人称;不设最少字数,能一句说清就一句,最多 80 字;不要客服腔,不要复述原文,不要写 @。',
149
- '记忆要求:如果值得记,用真实事件写第一人称,例如“我在微博看到 xx 说……,觉得……”。不要把所有事件都写成“我刷到”。不超过 180 字。',
151
+ input.targetPost
152
+ ? '评论是发在这条博文下的一级评论,不是回复某个人;不要写 @,不要假设对方在跟你对话。'
153
+ : '评论要求:像 Foxy 自己刷到后随口说的,中文第一人称;不设最少字数,能一句说清就一句,最多 80 字;不要客服腔,不要复述原文,不要写 @。',
154
+ input.targetPost
155
+ ? '评论要求:像一个了解对方的人看到这条博文后的自然回应;不设最少字数,能一句说清就一句,最多 80 字。'
156
+ : '记忆要求:如果值得记,用真实事件写第一人称,例如“我在微博看到 xx 说……,觉得……”。不要把所有事件都写成“我刷到”。不超过 180 字。',
150
157
  '',
151
158
  input.targetComment
152
159
  ? [
@@ -156,7 +163,7 @@ export async function judgePost(config, input) {
156
163
  '如果是广告、引战、辱骂、诈骗或无意义内容,输出 action=skip。',
157
164
  'replyTo 填这条评论的 cid,不要回复其他人。',
158
165
  ].join('\n')
159
- : input.mayReply && input.comments.length
166
+ : input.mayReply && Array.isArray(input.comments) && input.comments.length
160
167
  ? [
161
168
  '回复规则:可以回复下面某条评论。replyTo 填要回复的 cid;只发一级评论时填 0。',
162
169
  '不要回复自己,不要回复广告/引战/辱骂/诈骗。',
package/lib/settings.js CHANGED
@@ -8,7 +8,9 @@ export const DEFAULTS = {
8
8
  targetUid: '',
9
9
  targetUids: [],
10
10
  replyOwnPosts: true,
11
- replyMode: 'off', // off | auto
11
+ replyMode: 'off',
12
+ replyAfter: '',
13
+ learnPosts: true,
12
14
  scheduleTimes: ['20:00'],
13
15
  intervalMinutes: 60,
14
16
  maxPostsPerRun: 5,
@@ -30,6 +32,8 @@ export const SETTING_KEYS = [
30
32
  'targetUids',
31
33
  'replyOwnPosts',
32
34
  'replyMode',
35
+ 'replyAfter',
36
+ 'learnPosts',
33
37
  'scheduleTimes',
34
38
  'intervalMinutes',
35
39
  'maxPostsPerRun',
@@ -46,6 +50,7 @@ export const SETTING_KEYS = [
46
50
 
47
51
  const TEXT_KEYS = new Set([
48
52
  'targetUid',
53
+ 'replyAfter',
49
54
  'commentMode',
50
55
  'replyMode',
51
56
  'memoryPresetId',
@@ -56,6 +61,7 @@ const BOOLEAN_KEYS = new Set([
56
61
  'enabled',
57
62
  'replyOwnPosts',
58
63
  'replyOthers',
64
+ 'learnPosts',
59
65
  'enableMemory',
60
66
  'runOnStart',
61
67
  'debug',
@@ -97,7 +103,7 @@ function clampNumber(key, value, fallback) {
97
103
  const n = Number(value)
98
104
  if (!Number.isFinite(n)) return fallback
99
105
  if (key === 'intervalMinutes') return Math.max(5, Math.min(24 * 60, Math.round(n)))
100
- if (key === 'maxPostsPerRun') return Math.max(1, Math.min(20, Math.round(n)))
106
+ if (key === 'maxPostsPerRun') return Math.max(1, Math.min(100, Math.round(n)))
101
107
  if (key === 'maxCommentsPerDay') return Math.max(0, Math.min(10, Math.round(n)))
102
108
  return n
103
109
  }
@@ -124,6 +130,7 @@ export function normalizePatch(current = {}, patch = {}) {
124
130
  next.enabled = next.enabled === true
125
131
  next.replyOwnPosts = next.replyOwnPosts !== false
126
132
  next.replyOthers = next.replyOthers !== false
133
+ next.learnPosts = next.learnPosts !== false
127
134
  next.enableMemory = next.enableMemory !== false
128
135
  next.replyMode = next.replyMode === 'auto' ? 'auto' : 'off'
129
136
  next.targetUids = normalizeUids(next.targetUids)
package/lib/weibo.js CHANGED
@@ -79,10 +79,41 @@ async function readJson(response) {
79
79
  }
80
80
  }
81
81
 
82
+ export function parseWeiboTime(value, now = Date.now()) {
83
+ const text = String(value || '').trim()
84
+ if (!text) return 0
85
+ const current = new Date(now)
86
+ const at = (year, month, day, hour = 0, minute = 0) =>
87
+ new Date(year, month - 1, day, hour, minute).getTime()
88
+ let match = text.match(/^(\d+)\s*秒前/)
89
+ if (match) return now - Number(match[1]) * 1000
90
+ match = text.match(/^(\d+)\s*分钟前/)
91
+ if (match) return now - Number(match[1]) * 60_000
92
+ match = text.match(/^(\d+)\s*小时前/)
93
+ if (match) return now - Number(match[1]) * 3_600_000
94
+ if (/^刚刚/.test(text)) return now
95
+ match = text.match(/^今天\s*(\d{1,2}):(\d{2})/)
96
+ if (match) return at(current.getFullYear(), current.getMonth() + 1, current.getDate(), Number(match[1]), Number(match[2]))
97
+ match = text.match(/^昨天\s*(\d{1,2}):(\d{2})/)
98
+ if (match) {
99
+ const day = new Date(current.getFullYear(), current.getMonth(), current.getDate() - 1)
100
+ return at(day.getFullYear(), day.getMonth() + 1, day.getDate(), Number(match[1]), Number(match[2]))
101
+ }
102
+ match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/)
103
+ if (match) return at(Number(match[1]), Number(match[2]), Number(match[3]), Number(match[4] || 0), Number(match[5] || 0))
104
+ match = text.match(/^(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/)
105
+ if (match) return at(current.getFullYear(), Number(match[1]), Number(match[2]), Number(match[3] || 0), Number(match[4] || 0))
106
+ match = text.match(/^(\d{1,2})月(\d{1,2})日(?:\s+(\d{1,2}):(\d{2}))?/)
107
+ if (match) return at(current.getFullYear(), Number(match[1]), Number(match[2]), Number(match[3] || 0), Number(match[4] || 0))
108
+ const parsed = Date.parse(text)
109
+ return Number.isFinite(parsed) ? parsed : 0
110
+ }
111
+
82
112
  export function normalizePost(raw) {
83
113
  const mblog = raw?.mblog || raw || {}
84
114
  const mid = String(mblog.mid || mblog.id || mblog.mblogid || '')
85
115
  const id = String(mblog.mblogid || mid)
116
+ const createdAt = String(mblog.created_at || '')
86
117
  return {
87
118
  id,
88
119
  mid,
@@ -92,7 +123,8 @@ export function normalizePost(raw) {
92
123
  uid: String(mblog.user?.id || mblog.user?.idstr || ''),
93
124
  name: String(mblog.user?.screen_name || ''),
94
125
  },
95
- createdAt: String(mblog.created_at || ''),
126
+ createdAt,
127
+ createdAtMs: parseWeiboTime(createdAt),
96
128
  commentsCount: Number(mblog.comments_count || 0),
97
129
  attitudesCount: Number(mblog.attitudes_count || 0),
98
130
  repostsCount: Number(mblog.reposts_count || 0),
@@ -100,11 +132,11 @@ export function normalizePost(raw) {
100
132
  }
101
133
  }
102
134
 
103
- async function fetchWebTimeline(uid, config = {}) {
135
+ async function fetchWebTimeline(uid, config = {}, page = 1) {
104
136
  const target = String(uid || '').trim()
105
137
  const url =
106
138
  `https://weibo.com/ajax/statuses/mymblog?uid=${encodeURIComponent(target)}` +
107
- `&page=1&feature=0`
139
+ `&page=${Math.max(1, page)}&feature=0`
108
140
  const response = await fetch(url, { headers: headersFor(config, false) })
109
141
  if (!response.ok) throw new WeiboError(`读取微博失败:HTTP ${response.status}`, 'HTTP')
110
142
  const text = await response.text()
@@ -122,39 +154,70 @@ async function fetchWebTimeline(uid, config = {}) {
122
154
  return list.map(normalizePost).filter((post) => post.id && post.text)
123
155
  }
124
156
 
125
- export async function fetchTimeline(uid, config = {}) {
157
+ function uniquePosts(posts) {
158
+ const seen = new Set()
159
+ const out = []
160
+ for (const post of posts) {
161
+ const key = post.id || post.mid
162
+ if (!key || seen.has(key)) continue
163
+ seen.add(key)
164
+ out.push(post)
165
+ }
166
+ return out
167
+ }
168
+
169
+ export async function fetchTimeline(uid, config = {}, options = {}) {
126
170
  const target = String(uid || '').trim()
127
171
  if (!/^\d{5,20}$/.test(target)) throw new WeiboError('微博 UID 格式不正确', 'BAD_UID')
128
- const url =
129
- `https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(target)}` +
130
- `&containerid=107603${encodeURIComponent(target)}&page=1`
131
- try {
132
- const response = await fetch(url, { headers: headersFor(config, true) })
133
- if (!response.ok) throw new WeiboError(`读取微博失败:HTTP ${response.status}`, 'HTTP')
134
- const json = await readJson(response)
135
- const cards = Array.isArray(json?.data?.cards) ? json.data.cards : []
136
- return cards
137
- .filter((card) => card?.mblog)
138
- .map((card) => normalizePost(card.mblog))
139
- .filter((post) => post.id && post.text)
140
- } catch (error) {
141
- if (config.cookie) {
142
- try {
143
- return await fetchWebTimeline(target, config)
144
- } catch (webError) {
145
- throw new WeiboError(
146
- `${error.message};备用接口也失败:${webError.message}`,
147
- 'FETCH_FAILED',
148
- webError.detail,
149
- )
172
+ const maxPosts = Math.max(1, Math.min(100, Number(options.maxPosts) || 20))
173
+ const maxPages = Math.max(1, Math.min(20, Number(options.maxPages) || Math.ceil(maxPosts / 10) + 1))
174
+ const collected = []
175
+
176
+ for (let page = 1; page <= maxPages && collected.length < maxPosts; page += 1) {
177
+ const url =
178
+ `https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(target)}` +
179
+ `&containerid=107603${encodeURIComponent(target)}&page=${page}`
180
+ try {
181
+ const response = await fetch(url, { headers: headersFor(config, true) })
182
+ if (!response.ok) throw new WeiboError(`读取微博失败:HTTP ${response.status}`, 'HTTP')
183
+ const json = await readJson(response)
184
+ const cards = Array.isArray(json?.data?.cards) ? json.data.cards : []
185
+ const posts = uniquePosts(
186
+ cards
187
+ .filter((card) => card?.mblog)
188
+ .map((card) => normalizePost(card.mblog))
189
+ .filter((post) => post.id && post.text),
190
+ )
191
+ if (!posts.length) break
192
+ collected.push(...posts)
193
+ if (posts.length < 10) break
194
+ } catch (error) {
195
+ if (config.cookie) {
196
+ try {
197
+ const posts = uniquePosts(await fetchWebTimeline(target, config, page))
198
+ if (!posts.length) break
199
+ collected.push(...posts)
200
+ if (posts.length < 10) break
201
+ continue
202
+ } catch (webError) {
203
+ if (collected.length) break
204
+ throw new WeiboError(
205
+ `${error.message};备用接口也失败:${webError.message}`,
206
+ 'FETCH_FAILED',
207
+ webError.detail,
208
+ )
209
+ }
150
210
  }
211
+ if (collected.length) break
212
+ throw new WeiboError(
213
+ `${error.message};微博接口可能要求登录 Cookie(设置 → Foxy-weibo → 微博 Cookie)`,
214
+ 'LOGIN_REQUIRED',
215
+ error.detail,
216
+ )
151
217
  }
152
- throw new WeiboError(
153
- `${error.message};微博接口可能要求登录 Cookie(设置 → Foxy-weibo → 微博 Cookie)`,
154
- 'LOGIN_REQUIRED',
155
- error.detail,
156
- )
157
218
  }
219
+
220
+ return uniquePosts(collected).slice(0, maxPosts)
158
221
  }
159
222
 
160
223
  function normalizeComment(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-foxy-weibo",
3
- "version": "0.2.3",
3
+ "version": "0.3.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",
@@ -48,4 +48,5 @@
48
48
  "test": "node test/test-weibo.cjs && node test/test-client.cjs"
49
49
  }
50
50
  }
51
-
51
+
52
+