dsh-social-trace 0.1.0 → 0.1.2

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
@@ -1,38 +1,108 @@
1
- # dsh-Social Trace
1
+ # Social Trace
2
2
 
3
- DeepSeek Harness 的本地社交动态监控插件,支持微博和雪球。
3
+ **把你在微博、雪球关注的博主说过的话,完整留在本地。**
4
4
 
5
- ## 功能
5
+ Social Trace 是 DeepSeek Harness 的本地社交动态监控插件。它按博主持续检查新动态,把正文、转发原文和配图归档到本机;有新内容时,可以通过 dsh-im 推送到 QQ / 微信。
6
6
 
7
- - 可视化监控流向,按平台、博主独立启停。
8
- - 定时检查新动态,按日期缓存历史消息,支持断点续传。
9
- - 按平台和博主保存正文、转发及图片,支持搜索和 Markdown / JSONL 导出。
10
- - 通过 dsh-im 向 QQ、微信等多个接收目标推送消息。
11
- - 内置版本检查;通过 npm 安装的插件支持一键更新。
7
+ [![npm](https://img.shields.io/npm/v/dsh-social-trace.svg)](https://www.npmjs.com/package/dsh-social-trace)
8
+ [![license](https://img.shields.io/npm/l/dsh-social-trace.svg)](LICENSE)
9
+
10
+ ## 为什么用它
11
+
12
+ - **平台上的内容会消失。** 博主删帖、改口、设置半年可见,刷过去就找不到了。Social Trace 把看到的动态按博主留在本地,之后还能搜索、导出。
13
+ - **不想每天手动刷主页。** 按博主设置检查频率和时间段,只在新动态出现时记录和提醒。
14
+ - **不想把账号交给 AI。** 登录在插件自己的浏览器里完成,Cookie 不经过模型,资料库只落在本机。
15
+
16
+ ## 核心能力
17
+
18
+ | 能力 | 说明 |
19
+ | --- | --- |
20
+ | 按博主监控 | 微博、雪球;每位博主独立开关、频率和时间段 |
21
+ | 增量检查 | 定时读取新动态;关机/重启后补查期间仍可见的内容 |
22
+ | 历史缓存 | 按日期范围回补旧动态,支持断点续传,不重复抓取 |
23
+ | 完整留档 | 正文、转发原文、配图;按平台和博主 UID 分目录保存 |
24
+ | 本地资料库 | SQLite + 按博主分目录的 Markdown;支持中文正文搜索 |
25
+ | 导出 | 单博主 Markdown / JSONL,正文和来源一起带走 |
26
+ | 可视化流向 | 博主 → 平台 → Social Trace → 资料库四层拓扑,状态、进度、异常一眼可见 |
27
+ | dsh-im 推送 | 新动态原文推送到 QQ / 微信;支持多个接收目标;默认关闭 |
28
+ | 本地优先 | 授权会话只存插件目录(0600),资料库不额外上传云端,不写进仓库 |
29
+
30
+ ## 它怎么工作
31
+
32
+ 1. **添加博主**:粘贴微博/雪球主页链接或数字 UID。
33
+ 2. **选择记录方式**:从现在开始,或按日期范围缓存历史消息。
34
+ 3. **交给它跑**:按设定频率检查;新内容入库后写 Markdown、下载配图;需要时通过 dsh-im 推送。
12
35
 
13
36
  ## 安装
14
37
 
15
- 需要 DeepSeek Harness 桌面版、Google Chrome 和 Node.js 22.19 或以上。
38
+ 需要 DeepSeek Harness 桌面版、Google Chrome,以及 Node.js 22.19 或以上。
16
39
 
17
- 在源码目录执行:
40
+ 从 npm 安装(也可以在 Harness 的插件管理里直接安装 `dsh-social-trace`):
18
41
 
19
42
  ```sh
43
+ dsh plugin --profile desktop add dsh-social-trace
44
+ ```
45
+
46
+ 也可以从源码安装:
47
+
48
+ ```sh
49
+ git clone https://github.com/chiinsight/dsh-social-trace.git
50
+ cd dsh-social-trace
20
51
  npm ci
21
52
  dsh plugin --profile desktop add .
22
53
  ```
23
54
 
24
- 重新打开 Harness,在侧栏进入 Social Trace。
55
+ 安装后完全退出并重新打开 Harness,在侧栏打开 Social Trace。
56
+
57
+ ## 快速开始
58
+
59
+ 1. **选保存位置**:在「连接与设置」里选择资料库文件夹。
60
+ 2. **登录平台**:点「登录微博」/「登录雪球」,在独立浏览器窗口完成登录,回来点「我已登录,继续」。
61
+ 3. **添加博主**:粘贴主页或 UID,读取昵称/头像/总数,选择从现在记录或缓存历史。
62
+ 4. **调频率**:点大屏里的博主、平台或总控齿轮,设置检查频率、时间段和历史范围。
63
+ 5. **开推送(可选)**:先在 dsh-im 里连接机器人并创建接收目标,再到 Social Trace 的 dsh-im 节点添加渠道、选择博主范围、开启自动推送。
64
+
65
+ ## 通过 dsh-im 推送
66
+
67
+ 推送的是**新动态原文通知**,不是 AI 摘要:包含博主、发布时间、正文节选、转发引用和原文链接。
68
+
69
+ - 默认关闭;安装、打开设置或选择目标都不会自动发送。
70
+ - 只推送开启之后新采集的内容;历史缓存和此前已存内容不补发。
71
+ - 支持多个接收目标(QQ、微信等),每个目标可单独开关;一个渠道失败不影响其他渠道。
72
+ - 投递记录可查;失败或结果未知不会自动重发,避免重复打扰。
73
+ - 机器人密钥由 dsh-im 管理,Social Trace 不重复保存。
74
+
75
+ 配置入口:大屏的 dsh-im 节点,或「连接与设置」里的消息推送区域。
76
+
77
+ ## 数据放在哪
78
+
79
+ 默认在 `~/.local/share/dsh-social-trace/`,也可以在设置里改到自己的文件夹。
80
+
81
+ ```text
82
+ 你选择的文件夹/
83
+ archive.sqlite 正文、任务、状态和消息
84
+ documents/
85
+ weibo/<UID>/ 该博主的 Markdown 与配图
86
+ xueqiu/<UID>/
87
+ ```
88
+
89
+ 登录会话单独保存在插件目录,权限 0600,不进入仓库,也不会复制到资料文件夹。资料库不是加密数据库;需要更高保护时请把文件夹放在加密磁盘里。
25
90
 
26
- ## 开始使用
91
+ ## 边界
27
92
 
28
- 1. 在「连接与设置」选择信息保存位置,登录对应平台。
29
- 2. 添加博主主页链接或 UID,选择从现在记录或缓存历史消息。
30
- 3. 点击大屏节点齿轮,配置监控时间、频率及历史缓存范围。
31
- 4. 如需推送,在 dsh-im 配好机器人和接收目标,再从大屏 dsh-im 节点添加渠道并开启推送。
93
+ - 需要电脑唤醒且 Harness/插件在运行;关机期间不会采集,重开后补查仍可见的内容。
94
+ - 已删除、被权限挡住或超出平台可见范围的内容无法补回。
95
+ - 平台要求登录/验证时会暂停采集,处理后继续;不会绕过验证码、轮换账号或使用代理池。
96
+ - 雪球文章只保存标题和原文链接,不保存文章正文。
97
+ - 当前推送的是原文通知;AI 总结和 IM 内问答尚未接入。
32
98
 
33
- ## 说明
99
+ ## 开发
34
100
 
35
- 监控需要电脑唤醒且插件运行。重新启动后会补查期间仍可见的动态;已删除或无权访问的内容无法补存。平台要求登录或验证时会暂停采集,处理后可继续。雪球文章仅保存标题和原文链接。
101
+ ```sh
102
+ npm ci
103
+ npm test # 254 项离线测试
104
+ npm run test:ui # 5 项界面测试(需要 Chrome)
105
+ ```
36
106
 
37
107
  ## 许可
38
108
 
package/lib/client.js CHANGED
@@ -101,6 +101,15 @@
101
101
  const taskProblem = job => job.kind === 'history' && /长文接口未返回全文/.test(job.message || '')
102
102
  ? '有一条长内容暂时无法完整读取,历史缓存已暂停;已保存内容保留,新动态监控不受此问题影响。'
103
103
  : (job.kind === 'history' ? '历史缓存已暂停:' : '新动态检查已暂停:') + (job.message || '读取未完成,请重试');
104
+ function checkHealthReason(source, uid = '') {
105
+ const health = source?.health;
106
+ if (!health) return '';
107
+ const delayed = health.delayed.filter(check => !uid || check.uid === uid);
108
+ const reasons = [];
109
+ if (delayed.length) reasons.push((uid ? '这位博主' : delayed.length + ' 位博主') + '未按设定频率及时检查');
110
+ if (health.capacityLimited) reasons.push(health.activeAuthors + ' 位博主共用账号,仅检查一轮最新页就至少需要 ' + Math.ceil(health.minimumCycleMs / 1000) + ' 秒,超出了当前频率的处理能力');
111
+ return reasons.join(';');
112
+ }
104
113
  function monitorStatus(state, platform = 'weibo', uid = '') {
105
114
  const target = state?.targets.find(item => item.uid === uid);
106
115
  const source = state?.platforms?.find(item => item.id === platform);
@@ -126,6 +135,8 @@
126
135
  return result(historyOnly ? names.length + ' 位博主的历史缓存已暂停' : names.length + ' 位博主的新动态检查需处理', jobs.some(job => job.status === 'queued') ? 'partial' : 'blocked', names.join('、') + ':' + taskProblem(problems[0]), 'tasks');
127
136
  }
128
137
  if (jobs.length && jobs.every(job => ['paused', 'complete'].includes(job.status))) return result('任务已暂停', 'paused', '在大屏开启需要执行的任务', 'tasks');
138
+ const healthReason = jobs.some(job => job.kind === 'updates' && job.status === 'queued') && checkHealthReason(source, uid);
139
+ if (healthReason) return result('检查延迟', 'partial', healthReason, 'frequency');
129
140
  const active = state.activeJobIds || [state.activeJobId];
130
141
  if (jobs.some(job => active.includes(job.id))) return result('正在读取', 'active', '新内容保存后会显示在本地档案中');
131
142
  return result(jobs.length ? '监控中' + (uid ? ' · ' + intervalText(checkInterval(state, platform, uid)) : '') : '监控就绪', 'ready', '更新按设置的检查间隔执行,历史内容分批缓存');
@@ -157,6 +168,7 @@
157
168
  if (state.enabled === false || state.platforms?.find(item => item.id === target.platform)?.enabled === false || target.enabled === false || job?.status === 'paused') return '已暂停';
158
169
  if (job.status === 'needs_review') return '需处理';
159
170
  if (!platformReady(state, target.platform || 'weibo')) return '等待连接';
171
+ if (kind === 'updates' && state.platforms?.find(item => item.id === (target.platform || 'weibo'))?.health?.delayed.some(check => check.uid === target.uid)) return '检查延迟';
160
172
  if (kind === 'updates' && progressFor(state, target.uid).catchup) return '补查中';
161
173
  return kind === 'history' ? '缓存中' : '监控中';
162
174
  }
@@ -494,6 +506,12 @@
494
506
  tab === 'frequency' ? h('form', { id: 'st-node-panel-frequency', role: 'tabpanel', 'aria-labelledby': embedded ? 'st-reader-tab-frequency' : 'st-node-tab-frequency', onSubmit: event => { event.preventDefault(); void run(target ? '/targets/policy' : '/settings', target ? { uid: target.uid, inherit: false, intervalMinutes: value.intervalMinutes, schedule: value.schedule } : { intervalMinutes: value.intervalMinutes, schedule: value.schedule }, '监控时间与频率已保存'); } },
495
507
  target ? h('p', { className: 'st-caption' }, target.policy ? '当前为这位博主单独设置。' : '已填入' + platformNames[context.platform] + '平台的时间;保存后只对这位博主生效。') : null,
496
508
  target ? h(TaskControl, { state, target, kind: 'updates', act, busy, openSettings: context => { setTab(context.tab || 'frequency'); } }) : null,
509
+ checkHealthReason(source, target?.uid) ? h('p', { className: 'st-inline-error', role: 'status' }, checkHealthReason(source, target?.uid), '。登录正常不代表每次检查都及时完成。') : null,
510
+ progress?.recentChecks?.length ? h('details', null, h('summary', null, '最近检查记录'), h('table', null,
511
+ h('thead', null, h('tr', null, ['检查时间', '接口结果', '返回的最新发布时间'].map(label => h('th', { key: label }, label)))),
512
+ h('tbody', null, progress.recentChecks.slice(-6).reverse().map((check, index) => h('tr', { key: index },
513
+ h('td', null, date(check.at)), h('td', null, check.status === 'success' ? '第 ' + check.page + ' 页 · 返回 ' + check.count + ' 条' : (check.stage === 'complete' ? '正文补全失败' : '动态列表读取失败')),
514
+ h('td', null, check.newestPublishedAt ? date(check.newestPublishedAt) : '—')))))) : null,
497
515
  h(FrequencyFields, { value, change: setValue, disabled: busy }),
498
516
 
499
517
  !target ? h('section', { className: 'st-independent', 'aria-label': '独立时间配置的博主' }, h('h3', null, '独立时间配置', independent.length ? ' · ' + independent.length + ' 位' : ''),
package/lib/index.js CHANGED
@@ -59,7 +59,8 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
59
59
  let worker;
60
60
  const browser = new Adapter(directory, options => worker.beforeRequest(options), () => store.account(id).uid);
61
61
  if (browser.defaults && !store.getSetting('targetDefaults:' + id, null)) store.setSetting('targetDefaults:' + id, { intervalMs: browser.defaults.intervalMs, historyGapMs: browser.defaults.historyGapMs });
62
- worker = new Worker(store, browser, { documents, requestGapMs: Math.max(browser.defaults?.requestGapMs || 15000, store.getSetting(id === 'weibo' ? 'requestGapMs' : `requestGapMs:${id}`, browser.defaults?.requestGapMs || 15000)) });
62
+ worker = new Worker(store, browser, { documents, monitoringGapMs: id === 'weibo' ? 3000 : browser.defaults?.requestGapMs || 15000,
63
+ requestGapMs: Math.max(browser.defaults?.requestGapMs || 15000, store.getSetting(id === 'weibo' ? 'requestGapMs' : `requestGapMs:${id}`, browser.defaults?.requestGapMs || 15000)) });
63
64
  return [id, { browser, worker, loginBusy: false, historyBusy: false }];
64
65
  }));
65
66
  const { browser, worker } = sources.weibo;
@@ -174,6 +175,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
174
175
  return { id, name: source.browser.name, enabled: store.getSetting('platformEnabled:' + id, true), schedule, targetDefaults,
175
176
  frequency: { intervalMs: intervalAt(schedule, targetDefaults.intervalMs, now), nextChangeAt: Number.isFinite(change) ? change : null,
176
177
  nextIntervalMs: Number.isFinite(change) ? intervalAt(schedule, targetDefaults.intervalMs, change) : null },
178
+ health: store.monitoringHealth(id, Math.min(source.worker.requestGapMs, source.worker.monitoringGapMs)),
177
179
  requestGapMs: source.worker.requestGapMs, profileRequestAt: store.getSetting('profileRequestAt:' + id, 0), loginBusy: source.loginBusy, stopped: source.worker.stopping, lastFailure: store.getSetting('lastFailure:' + id, null) };
178
180
  }),
179
181
  targetDefaults: store.getSetting('targetDefaults', { intervalMs: 900000, historyGapMs: 60000 }),
@@ -61,7 +61,7 @@ function browserError(error) {
61
61
  return new CaptureError('network', /closed|destroyed|disconnected/i.test(message) ? '微博采集页面已关闭或正在跳转,请重新打开登录窗口并确认连接'
62
62
  : /timeout|timed out|net::/i.test(message) ? '微博网页或接口连接超时,稍后自动重试,已保存内容不受影响'
63
63
  : /executable|browser.*not found/i.test(message) ? '找不到采集所需的 Chrome 浏览器,请检查 Chrome 是否已安装'
64
- : '微博采集浏览器暂不可用,请重新打开登录窗口并确认连接');
64
+ : '微博接口连接失败,稍后自动重试;若持续失败,请检查网络或微博连接');
65
65
  }
66
66
 
67
67
  export class WeiboBrowser {
@@ -158,7 +158,8 @@ export class WeiboBrowser {
158
158
  let response;
159
159
  try {
160
160
  const context = await this.httpSession();
161
- response = await context.get('https://weibo.com' + path, { maxRedirects: 0 });
161
+ response = await context.get('https://weibo.com' + path, { maxRedirects: 0,
162
+ ...(options?.monitoringHead ? { timeout: 10000, headers: { 'Cache-Control': 'no-cache' } } : {}) });
162
163
  let body;
163
164
  try { body = await response.json(); } catch { body = null; }
164
165
  const retry = response.headers()['retry-after'];
@@ -197,9 +198,9 @@ export class WeiboBrowser {
197
198
  return { name: user.screen_name.trim(), avatar: weiboAvatar(user), total: user.statuses_count != null && user.statuses_count !== '' && Number.isSafeInteger(Number(user.statuses_count)) && Number(user.statuses_count) >= 0 ? Number(user.statuses_count) : null };
198
199
  }
199
200
 
200
- async timeline(uid, page, cursor) {
201
+ async timeline(uid, page, cursor, options) {
201
202
  const data = await this.request(`/ajax/statuses/mymblog?uid=${uid}&page=${page}&feature=0`
202
- + (cursor ? '&since_id=' + encodeURIComponent(cursor) : ''));
203
+ + (cursor ? '&since_id=' + encodeURIComponent(cursor) : ''), options);
203
204
  if (!Array.isArray(data.list)) throw new CaptureError('schema', '时间线字段变化,已暂停');
204
205
  // An explicit end marker is needed: an empty page alone cannot prove completion.
205
206
  return { items: data.list, end: data.bottom_tips_visible === true || data.bottom_tips_visible === 1,
package/lib/store.js CHANGED
@@ -195,13 +195,15 @@ export class Store {
195
195
  return this.decodeJob(this.db.prepare(`SELECT jobs.* FROM jobs JOIN targets ON targets.uid=jobs.uid
196
196
  WHERE jobs.status='queued' AND next_run_at<=? AND targets.platform=? AND targets.capture_mode<>'pending'
197
197
  AND NOT EXISTS (SELECT 1 FROM settings WHERE key='targetEnabled:' || targets.uid AND value='false')
198
- ORDER BY CASE kind WHEN 'updates' THEN 0 ELSE 1 END,next_run_at LIMIT 1`).get(now, platform));
198
+ ORDER BY CASE WHEN kind='updates' AND COALESCE(json_extract(checkpoint,'$.page'),1)=1
199
+ AND NOT COALESCE(json_extract(checkpoint,'$.pageLoaded'),0) THEN 0 WHEN kind='updates' THEN 1 ELSE 2 END,next_run_at LIMIT 1`).get(now, platform));
199
200
  }
200
201
 
201
- nextJobAt(platform, kind = '') {
202
+ nextJobAt(platform, kind = '', headOnly = false) {
202
203
  return this.db.prepare(`SELECT MIN(next_run_at) AS due FROM jobs JOIN targets ON targets.uid=jobs.uid
203
204
  WHERE jobs.status='queued' AND targets.platform=? AND targets.capture_mode<>'pending' AND (?='' OR jobs.kind=?)
204
- AND NOT EXISTS (SELECT 1 FROM settings WHERE key='targetEnabled:' || targets.uid AND value='false')`).get(platform, kind, kind).due;
205
+ AND (?=0 OR COALESCE(json_extract(checkpoint,'$.page'),1)=1 AND NOT COALESCE(json_extract(checkpoint,'$.pageLoaded'),0))
206
+ AND NOT EXISTS (SELECT 1 FROM settings WHERE key='targetEnabled:' || targets.uid AND value='false')`).get(platform, kind, kind, Number(headOnly)).due;
205
207
  }
206
208
 
207
209
  updateJob(id, patch, now = Date.now()) {
@@ -320,6 +322,21 @@ export class Store {
320
322
  return this.getSetting('targetPolicy:' + target.uid, null) || { intervalMs: target.interval_ms, schedule: this.getSetting('schedule:' + target.platform, null) };
321
323
  }
322
324
 
325
+ monitoringHealth(platform, requestGapMs, now = Date.now()) {
326
+ const targets = new Map(this.targets().filter(target => target.platform === platform && target.capture_mode !== 'pending'
327
+ && this.monitoringEnabled(platform, target.uid)).map(target => [target.uid, target]));
328
+ const checks = this.jobs().filter(job => job.kind === 'updates' && job.status === 'queued' && targets.has(job.uid)).map(job => {
329
+ const policy = this.monitoringPolicy(targets.get(job.uid));
330
+ const intervalMs = intervalAt(policy.schedule, policy.intervalMs, now);
331
+ const lastCheckedAt = job.checkpoint.lastScanAt || job.last_success_at || null;
332
+ const dueAt = lastCheckedAt ? lastCheckedAt + intervalMs : job.next_run_at;
333
+ return { uid: job.uid, intervalMs, lastCheckedAt, lateMs: Math.max(0, now - dueAt) };
334
+ });
335
+ return { activeAuthors: checks.length, minimumCycleMs: checks.length * requestGapMs,
336
+ capacityLimited: checks.reduce((load, check) => load + requestGapMs / check.intervalMs, 0) > 1.001,
337
+ delayed: checks.filter(check => check.lateMs > Math.max(2 * requestGapMs, 5000)) };
338
+ }
339
+
323
340
  targetProgress(now = Date.now()) {
324
341
  const jobs = this.jobs(), summaries = this.archiveSummary();
325
342
  return this.targets().map(target => {
@@ -334,6 +351,7 @@ export class Store {
334
351
  const canCheck = updates?.status === 'queued' && this.monitoringEnabled(target.platform, target.uid) && account?.status === 'ready';
335
352
  return { uid: target.uid, catchup: updates?.checkpoint.catchupFrom != null ? { from: updates.checkpoint.catchupFrom, page: updates.checkpoint.page || 1, saved: Math.max(0, updates.saved - (updates.checkpoint.catchupSaved || 0)) } : null, intervalMs: intervalAt(policy.schedule, policy.intervalMs, now), nextChangeAt: Number.isFinite(policyChange) ? policyChange : null, nextIntervalMs: Number.isFinite(policyChange) ? intervalAt(policy.schedule, policy.intervalMs, policyChange) : null, saved, originals: summary?.original_count || 0, reposts: summary?.repost_count || 0, lastSavedAt: summary?.last_saved_at || null,
336
353
  lastCheckedAt: updates?.last_success_at || null,
354
+ recentChecks: this.getSetting('checkHistory:' + target.uid, []),
337
355
  nextCheckAt: canCheck ? Math.max(now, updates.next_run_at, account.next_request_at, account.cooldown_until) : null,
338
356
  history: { from: target.history_from, to: target.history_to, originalOnly, rangeBounded: target.history_from !== null || target.history_to !== null, rangeComplete: !!history?.checkpoint.rangeComplete,
339
357
  rangeSaved: target.history_from === null && target.history_to === null && !originalOnly ? saved : this.db.prepare('SELECT COUNT(*) AS n FROM posts WHERE uid=? AND published_at>=? AND published_at<=? AND (?=0 OR quote IS NULL)').get(target.uid, target.history_from ?? 0, target.history_to ?? Number.MAX_SAFE_INTEGER, Number(originalOnly)).n, id: history?.id, status: history?.status || 'not_started',
package/lib/worker.js CHANGED
@@ -3,7 +3,7 @@ import { alignedCheckAt, intervalAt, nextBoundary } from './schedule.js';
3
3
  import { CaptureError } from './capture-error.js';
4
4
 
5
5
  export class Worker {
6
- constructor(store, adapter, { now = Date.now, requestGapMs = 15000, documents = null } = {}) {
6
+ constructor(store, adapter, { now = Date.now, requestGapMs = 15000, monitoringGapMs = requestGapMs, documents = null } = {}) {
7
7
  this.store = store;
8
8
  this.adapter = adapter;
9
9
  this.documents = documents;
@@ -12,6 +12,7 @@ export class Worker {
12
12
  this.leaseKey = this.platform === 'weibo' ? 'worker' : 'worker:' + this.platform;
13
13
  this.now = now;
14
14
  this.requestGapMs = requestGapMs;
15
+ this.monitoringGapMs = monitoringGapMs;
15
16
  this.busy = null;
16
17
  this.currentJobId = null;
17
18
  this.stopping = false;
@@ -20,6 +21,8 @@ export class Worker {
20
21
  this.running = false;
21
22
  this.claimAfter = 0;
22
23
  this.boundaryAt = 0;
24
+ this.lastTickAt = null;
25
+ this.resumeCheckPending = false;
23
26
  }
24
27
 
25
28
  claim() {
@@ -62,9 +65,9 @@ export class Worker {
62
65
 
63
66
  pickJob() {
64
67
  const job = this.store.dueJob(this.now(), this.platform);
65
- if (job?.kind === 'history' && !this.localStep(job)) {
66
- const updateAt = this.store.nextJobAt(this.platform, 'updates');
67
- if (updateAt > this.now() && updateAt <= this.now() + this.requestGapMs) return { wait: updateAt };
68
+ if (job && (job.kind === 'history' || job.checkpoint.pageLoaded || job.checkpoint.page > 1) && !this.localStep(job)) {
69
+ const updateAt = this.store.nextJobAt(this.platform, 'updates', true);
70
+ if (updateAt > this.now() && updateAt <= this.now() + Math.min(this.requestGapMs, this.monitoringGapMs)) return { wait: updateAt };
68
71
  }
69
72
  return { job };
70
73
  }
@@ -110,25 +113,36 @@ export class Worker {
110
113
  if (this.running && !this.stopping) return;
111
114
  this.claim();
112
115
  this.prepareRestartChecks();
116
+ this.lastTickAt = this.now();
117
+ this.resumeCheckPending = false;
113
118
  this.running = true;
114
119
  this.rescheduleUpdates();
115
120
  this.wake();
116
121
  }
117
122
 
118
- async beforeRequest({ profileOnly = false, interactiveProfile = false, verifying = false } = {}) {
123
+ async beforeRequest({ profileOnly = false, interactiveProfile = false, verifying = false, monitoringHead = false } = {}) {
119
124
  const account = this.store.account(this.platform);
120
125
  const job = this.currentJobId ? this.store.job(this.currentJobId) : null;
121
126
  if (this.stopping || (verifying ? false : profileOnly ? !['ready', 'paused'].includes(account.status) : !this.store.monitoringEnabled(this.platform, job?.uid) || (job && job.status !== 'queued') || account.status !== 'ready')) throw new CaptureError('stopped', '账号已暂停');
122
127
  const interactive = profileOnly && interactiveProfile;
123
128
  const profileKey = 'profileRequestAt:' + this.platform;
124
- const remaining = Math.max(interactive ? this.store.getSetting(profileKey, 0) : account.next_request_at, account.cooldown_until) - this.now();
129
+ const auxiliary = !monitoringHead && !interactive && !verifying;
130
+ const auxiliaryKey = 'auxiliaryRequestAt:' + this.platform;
131
+ const remaining = Math.max(interactive ? this.store.getSetting(profileKey, 0) : account.next_request_at,
132
+ auxiliary ? this.store.getSetting(auxiliaryKey, 0) : 0, account.cooldown_until) - this.now();
125
133
  if (remaining > 0) throw new CaptureError('wait', '等待下一次请求时间', remaining);
126
134
  if (interactive) this.store.setSetting(profileKey, this.now() + this.requestGapMs);
127
- this.store.setAccount({ next_request_at: Math.max(account.next_request_at, this.now() + this.requestGapMs) }, this.platform);
135
+ if (auxiliary) this.store.setSetting(auxiliaryKey, this.now() + this.requestGapMs);
136
+ const gap = Math.min(this.requestGapMs, this.monitoringGapMs);
137
+ this.store.setAccount({ next_request_at: Math.max(account.next_request_at, this.now() + gap) }, this.platform);
128
138
  }
129
139
 
130
140
  async tick() {
131
141
  if (this.stopping || this.suspended || this.busy) return;
142
+ if (this.running) {
143
+ if (this.lastTickAt !== null && this.now() - this.lastTickAt > 120000) this.resumeCheckPending = true;
144
+ this.lastTickAt = this.now();
145
+ }
132
146
  const account = this.store.account(this.platform);
133
147
  if (!this.store.monitoringEnabled(this.platform) || account.status !== 'ready' || account.cooldown_until > this.now()) return;
134
148
  try { if (this.now() >= this.claimAfter) this.claim(); } catch (error) {
@@ -137,6 +151,10 @@ export class Worker {
137
151
  clearTimeout(this.timer);
138
152
  throw error;
139
153
  }
154
+ if (this.resumeCheckPending) {
155
+ this.prepareRestartChecks();
156
+ this.resumeCheckPending = false;
157
+ }
140
158
  if (this.now() >= this.boundaryAt) this.rescheduleUpdates();
141
159
  const { job } = this.pickJob();
142
160
  if (!job) return;
@@ -210,7 +228,7 @@ export class Worker {
210
228
  return;
211
229
  }
212
230
  const page = checkpoint.page || 1;
213
- const result = await this.adapter.timeline(target.external_uid || job.uid, page, checkpoint.cursor);
231
+ const result = await this.adapter.timeline(target.external_uid || job.uid, page, checkpoint.cursor, { monitoringHead: job.kind === 'updates' && page === 1 });
214
232
  this.requestSucceeded();
215
233
  if (!this.canRun(job)) return;
216
234
  const detectedAt = this.now();
@@ -218,6 +236,7 @@ export class Worker {
218
236
  const pending = [];
219
237
  const unpinnedDates = [];
220
238
  let foundKnown = false;
239
+ let reachedBoundary = false;
221
240
  const firstPageIds = page === 1 ? [] : checkpoint.firstPageIds || [];
222
241
  for (const raw of result.items) {
223
242
  const post = this.normalizePost(raw, target);
@@ -229,20 +248,24 @@ export class Worker {
229
248
  if (!this.adapter.isPinned(raw)) {
230
249
  if (page === 1) firstPageIds.push(post.id);
231
250
  if (job.kind === 'history' && known) foundKnown = true;
251
+ if (checkpoint.boundaryIds?.includes(post.id) && post.publishedAt < scanFrom) reachedBoundary = true;
232
252
  }
233
253
  if (inRange(post)) {
234
254
  if (!this.store.hasCompletePost(post.id)) pending.push(raw);
235
255
  else if (job.kind === 'updates') this.store.observeUpdate(post.id, this.now());
236
256
  }
237
257
  }
238
- // A known ID may come from history; only an older non-pinned page closes the time gap.
239
- if (job.kind === 'updates') foundKnown = unpinnedDates.length > 0 && unpinnedDates.every(at => at < scanFrom);
258
+ // Only the previous update head is a baseline; arbitrary history IDs and pinned posts are not.
259
+ if (job.kind === 'updates') foundKnown = unpinnedDates.length > 0 && (unpinnedDates.every(at => at < scanFrom)
260
+ || checkpoint.catchupFrom == null && reachedBoundary);
240
261
  if (ids.length && JSON.stringify(ids) === JSON.stringify(checkpoint.lastPageIds)) {
241
262
  throw new CaptureError('schema', '平台重复返回上一页,已暂停,避免循环请求');
242
263
  }
243
264
  if (!ids.length && !result.end) {
244
265
  throw new CaptureError('schema', '平台返回空页且没有明确末尾说明,已暂停待检查');
245
266
  }
267
+ if (job.kind === 'updates') this.recordCheck(job.uid, { at: detectedAt, page, stage: 'timeline', status: 'success', count: ids.length,
268
+ newestPublishedAt: unpinnedDates.length ? Math.max(...unpinnedDates) : null });
246
269
  this.store.updateJob(job.id, {
247
270
  checkpoint: { ...checkpoint, scanFrom, pending, page, pageLoaded: true, lastPageIds: ids, detectedAt,
248
271
  firstPageIds, lastScanAt: page === 1 ? this.now() : checkpoint.lastScanAt, retryAt: 0,
@@ -294,6 +317,11 @@ export class Worker {
294
317
  if (account.status === 'ready' && (account.errors || account.reason)) this.store.setAccount({ errors: 0, reason: '' }, this.platform);
295
318
  }
296
319
 
320
+ recordCheck(uid, check) {
321
+ const key = 'checkHistory:' + uid;
322
+ this.store.setSetting(key, [...this.store.getSetting(key, []), check].slice(-32));
323
+ }
324
+
297
325
  failAccount(error) {
298
326
  const account = this.store.account(this.platform);
299
327
  let message = error.message;
@@ -318,6 +346,8 @@ export class Worker {
318
346
  }
319
347
  let message = error.kind && typeof error.message === 'string' ? error.message : '处理采集数据时发生内部错误,已暂停此任务并保留断点';
320
348
  this.store.setSetting('lastFailure:' + this.platform, { uid: job.uid, kind, at: this.now(), message, code: error.kind ? null : String(error.name || 'Error') });
349
+ if (job.kind === 'updates') this.recordCheck(job.uid, { at: this.now(), page: job.checkpoint.page || 1,
350
+ stage: job.checkpoint.pending?.length ? 'complete' : 'timeline', status: kind });
321
351
  if (kind === 'limited' || kind === 'network') {
322
352
  message = this.failAccount(error);
323
353
  const retryAt = this.store.account(this.platform).cooldown_until;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-social-trace",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Social Trace:微博与雪球增量监控、可视化拓扑、历史缓存与本地检索",
5
5
  "type": "module",
6
6
  "license": "MIT",