dsh-social-trace 0.1.1 → 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
@@ -100,7 +100,7 @@ dsh plugin --profile desktop add .
100
100
 
101
101
  ```sh
102
102
  npm ci
103
- npm test # 244 项离线测试
103
+ npm test # 254 项离线测试
104
104
  npm run test:ui # 5 项界面测试(需要 Chrome)
105
105
  ```
106
106
 
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.1",
3
+ "version": "0.1.2",
4
4
  "description": "Social Trace:微博与雪球增量监控、可视化拓扑、历史缓存与本地检索",
5
5
  "type": "module",
6
6
  "license": "MIT",