dsh-social-trace 0.1.2 → 0.1.13

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/lib/index.js CHANGED
@@ -9,6 +9,9 @@ import { dataDirectory, workspaceDirectory, saveWorkspaceDirectory, chooseDirect
9
9
  import { Documents } from './documents.js';
10
10
  import { Avatars } from './avatars.js';
11
11
  import { SocialAgent } from './agent.js';
12
+ import { hostModel } from './chat-model.js';
13
+ import { BusinessChat } from './chat.js';
14
+ import { createBusiness } from './business.js';
12
15
  import { CaptureError } from './capture-error.js';
13
16
  import { platformImage } from './text.js';
14
17
  import { backup } from 'node:sqlite';
@@ -44,7 +47,7 @@ function localRequest(req) {
44
47
  return local && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address);
45
48
  }
46
49
 
47
- export function createRuntime(directory = dataDirectory(), { adapterFactories = { weibo: WeiboBrowser, xueqiu: XueqiuBrowser }, chooseFolder = chooseDirectory, avatarFetch = fetch, getIM = () => null, updates = createUpdates() } = {}) {
50
+ export function createRuntime(directory = dataDirectory(), { adapterFactories = { weibo: WeiboBrowser, xueqiu: XueqiuBrowser }, chooseFolder = chooseDirectory, avatarFetch = fetch, getIM = () => null, model = hostModel(() => null), updates = createUpdates() } = {}) {
48
51
  let workspace = realpathSync(workspaceDirectory(directory));
49
52
  let store = new Store(workspace);
50
53
  let documents = new Documents(store, workspace);
@@ -54,7 +57,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
54
57
  let workspaceTask = null;
55
58
  let exporting = 0;
56
59
  let serviceError = '';
57
- const agent = new SocialAgent(() => store, getIM, () => changingWorkspace || applyingUpdate || closing);
60
+ const agent = new SocialAgent(() => store, getIM, () => changingWorkspace || applyingUpdate || closing, () => model.available());
58
61
  const sources = Object.fromEntries(Object.entries(adapterFactories).map(([id, Adapter]) => {
59
62
  let worker;
60
63
  const browser = new Adapter(directory, options => worker.beforeRequest(options), () => store.account(id).uid);
@@ -65,6 +68,8 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
65
68
  }));
66
69
  const { browser, worker } = sources.weibo;
67
70
  let closing = false;
71
+ const business = createBusiness(() => store, sources, () => documents.drainImages());
72
+ const chat = new BusinessChat(() => store, business, model, { paused: () => changingWorkspace || applyingUpdate || closing || !!serviceError });
68
73
 
69
74
  function enableAuthorJob(uid, kind) {
70
75
  if (store.getSetting('targetEnabled:' + uid, true)) return;
@@ -90,7 +95,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
90
95
  if (destination === workspace) { saveWorkspaceDirectory(directory, destination); await documents.sync({ backgroundImages: true }); return; }
91
96
  const database = join(destination, 'archive.sqlite');
92
97
  if (existsSync(database)) throw new CaptureError('storage', '所选文件夹已有资料库,为避免覆盖,请选择一个新的文件夹');
93
- if (agent.busy) throw new CaptureError('storage', '请等当前消息投递结束后再切换信息保存位置');
98
+ if (agent.busy || chat.busy) throw new CaptureError('storage', '请等当前消息投递或 Chat 执行结束后再切换信息保存位置');
94
99
  if (exporting) throw new CaptureError('storage', '正在导出文档,请等导出完成后再切换信息保存位置');
95
100
  if (Object.values(sources).some(source => source.loginBusy || source.historyBusy)) throw new CaptureError('storage', '请等登录窗口打开完成后再切换信息保存位置');
96
101
  changingWorkspace = true;
@@ -141,6 +146,11 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
141
146
  const url = new URL(req.url, 'http://' + req.headers.host);
142
147
  const path = url.pathname.slice(PREFIX.length);
143
148
  if (req.method === 'GET') {
149
+ if (path === '/chat') return json(res, 200, chat.view(url.searchParams.get('sessionId') || ''));
150
+ if (path === '/chat/run') {
151
+ const run = store.db.prepare('SELECT r.session_id AS sessionId FROM chat_runs r JOIN chat_sessions s ON s.id=r.session_id WHERE r.id=? AND s.deleted_at IS NULL').get(url.searchParams.get('id') || '');
152
+ return run ? json(res, 200, run) : json(res, 404, { error: '报告不存在' });
153
+ }
144
154
  if (path === '/update') return json(res, 200, { update: await updates.check(url.searchParams.get('fresh') === '1') });
145
155
  if (path === '/update/state') return json(res, 200, { update: updates.view() });
146
156
  if (path === '/assets/xueqiu.png') {
@@ -175,6 +185,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
175
185
  return { id, name: source.browser.name, enabled: store.getSetting('platformEnabled:' + id, true), schedule, targetDefaults,
176
186
  frequency: { intervalMs: intervalAt(schedule, targetDefaults.intervalMs, now), nextChangeAt: Number.isFinite(change) ? change : null,
177
187
  nextIntervalMs: Number.isFinite(change) ? intervalAt(schedule, targetDefaults.intervalMs, change) : null },
188
+ connectionRecovery: source.worker.recovery(),
178
189
  health: store.monitoringHealth(id, Math.min(source.worker.requestGapMs, source.worker.monitoringGapMs)),
179
190
  requestGapMs: source.worker.requestGapMs, profileRequestAt: store.getSetting('profileRequestAt:' + id, 0), loginBusy: source.loginBusy, stopped: source.worker.stopping, lastFailure: store.getSetting('lastFailure:' + id, null) };
180
191
  }),
@@ -242,7 +253,17 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
242
253
  const body = await readBody(req);
243
254
  if (workspaceTask) return json(res, 409, { error: '正在切换信息保存位置,请稍后操作' });
244
255
  if (updates.view().phase === 'applying') return json(res, 409, { error: '插件正在更新,请稍后操作' });
256
+ if (path === '/chat/message' || path === '/chat/confirm' || path === '/chat/cancel' || path === '/chat/session/delete' || path === '/chat/session/restore') {
257
+ try {
258
+ if (path === '/chat/session/delete') return json(res, 200, await chat.deleteSession(body.id));
259
+ if (path === '/chat/session/restore') return json(res, 200, chat.restoreSession(body.id));
260
+ if (path === '/chat/message') return json(res, 202, chat.start(body.message, body.sessionId || ''));
261
+ if (path === '/chat/confirm') return json(res, 200, chat.confirm(body.id));
262
+ chat.cancel(body.id); return json(res, 200, { ok: true });
263
+ } catch (error) { return json(res, 400, { error: error.message }); }
264
+ }
245
265
  if (path === '/update') {
266
+ if (chat.busy) return json(res, 409, { error: '请先停止或等候当前 Chat 任务结束后再更新' });
246
267
  if (exporting || Object.values(sources).some(source => source.loginBusy || source.historyBusy)) return json(res, 409, { error: '请等当前登录、缓存设置或导出完成后再更新' });
247
268
  try {
248
269
  const update = updates.start(body.version, async apply => {
@@ -303,13 +324,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
303
324
  const platformName = platformBrowser.name;
304
325
  if (source.historyBusy) return json(res, 409, { error: '正在调整缓存范围,请稍候' });
305
326
  if (path === '/targets/policy') {
306
- const target = store.target(body.uid);
307
- if (!target || target.platform !== platform || typeof body.inherit !== 'boolean') return json(res, 400, { error: '请选择有效的博主设置' });
308
- const interval = Number(body.intervalMinutes);
309
- if (!body.inherit && (!Number.isInteger(interval) || interval < 1 || interval > 10080)) return json(res, 400, { error: '检查间隔为 1–10080 分钟' });
310
- const policy = body.inherit ? null : { intervalMs: interval * 60000, schedule: validateSchedule(body.schedule) };
311
- store.setSetting('targetPolicy:' + target.uid, policy);
312
- platformWorker.rescheduleUpdates();
327
+ business.policy({ ...body, platform });
313
328
  } else if (path === '/history/pause') {
314
329
  const targets = body.uid ? [store.target(body.uid)] : body.scope === 'platform' ? store.targets().filter(target => target.platform === platform) : [];
315
330
  if (!targets.length || targets.some(target => !target || target.platform !== platform)) return json(res, 400, { error: '请选择要暂停缓存的博主' });
@@ -377,6 +392,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
377
392
  } else if (path === '/login') {
378
393
  if (source.loginBusy || platformWorker.busy) return json(res, 409, { error: '正在执行任务,请稍后打开登录窗口' });
379
394
  source.loginBusy = true;
395
+ store.setSetting('connectionRecovery:' + platform, null);
380
396
  store.setAccount({ status: 'needs_login', reason: `请在${platformName}完成登录或验证,然后点击确认连接` }, platform);
381
397
  try { await platformBrowser.login(); } finally { source.loginBusy = false; }
382
398
  } else if (path === '/verify') {
@@ -388,13 +404,15 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
388
404
  if (previous.uid && previous.uid !== uid) {
389
405
  return json(res, 409, { error: '当前登录账号与此档案库绑定账号不同,请切回原账号或使用独立数据目录' });
390
406
  }
391
- store.setAccount({ uid, status: 'ready', reason: '', errors: 0 }, platform);
407
+ platformWorker.confirmConnection(uid);
392
408
  store.notice('task', `已确认${platformName}登录`);
393
409
  } catch (error) {
394
410
  platformWorker.failAccount(error);
395
411
  throw error;
396
412
  } finally { source.loginBusy = false; platformWorker.suspended = false; platformWorker.wake(); }
397
413
  } else if (path === '/account/pause') {
414
+ store.setSetting('accountManualPause:' + platform, true);
415
+ store.setSetting('connectionRecovery:' + platform, null);
398
416
  store.setAccount({ status: 'paused', reason: `已手动暂停${platformName}采集` }, platform);
399
417
  } else if (path === '/targets') {
400
418
  let uid;
@@ -442,11 +460,7 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
442
460
  if (match[2] === 'resume' && job.status === 'complete') {
443
461
  return json(res, 409, { error: '历史任务已到可访问末尾;更新任务仍会持续检查' });
444
462
  }
445
- store.transaction(() => {
446
- if (match[2] === 'resume' && body.enableTarget === true) enableAuthorJob(job.uid, job.kind);
447
- if (match[2] === 'resume' && job.kind === 'history') store.setSetting('historyRequested:' + job.uid, true);
448
- store.updateJob(job.id, { status: match[2] === 'pause' ? 'paused' : 'queued', message: match[2] === 'pause' ? '已手动暂停,断点保留' : '等待继续;账号冷却仍然有效' });
449
- })();
463
+ business.job(job.id, match[2], body.enableTarget);
450
464
  }
451
465
  const clockChanges = ['/targets/start', '/monitoring', '/verify'].includes(path) || path.startsWith('/jobs/') && path.endsWith('/resume') && store.job(path.split('/')[2])?.kind === 'updates';
452
466
  Object.values(sources).forEach(source => { if (clockChanges) source.worker.rescheduleUpdates(); source.worker.wake(); });
@@ -469,18 +483,20 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
469
483
  Object.values(sources).forEach(source => { source.worker.stopping = true; source.worker.running = false; clearTimeout(source.worker.timer); });
470
484
  return;
471
485
  }
486
+ chat.recover();
472
487
  agent.start();
488
+ chat.startScheduler();
473
489
  void documents.sync({ incremental: true }).catch(() => { documents.error = '启动文档补写中断,正文已保留,可在设置中重试'; });
474
490
  void documents.drainImages();
475
491
  }
476
492
  return {
477
- get store() { return store; }, get documents() { return documents; }, updates, agent, worker, browser, sources, handle, start,
493
+ get store() { return store; }, get documents() { return documents; }, updates, agent, chat, worker, browser, sources, handle, start,
478
494
  async close() {
479
495
  if (closing) return;
480
496
  closing = true;
481
497
  if (workspaceTask) await workspaceTask.catch(() => {});
482
498
  documents.stopping = true;
483
- await Promise.all([agent.close(), avatars.close(), ...Object.values(sources).map(source => source.worker.stop())]);
499
+ await Promise.all([chat.close(), agent.close(), avatars.close(), ...Object.values(sources).map(source => source.worker.stop())]);
484
500
  if (documents.task) await documents.task;
485
501
  if (documents.queueTask) await documents.queueTask;
486
502
  await Promise.allSettled([...documents.imageTasks.values()]);
@@ -491,7 +507,8 @@ export function createRuntime(directory = dataDirectory(), { adapterFactories =
491
507
  }
492
508
 
493
509
  export function apply(ctx) {
494
- const updates = createUpdates({ getService: key => { try { return ctx.get(key); } catch { return null; } } });
510
+ const getService = key => { try { return ctx.get(key); } catch { return null; } };
511
+ const updates = createUpdates({ getService });
495
512
  let im = null;
496
513
  ctx.inject(['dshIm'], ready => {
497
514
  im = ready.dshIm;
@@ -500,7 +517,7 @@ export function apply(ctx) {
500
517
  ctx.effect(() => {
501
518
  let runtime;
502
519
  let startupError;
503
- try { runtime = createRuntime(undefined, { getIM: () => im, updates }); runtime.start(); }
520
+ try { runtime = createRuntime(undefined, { getIM: () => im, model: hostModel(getService), updates }); runtime.start(); }
504
521
  catch { startupError = '无法打开本地资料库,请检查信息保存位置和文件权限后重新启用插件'; }
505
522
  const unregister = ctx.webServer.register({ kind: 'prefix', path: PREFIX,
506
523
  handler: (req, res) => startupError ? json(res, 503, { error: startupError }) : runtime.handle(req, res) });
package/lib/worker.js CHANGED
@@ -137,6 +137,55 @@ export class Worker {
137
137
  this.store.setAccount({ next_request_at: Math.max(account.next_request_at, this.now() + gap) }, this.platform);
138
138
  }
139
139
 
140
+ recovery() {
141
+ const key = 'connectionRecovery:' + this.platform;
142
+ let recovery = this.store.getSetting(key, null);
143
+ const account = this.store.account(this.platform);
144
+ if (this.store.getSetting('accountManualPause:' + this.platform, false) || /手动暂停/.test(account.reason || '')) return null;
145
+ // Upgrade previously automatic network pauses without reviving manual pauses.
146
+ if (!recovery && account.uid && account.status === 'paused' && account.errors >= 3 && /连续三次异常/.test(account.reason || '')) {
147
+ recovery = { id: randomUUID(), kind: 'network', attempts: 0, nextAt: Math.max(this.now(), account.cooldown_until) };
148
+ this.store.setSetting(key, recovery);
149
+ }
150
+ return recovery;
151
+ }
152
+
153
+ confirmConnection(uid) {
154
+ const previous = this.store.account(this.platform);
155
+ if (previous.uid && previous.uid !== uid) throw new CaptureError('login', '当前登录账号与资料库绑定账号不同,请切回原账号');
156
+ this.store.transaction(() => {
157
+ this.store.setAccount({ uid, status: 'ready', reason: '', errors: 0 }, this.platform);
158
+ this.store.setSetting('connectionRecovery:' + this.platform, null);
159
+ this.store.setSetting('accountManualPause:' + this.platform, false);
160
+ })();
161
+ }
162
+
163
+ async recoverConnection(recovery) {
164
+ const key = 'connectionRecovery:' + this.platform;
165
+ const current = () => !this.stopping && !this.suspended && this.store.monitoringEnabled(this.platform)
166
+ && this.store.getSetting(key, null)?.id === recovery.id;
167
+ try {
168
+ const uid = await this.adapter.checkLogin(); // Uses the saved HTTP session; never opens a browser.
169
+ if (!current()) return;
170
+ this.confirmConnection(uid);
171
+ this.prepareRestartChecks();
172
+ this.store.notice('task', (this.platform === 'weibo' ? '微博' : '雪球') + '连接已自动恢复,继续检查新动态', this.now());
173
+ } catch (error) {
174
+ if (!current()) return;
175
+ const kind = error.kind || 'internal';
176
+ if (['network', 'limited', 'wait'].includes(kind)) {
177
+ const attempts = recovery.attempts + 1;
178
+ const delay = Math.max(error.retryAfter || 0, Math.min(3600000, 60000 * 2 ** Math.min(attempts, 6)));
179
+ const until = Math.max(this.store.account(this.platform).cooldown_until, this.now() + delay);
180
+ this.store.setAccount({ status: 'paused', cooldown_until: until, reason: '连接暂时不可用,将自动检查恢复;无需反复点击检查连接' }, this.platform);
181
+ this.store.setSetting(key, { ...recovery, attempts, nextAt: until });
182
+ } else {
183
+ this.store.setSetting(key, null);
184
+ this.store.setAccount({ status: kind === 'login' ? 'needs_login' : kind === 'verification' ? 'needs_verification' : 'paused', reason: error.message || '自动检查未能确认授权,请检查平台登录' }, this.platform, true);
185
+ }
186
+ }
187
+ }
188
+
140
189
  async tick() {
141
190
  if (this.stopping || this.suspended || this.busy) return;
142
191
  if (this.running) {
@@ -144,13 +193,23 @@ export class Worker {
144
193
  this.lastTickAt = this.now();
145
194
  }
146
195
  const account = this.store.account(this.platform);
147
- if (!this.store.monitoringEnabled(this.platform) || account.status !== 'ready' || account.cooldown_until > this.now()) return;
196
+ if (!this.store.monitoringEnabled(this.platform)) return;
148
197
  try { if (this.now() >= this.claimAfter) this.claim(); } catch (error) {
149
198
  this.stopping = true;
150
199
  this.running = false;
151
200
  clearTimeout(this.timer);
152
201
  throw error;
153
202
  }
203
+ const recovery = this.recovery();
204
+ if (account.status !== 'ready') {
205
+ const activeTasks = this.store.jobs().some(job => job.status === 'queued' && this.store.target(job.uid)?.platform === this.platform && this.store.monitoringEnabled(this.platform, job.uid));
206
+ if (!activeTasks) return;
207
+ if (!recovery || typeof this.adapter.checkLogin !== 'function' || Math.max(recovery.nextAt, account.cooldown_until, account.next_request_at) > this.now()) return;
208
+ this.busy = this.recoverConnection(recovery);
209
+ try { await this.busy; } finally { this.busy = null; }
210
+ return;
211
+ }
212
+ if (account.cooldown_until > this.now()) return;
154
213
  if (this.resumeCheckPending) {
155
214
  this.prepareRestartChecks();
156
215
  this.resumeCheckPending = false;
@@ -325,14 +384,20 @@ export class Worker {
325
384
  failAccount(error) {
326
385
  const account = this.store.account(this.platform);
327
386
  let message = error.message;
387
+ const manualPause = this.store.getSetting('accountManualPause:' + this.platform, false) || /手动暂停/.test(account.reason || '');
388
+ if (manualPause) return message;
328
389
  if (error.kind === 'limited' || error.kind === 'network') {
329
390
  const errors = account.errors + 1;
330
391
  const delay = Math.max(error.retryAfter || 0, Math.min(3600000, 60000 * 2 ** Math.min(errors, 6)));
331
- if (errors >= 3) message += ';连续三次异常,账号任务已暂停';
392
+ if (errors >= 3) message += account.uid && typeof this.adapter.checkLogin === 'function' ? ';连续三次连接异常,冷却后自动检查恢复' : ';连续三次连接异常,已暂停,请检查平台授权';
332
393
  this.store.setAccount({ errors, cooldown_until: Math.max(account.cooldown_until, this.now() + delay), reason: message,
333
394
  status: errors >= 3 ? 'paused' : account.status }, this.platform);
395
+ if (errors >= 3 && account.uid && typeof this.adapter.checkLogin === 'function') this.store.setSetting('connectionRecovery:' + this.platform,
396
+ { id: randomUUID(), kind: error.kind, attempts: 0, nextAt: this.store.account(this.platform).cooldown_until });
334
397
  } else if (error.kind === 'login' || error.kind === 'verification') {
335
- this.store.setAccount({ status: error.kind === 'login' ? 'needs_login' : 'needs_verification', reason: message }, this.platform, true);
398
+ const recheck = error.kind === 'login' && account.uid && typeof this.adapter.checkLogin === 'function';
399
+ this.store.setSetting('connectionRecovery:' + this.platform, recheck ? { id: randomUUID(), kind: 'login', attempts: 0, nextAt: Math.max(this.now() + 60000, account.cooldown_until, account.next_request_at) } : null);
400
+ this.store.setAccount({ status: error.kind === 'login' ? 'needs_login' : 'needs_verification', reason: message }, this.platform, !recheck);
336
401
  }
337
402
  return message;
338
403
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-social-trace",
3
- "version": "0.1.2",
3
+ "version": "0.1.13",
4
4
  "description": "Social Trace:微博与雪球增量监控、可视化拓扑、历史缓存与本地检索",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,18 +33,25 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "files": [
36
- "lib",
37
- "assets",
36
+ "lib/*.js",
37
+ "lib/platforms/**/*.js",
38
+ "assets/icon.svg",
39
+ "assets/weibo.ico",
40
+ "assets/xueqiu.png",
41
+ "assets/NOTICE.md",
38
42
  "cordis.patch.yml",
39
43
  "README.md",
40
- "LICENSE"
44
+ "LICENSE",
45
+ "THIRD_PARTY_NOTICES"
41
46
  ],
42
47
  "scripts": {
43
48
  "start": "node scripts/preview.mjs",
44
49
  "login": "node scripts/login.mjs",
45
50
  "probe": "node scripts/probe.mjs",
46
51
  "test": "node --test test/*.test.mjs",
47
- "test:ui": "node --test test/client.ui.mjs"
52
+ "test:ui": "node --test test/client.ui.mjs",
53
+ "build": "node scripts/build-markdown.mjs",
54
+ "prepack": "npm run build"
48
55
  },
49
56
  "icon": "assets/icon.svg",
50
57
  "dsh": {
@@ -63,7 +70,11 @@
63
70
  "playwright": "1.64.1"
64
71
  },
65
72
  "devDependencies": {
73
+ "esbuild": "0.25.12",
66
74
  "react": "18.3.1",
67
- "react-dom": "18.3.1"
75
+ "react-dom": "18.3.1",
76
+ "react-markdown": "10.1.0",
77
+ "remark-cjk-friendly": "2.3.1",
78
+ "remark-gfm": "4.0.1"
68
79
  }
69
80
  }