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 +1 -1
- package/lib/client.js +18 -0
- package/lib/index.js +3 -1
- package/lib/platforms/weibo/index.js +5 -4
- package/lib/store.js +21 -3
- package/lib/worker.js +40 -10
- package/package.json +1 -1
package/README.md
CHANGED
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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;
|