dsh-social-trace 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +108 -2
- package/assets/NOTICE.md +3 -0
- package/assets/icon.svg +1 -0
- package/assets/weibo.ico +0 -0
- package/assets/xueqiu.png +0 -0
- package/cordis.patch.yml +3 -0
- package/lib/agent.js +198 -0
- package/lib/avatars.js +63 -0
- package/lib/capture-error.js +8 -0
- package/lib/client.js +1302 -0
- package/lib/delivery-config.js +8 -0
- package/lib/documents.js +176 -0
- package/lib/history-range.js +15 -0
- package/lib/index.js +507 -0
- package/lib/paths.js +49 -0
- package/lib/platforms/weibo/index.js +232 -0
- package/lib/platforms/xueqiu/index.js +239 -0
- package/lib/platforms/xueqiu/media.js +25 -0
- package/lib/schedule.js +102 -0
- package/lib/store.js +356 -0
- package/lib/text.js +35 -0
- package/lib/update.js +61 -0
- package/lib/worker.js +342 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Social Trace contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Social Trace
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**把你在微博、雪球关注的博主说过的话,完整留在本地。**
|
|
4
|
+
|
|
5
|
+
Social Trace 是 DeepSeek Harness 的本地社交动态监控插件。它按博主持续检查新动态,把正文、转发原文和配图归档到本机;有新内容时,可以通过 dsh-im 推送到 QQ / 微信。
|
|
6
|
+
|
|
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 推送。
|
|
35
|
+
|
|
36
|
+
## 安装
|
|
37
|
+
|
|
38
|
+
需要 DeepSeek Harness 桌面版、Google Chrome,以及 Node.js 22.19 或以上。
|
|
39
|
+
|
|
40
|
+
从 npm 安装(也可以在 Harness 的插件管理里直接安装 `dsh-social-trace`):
|
|
41
|
+
|
|
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
|
|
51
|
+
npm ci
|
|
52
|
+
dsh plugin --profile desktop add .
|
|
53
|
+
```
|
|
54
|
+
|
|
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,不进入仓库,也不会复制到资料文件夹。资料库不是加密数据库;需要更高保护时请把文件夹放在加密磁盘里。
|
|
90
|
+
|
|
91
|
+
## 边界
|
|
92
|
+
|
|
93
|
+
- 需要电脑唤醒且 Harness/插件在运行;关机期间不会采集,重开后补查仍可见的内容。
|
|
94
|
+
- 已删除、被权限挡住或超出平台可见范围的内容无法补回。
|
|
95
|
+
- 平台要求登录/验证时会暂停采集,处理后继续;不会绕过验证码、轮换账号或使用代理池。
|
|
96
|
+
- 雪球文章只保存标题和原文链接,不保存文章正文。
|
|
97
|
+
- 当前推送的是原文通知;AI 总结和 IM 内问答尚未接入。
|
|
98
|
+
|
|
99
|
+
## 开发
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npm ci
|
|
103
|
+
npm test # 244 项离线测试
|
|
104
|
+
npm run test:ui # 5 项界面测试(需要 Chrome)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 许可
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE)。平台标识的权利说明见 [assets/NOTICE.md](assets/NOTICE.md)。
|
package/assets/NOTICE.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
微博标识(weibo.ico)取自微博官网 https://weibo.com/favicon.ico(2026-11-05),仅用于标识监控平台。商标与图形权利归相关权利人所有,不包含在本项目 MIT 授权范围内。
|
|
2
|
+
|
|
3
|
+
雪球标识(xueqiu.png)取自雪球官网 https://xueqiu.com/ 的 favicon 链接 https://xqimg.imedao.com/17af5fe80fb1844b3fd48941.png(2026-11-06),仅用于标识平台。商标与图形权利归相关权利人所有,不包含在本项目 MIT 授权范围内。
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#15251f"/><g fill="none" stroke="#a0dfc1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h30v25H28L17 50V17Z"/><path d="M24 26h16M24 34h11"/></g></svg>
|
package/assets/weibo.ico
ADDED
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
package/lib/agent.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { deliveryDestinations, activeDestinations } from './delivery-config.js';
|
|
2
|
+
const defaults = { enabled: false, botId: '', targetId: '', uids: [] };
|
|
3
|
+
export const agentConfig = store => store.getSetting('agentDelivery', defaults);
|
|
4
|
+
|
|
5
|
+
export class SocialAgent {
|
|
6
|
+
constructor(getStore, getIM, paused = () => false) {
|
|
7
|
+
this.getStore = getStore;
|
|
8
|
+
this.getIM = getIM;
|
|
9
|
+
this.paused = paused;
|
|
10
|
+
this.timer = null;
|
|
11
|
+
this.startedAt = Date.now();
|
|
12
|
+
this.connections = { bots: [], targets: {}, checkedAt: 0, error: '' };
|
|
13
|
+
this.refreshing = null;
|
|
14
|
+
this.busy = null;
|
|
15
|
+
this.controller = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
async refreshConnections() {
|
|
18
|
+
if (this.refreshing) return this.refreshing;
|
|
19
|
+
const im = this.getIM(), config = agentConfig(this.getStore());
|
|
20
|
+
if (!im?.listBots || !im?.listTargets) return;
|
|
21
|
+
this.refreshing = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const bots = (await im.listBots()).map(({ botId, channel }) => ({ botId, channel }));
|
|
24
|
+
const targets = {};
|
|
25
|
+
for (const botId of new Set(deliveryDestinations(config).map(item => item.botId))) {
|
|
26
|
+
if (!bots.some(bot => bot.botId === botId)) continue;
|
|
27
|
+
try { targets[botId] = (await im.listTargets(botId)).map(({ targetId, name }) => ({ targetId, name })); }
|
|
28
|
+
catch { targets[botId] = null; }
|
|
29
|
+
}
|
|
30
|
+
this.connections = { bots, targets, checkedAt: Date.now(), error: '' };
|
|
31
|
+
} catch { this.connections = { ...this.connections, checkedAt: Date.now(), error: '无法读取 dsh-im 配置,请检查插件' }; }
|
|
32
|
+
})();
|
|
33
|
+
try { await this.refreshing; } finally { this.refreshing = null; }
|
|
34
|
+
}
|
|
35
|
+
status() {
|
|
36
|
+
const store = this.getStore(), config = agentConfig(store);
|
|
37
|
+
const transportAvailable = ['send', 'listBots', 'listTargets'].every(key => typeof this.getIM()?.[key] === 'function');
|
|
38
|
+
if (transportAvailable && Date.now() - this.connections.checkedAt > 30000) void this.refreshConnections();
|
|
39
|
+
const destinations = deliveryDestinations(config).map(item => {
|
|
40
|
+
const bot = this.connections.bots.find(bot => bot.botId === item.botId);
|
|
41
|
+
const target = this.connections.targets[item.botId]?.find(target => target.targetId === item.targetId);
|
|
42
|
+
const delivery = store.db.prepare("SELECT status,error,sent_at,finished_at,created_at FROM social_deliveries WHERE bot_id=? AND target_id=? AND status IN ('sent','failed','uncertain') ORDER BY COALESCE(finished_at,sent_at,created_at) DESC,id DESC LIMIT 1").get(item.botId, item.targetId);
|
|
43
|
+
const test = store.getSetting('imTest:' + item.botId + ':' + item.targetId, null);
|
|
44
|
+
const last = test && test.created_at > (delivery?.finished_at || delivery?.sent_at || delivery?.created_at || 0) ? test : delivery;
|
|
45
|
+
const state = !transportAvailable ? 'unavailable' : !config.enabled || item.enabled === false ? 'paused'
|
|
46
|
+
: !this.connections.checkedAt ? 'checking' : this.connections.error || !bot || !target ? 'invalid'
|
|
47
|
+
: last && ['failed', 'uncertain'].includes(last.status) ? 'failed' : last?.status === 'sent' ? 'accepted' : 'configured';
|
|
48
|
+
return { ...item, channel: bot?.channel || item.channel || '', name: target?.name || item.name || item.targetId, state, last };
|
|
49
|
+
});
|
|
50
|
+
return { config, transportAvailable, destinations, integration: this.connections, summaryAvailable: false,
|
|
51
|
+
deliveries: store.db.prepare('SELECT d.id,d.author,d.bot_id,d.target_id,d.status,d.error,d.created_at,d.sent_at,d.finished_at,p.published_at,p.detected_at,p.first_seen_at AS saved_at FROM social_deliveries d LEFT JOIN posts p ON p.id=d.post_id ORDER BY d.created_at DESC,d.id DESC LIMIT 30').all() };
|
|
52
|
+
}
|
|
53
|
+
async bots() {
|
|
54
|
+
const im = this.getIM();
|
|
55
|
+
if (typeof im?.listBots !== 'function') return [];
|
|
56
|
+
return (await im.listBots()).map(({ botId, channel }) => ({ botId, channel }));
|
|
57
|
+
}
|
|
58
|
+
async targets(botId) {
|
|
59
|
+
const im = this.getIM();
|
|
60
|
+
if (typeof im?.listTargets !== 'function') throw new Error('尚未连接 dsh-im,请在 Harness 中启用 IM 机器人插件');
|
|
61
|
+
return (await im.listTargets(botId)).map(({ targetId, name, kind }) => ({ targetId, name, kind }));
|
|
62
|
+
}
|
|
63
|
+
async validate(body, { force = false } = {}) {
|
|
64
|
+
if (typeof body.enabled !== 'boolean' || !Array.isArray(body.uids) || body.uids.length > 2000 || body.uids.some(uid => typeof uid !== 'string' || !this.getStore().target(uid))) throw new Error('请选择有效的推送开关与博主');
|
|
65
|
+
const multiple = Array.isArray(body.destinations);
|
|
66
|
+
const input = multiple ? body.destinations : [{ botId: body.botId, targetId: body.targetId, enabled: true }];
|
|
67
|
+
if (input.length > 20) throw new Error('最多配置 20 个推送目标');
|
|
68
|
+
const destinations = [], keys = new Set(), current = agentConfig(this.getStore());
|
|
69
|
+
let bots;
|
|
70
|
+
for (const item of input) {
|
|
71
|
+
if (typeof item.botId !== 'string' || typeof item.targetId !== 'string' || item.enabled !== undefined && typeof item.enabled !== 'boolean') throw new Error('请选择有效的机器人和接收目标');
|
|
72
|
+
const value = { botId: item.botId.trim(), targetId: item.targetId.trim(), enabled: item.enabled !== false };
|
|
73
|
+
if (value.botId.length > 256 || value.targetId.length > 256) throw new Error('机器人或目标标识格式无效,请从 dsh-im 选择');
|
|
74
|
+
const previous = deliveryDestinations(current).find(row => row.botId === value.botId && row.targetId === value.targetId);
|
|
75
|
+
if (previous?.channel) value.channel = previous.channel;
|
|
76
|
+
if (previous?.name) value.name = previous.name;
|
|
77
|
+
const key = value.botId + ':' + value.targetId;
|
|
78
|
+
if (keys.has(key)) throw new Error('该接收目标已添加');
|
|
79
|
+
keys.add(key);
|
|
80
|
+
if (multiple && (!value.botId || !value.targetId)) throw new Error('请选择机器人和接收目标');
|
|
81
|
+
if (body.enabled && value.enabled && (force || !current.enabled || !previous || previous.enabled === false)) {
|
|
82
|
+
if (typeof this.getIM()?.send !== 'function') throw new Error('dsh-im 尚未连接,不能开启推送');
|
|
83
|
+
bots ??= await this.bots();
|
|
84
|
+
const bot = bots.find(bot => bot.botId === value.botId);
|
|
85
|
+
const target = bot && (await this.targets(value.botId)).find(target => target.targetId === value.targetId);
|
|
86
|
+
if (!target) throw new Error('请先在 dsh-im 保存机器人和接收目标,再刷新选择');
|
|
87
|
+
value.channel = bot.channel; value.name = target.name || value.targetId;
|
|
88
|
+
}
|
|
89
|
+
destinations.push(value);
|
|
90
|
+
}
|
|
91
|
+
if (body.enabled && !destinations.some(item => item.enabled)) throw new Error('请至少开启一个接收目标');
|
|
92
|
+
return multiple ? { enabled: body.enabled, uids: [...new Set(body.uids)], destinations }
|
|
93
|
+
: { enabled: body.enabled, botId: destinations[0].botId, targetId: destinations[0].targetId, uids: [...new Set(body.uids)] };
|
|
94
|
+
}
|
|
95
|
+
async configure(body) {
|
|
96
|
+
const value = await this.validate(body), store = this.getStore();
|
|
97
|
+
const previous = agentConfig(store);
|
|
98
|
+
store.transaction(() => {
|
|
99
|
+
store.setSetting('agentDelivery', value);
|
|
100
|
+
const destinations = deliveryDestinations(value);
|
|
101
|
+
for (const item of activeDestinations(value)) {
|
|
102
|
+
if (!activeDestinations(previous).some(old => old.botId === item.botId && old.targetId === item.targetId)) store.setSetting('deliveryFrom:' + item.botId + ':' + item.targetId, Date.now());
|
|
103
|
+
}
|
|
104
|
+
for (const row of store.db.prepare("SELECT id,bot_id,target_id FROM social_deliveries WHERE status='queued'").all()) {
|
|
105
|
+
if (!destinations.some(item => item.botId === row.bot_id && item.targetId === row.target_id)) store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='接收目标已移除' WHERE id=?").run(row.id);
|
|
106
|
+
}
|
|
107
|
+
for (const platform of ['weibo', 'xueqiu']) if (!Array.isArray(previous.destinations) && store.getSetting('accountAlerted:' + platform, false)) {
|
|
108
|
+
for (const item of deliveryDestinations(previous)) store.setSetting('accountAlerted:' + platform + ':' + item.botId + ':' + item.targetId, true);
|
|
109
|
+
}
|
|
110
|
+
for (const platform of ['weibo', 'xueqiu']) for (const item of destinations) {
|
|
111
|
+
if (!deliveryDestinations(previous).some(old => old.botId === item.botId && old.targetId === item.targetId)) store.setSetting('accountAlerted:' + platform + ':' + item.botId + ':' + item.targetId, false);
|
|
112
|
+
}
|
|
113
|
+
if (value.uids.length) for (const row of store.db.prepare("SELECT id,uid FROM social_deliveries WHERE status='queued' AND message=''").all()) if (!value.uids.includes(row.uid)) store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='此博主已移出推送范围' WHERE id=?").run(row.id);
|
|
114
|
+
})();
|
|
115
|
+
for (const platform of ['weibo', 'xueqiu']) {
|
|
116
|
+
const account = store.account(platform);
|
|
117
|
+
if (value.enabled && account?.uid && ['needs_login', 'needs_verification'].includes(account.status)) store.setAccount({ status: account.status, reason: account.reason }, platform, true);
|
|
118
|
+
}
|
|
119
|
+
if (this.refreshing) await this.refreshing;
|
|
120
|
+
this.connections.checkedAt = 0;
|
|
121
|
+
await this.refreshConnections();
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
async test(body) {
|
|
125
|
+
const value = await this.validate({ enabled: true, botId: body.botId, targetId: body.targetId, uids: body.uids || [] }, { force: true });
|
|
126
|
+
const store = this.getStore(), key = 'imTest:' + value.botId + ':' + value.targetId;
|
|
127
|
+
try {
|
|
128
|
+
const result = await this.getIM().send(value.botId, value.targetId, 'Social Trace 推送连接测试:这是一条由你主动发送的测试消息。', { signal: AbortSignal.timeout(15000) });
|
|
129
|
+
if (result?.sent !== true) throw new Error('dsh-im 未确认接受测试消息');
|
|
130
|
+
store.setSetting(key, { status: 'sent', error: '', created_at: Date.now(), sent_at: Date.now() });
|
|
131
|
+
store.setSetting('imDeliveryAlert:' + value.botId + ':' + value.targetId, false);
|
|
132
|
+
return { sent: true };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
store.setSetting(key, { status: 'uncertain', error: String(error.message || '发送未确认').slice(0, 300), created_at: Date.now() });
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
start() {
|
|
140
|
+
if (this.timer) return;
|
|
141
|
+
this.controller = new AbortController();
|
|
142
|
+
this.startedAt = Date.now();
|
|
143
|
+
const store = this.getStore(), config = agentConfig(store);
|
|
144
|
+
const pending = activeDestinations(config).reduce((total, item) => total + store.db.prepare("SELECT COUNT(*) AS n FROM social_deliveries WHERE status='queued' AND message='' AND bot_id=? AND target_id=? AND created_at<?").get(item.botId, item.targetId, this.startedAt).n, 0);
|
|
145
|
+
if (pending) store.notice('task', '恢复推送:上次运行时已保存的 ' + pending + ' 条动态尚未发送,将继续推送;这不是刚发布的新消息。');
|
|
146
|
+
for (const platform of ['weibo', 'xueqiu']) {
|
|
147
|
+
const account = this.getStore().account(platform);
|
|
148
|
+
if (account?.uid && ['needs_login', 'needs_verification'].includes(account.status)) this.getStore().setAccount({ status: account.status, reason: account.reason }, platform, true);
|
|
149
|
+
}
|
|
150
|
+
this.getStore().db.prepare("UPDATE social_deliveries SET status='uncertain',finished_at=?,error='上次投递时进程中断,结果未知;未自动重发' WHERE status='sending'").run(Date.now());
|
|
151
|
+
this.timer = setInterval(() => { void this.tick(); }, 5000);
|
|
152
|
+
this.timer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
async tick() {
|
|
155
|
+
if (this.busy || this.paused() || this.controller.signal.aborted) return;
|
|
156
|
+
const store = this.getStore(), config = agentConfig(store), im = this.getIM();
|
|
157
|
+
if (!config.enabled || !im?.send) return;
|
|
158
|
+
const destinations = activeDestinations(config);
|
|
159
|
+
if (!destinations.length) return;
|
|
160
|
+
const where = destinations.map(() => '(bot_id=? AND target_id=?)').join(' OR ');
|
|
161
|
+
const row = store.db.prepare("SELECT * FROM social_deliveries WHERE status='queued' AND (" + where + ") AND (? OR message<>'') ORDER BY (message<>'') DESC,created_at,id LIMIT 1").get(...destinations.flatMap(item => [item.botId, item.targetId]), store.getSetting('monitorEnabled', true) === false ? 0 : 1);
|
|
162
|
+
if (!row) return;
|
|
163
|
+
const post = row.message ? null : store.posts({ ids: [row.post_id], limit: 1 })[0];
|
|
164
|
+
if (!post && !row.message) { store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='原文不存在' WHERE id=?").run(row.id); return; }
|
|
165
|
+
store.db.prepare("UPDATE social_deliveries SET status='sending' WHERE id=?").run(row.id);
|
|
166
|
+
this.busy = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const stamp = time => new Date(time).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
169
|
+
const prefix = post?.capture_kind === 'catchup' ? '离线补查通知:恢复运行后补查到的内容,并非刚刚发布。\n' : row.created_at < this.startedAt ? '恢复待发通知:上次运行时已保存,本次继续发送。\n' : '';
|
|
170
|
+
const text = row.message || prefix + `${post.author} · ${post.quote ? '转发' : '新动态'}\n博主发布:${stamp(post.published_at)}\n监控发现:${post.detected_at ? stamp(post.detected_at) : '旧记录未记录'}\n首次保存:${stamp(post.first_seen_at)}\n发起推送:${stamp(Date.now())}(以上均为北京时间)\n\n${post.text.slice(0, 1400)}${post.quote ? '\n\n引用 ' + post.quote.author + ':' + post.quote.text.slice(0, 400) : ''}\n\n原文:${post.url}`;
|
|
171
|
+
const result = await im.send(row.bot_id, row.target_id, text, { signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(15000)]) });
|
|
172
|
+
if (result?.sent !== true) throw new Error('dsh-im 未确认接受消息');
|
|
173
|
+
store.setSetting('imDeliveryAlert:' + row.bot_id + ':' + row.target_id, false);
|
|
174
|
+
const finishedAt = Date.now();
|
|
175
|
+
store.db.prepare("UPDATE social_deliveries SET status='sent',sent_at=?,finished_at=?,error='' WHERE id=?").run(finishedAt, finishedAt, row.id);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const status = ['unknown-bot', 'unknown-target', 'invalid-target', 'target-rejected', 'bot-not-connected'].includes(error.code) ? 'failed' : 'uncertain';
|
|
178
|
+
store.db.prepare('UPDATE social_deliveries SET status=?,finished_at=?,error=? WHERE id=?').run(status, Date.now(), String(error.message || '投递失败').slice(0, 300), row.id);
|
|
179
|
+
const alertKey = 'imDeliveryAlert:' + row.bot_id + ':' + row.target_id;
|
|
180
|
+
if (!store.getSetting(alertKey, false)) {
|
|
181
|
+
const destination = deliveryDestinations(config).find(item => item.botId === row.bot_id && item.targetId === row.target_id);
|
|
182
|
+
const channel = destination?.channel || this.connections.bots.find(bot => bot.botId === row.bot_id)?.channel || 'IM';
|
|
183
|
+
const recipient = '\n接收渠道:' + ({ weixin: '微信', qq: 'QQ' }[channel] || channel) + ' · ' + (destination?.name || row.target_id);
|
|
184
|
+
store.notice('attention', error.code === 'delivery-failed' || error.message === 'delivery-failed'
|
|
185
|
+
? 'dsh-im 插件发送失败:请检查 dsh-im 的机器人连接和接收目标。消息已保存在本地,未确认是否送达;详见推送记录。' + recipient
|
|
186
|
+
: 'dsh-im 推送未确认:请检查机器人连接与接收目标,详见推送记录。消息已保存在本地。' + recipient, Date.now());
|
|
187
|
+
store.setSetting(alertKey, true);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
try { await this.busy; } finally { this.busy = null; }
|
|
192
|
+
}
|
|
193
|
+
async close() {
|
|
194
|
+
clearInterval(this.timer); this.timer = null;
|
|
195
|
+
this.controller.abort();
|
|
196
|
+
if (this.busy) await this.busy;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/lib/avatars.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { imageReferer } from './text.js';
|
|
6
|
+
|
|
7
|
+
const types = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
8
|
+
const limit = 2 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export class Avatars {
|
|
11
|
+
constructor(directory, fetchImage = fetch) {
|
|
12
|
+
this.directory = directory;
|
|
13
|
+
this.fetchImage = fetchImage;
|
|
14
|
+
this.pending = new Map();
|
|
15
|
+
this.controller = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(image) {
|
|
19
|
+
if (!this.pending.has(image)) {
|
|
20
|
+
const task = this.load(image).finally(() => this.pending.delete(image));
|
|
21
|
+
this.pending.set(image, task);
|
|
22
|
+
}
|
|
23
|
+
return this.pending.get(image);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async load(image) {
|
|
27
|
+
const file = join(this.directory, createHash('sha256').update(image).digest('hex') + '.avatar');
|
|
28
|
+
try {
|
|
29
|
+
const cached = await readFile(file);
|
|
30
|
+
const end = cached.indexOf(10);
|
|
31
|
+
const type = cached.subarray(0, end).toString();
|
|
32
|
+
if (end > 0 && types.has(type) && cached.length > end + 1 && cached.length <= limit + end + 1) return { type, bytes: cached.subarray(end + 1) };
|
|
33
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
34
|
+
const response = await this.fetchImage(image, { headers: { Referer: imageReferer(image) }, redirect: 'error',
|
|
35
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]) });
|
|
36
|
+
const type = response.headers.get('content-type')?.split(';')[0];
|
|
37
|
+
if (!response.ok || !types.has(type) || Number(response.headers.get('content-length')) > limit) {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
throw new Error('头像暂时无法读取');
|
|
40
|
+
}
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let length = 0;
|
|
43
|
+
for await (const chunk of response.body) {
|
|
44
|
+
length += chunk.length;
|
|
45
|
+
if (length > limit) throw new Error('头像文件过大');
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
if (!length) throw new Error('头像内容为空');
|
|
49
|
+
const bytes = Buffer.concat(chunks, length);
|
|
50
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(temporary, Buffer.concat([Buffer.from(type + '\n'), bytes]), { mode: 0o600 });
|
|
54
|
+
await rename(temporary, file);
|
|
55
|
+
} finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
56
|
+
return { type, bytes };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async close() {
|
|
60
|
+
this.controller.abort();
|
|
61
|
+
await Promise.allSettled(this.pending.values());
|
|
62
|
+
}
|
|
63
|
+
}
|