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 +88 -18
- 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
|
@@ -1,38 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Social Trace
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**把你在微博、雪球关注的博主说过的话,完整留在本地。**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Social Trace 是 DeepSeek Harness 的本地社交动态监控插件。它按博主持续检查新动态,把正文、转发原文和配图归档到本机;有新内容时,可以通过 dsh-im 推送到 QQ / 微信。
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-social-trace)
|
|
8
|
+
[](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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
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;
|