@anyul/koishi-plugin-rss 5.0.2 → 5.0.4
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 +76 -13
- package/lib/core/feeder-old.d.ts +15 -0
- package/lib/core/feeder-old.js +403 -0
- package/lib/core/feeder.d.ts +6 -1
- package/lib/core/feeder.js +114 -96
- package/lib/core/notification-queue.d.ts +119 -0
- package/lib/core/notification-queue.js +352 -0
- package/lib/core/renderer.js +2 -1
- package/lib/database.js +18 -0
- package/lib/index.js +397 -79
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/utils/template.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 消息发送队列管理器
|
|
4
|
+
* 实现可靠的消息推送,支持重试、降级和错误处理
|
|
5
|
+
*/
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.NotificationQueueManager = void 0;
|
|
41
|
+
const logger_1 = require("../utils/logger");
|
|
42
|
+
/**
|
|
43
|
+
* 消息发送队列管理器
|
|
44
|
+
*/
|
|
45
|
+
class NotificationQueueManager {
|
|
46
|
+
ctx;
|
|
47
|
+
config;
|
|
48
|
+
processing = false;
|
|
49
|
+
maxRetries = 5;
|
|
50
|
+
batchSize = 10;
|
|
51
|
+
// 指数退避时间(秒):10s, 30s, 1m, 5m, 10m
|
|
52
|
+
backoffDelays = [10, 30, 60, 300, 600];
|
|
53
|
+
constructor(ctx, config) {
|
|
54
|
+
this.ctx = ctx;
|
|
55
|
+
this.config = config;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 添加任务到队列
|
|
59
|
+
*/
|
|
60
|
+
async addTask(task) {
|
|
61
|
+
const queueTask = {
|
|
62
|
+
...task,
|
|
63
|
+
status: 'PENDING',
|
|
64
|
+
retryCount: 0,
|
|
65
|
+
createdAt: new Date(),
|
|
66
|
+
updatedAt: new Date()
|
|
67
|
+
};
|
|
68
|
+
await this.ctx.database.create('rss_notification_queue', queueTask);
|
|
69
|
+
(0, logger_1.debug)(this.config, `任务已加入队列: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
|
|
70
|
+
return queueTask;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 处理队列中的任务
|
|
74
|
+
*/
|
|
75
|
+
async processQueue() {
|
|
76
|
+
if (this.processing) {
|
|
77
|
+
(0, logger_1.debug)(this.config, '队列正在处理中,跳过本次', 'queue', 'details');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.processing = true;
|
|
81
|
+
try {
|
|
82
|
+
// 1. 查找待处理任务
|
|
83
|
+
const tasks = await this.getPendingTasks();
|
|
84
|
+
if (tasks.length === 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
(0, logger_1.debug)(this.config, `开始处理 ${tasks.length} 个待发送任务`, 'queue', 'info');
|
|
88
|
+
// 2. 逐个处理任务
|
|
89
|
+
for (const task of tasks) {
|
|
90
|
+
await this.processTask(task);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
(0, logger_1.debug)(this.config, `队列处理异常: ${err.message}`, 'queue', 'error');
|
|
95
|
+
console.error('Queue processing error:', err);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.processing = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 获取待处理任务
|
|
103
|
+
*/
|
|
104
|
+
async getPendingTasks() {
|
|
105
|
+
const now = new Date();
|
|
106
|
+
// 获取所有 PENDING 状态的任务
|
|
107
|
+
const pendingTasks = await this.ctx.database.get('rss_notification_queue', { status: 'PENDING' }, { limit: this.batchSize });
|
|
108
|
+
// 获取到达重试时间的 RETRY 状态任务
|
|
109
|
+
const retryTasks = await this.ctx.database.get('rss_notification_queue', { status: 'RETRY' }, { limit: this.batchSize });
|
|
110
|
+
// 过滤出到达重试时间的任务
|
|
111
|
+
const readyRetryTasks = retryTasks.filter(task => task.nextRetryTime && new Date(task.nextRetryTime) <= now);
|
|
112
|
+
// 合并并按创建时间排序
|
|
113
|
+
return [...pendingTasks, ...readyRetryTasks]
|
|
114
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
115
|
+
.slice(0, this.batchSize);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 处理单个任务
|
|
119
|
+
*/
|
|
120
|
+
async processTask(task) {
|
|
121
|
+
(0, logger_1.debug)(this.config, `处理任务 [${task.rssId}] ${task.content.title} (重试${task.retryCount}次)`, 'queue', 'details');
|
|
122
|
+
try {
|
|
123
|
+
// 尝试发送消息
|
|
124
|
+
await this.sendMessage(task);
|
|
125
|
+
// 发送成功:标记为 SUCCESS
|
|
126
|
+
await this.markTaskSuccess(task.id);
|
|
127
|
+
(0, logger_1.debug)(this.config, `✓ 任务发送成功: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
|
|
128
|
+
// 写入缓存
|
|
129
|
+
await this.cacheMessage(task);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// 进入错误处理流程
|
|
133
|
+
await this.handleSendError(task, error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 发送消息(带降级机制)
|
|
138
|
+
*/
|
|
139
|
+
async sendMessage(task) {
|
|
140
|
+
const { guildId, platform, content } = task;
|
|
141
|
+
const target = `${platform}:${guildId}`;
|
|
142
|
+
try {
|
|
143
|
+
// 第一次尝试:发送原始消息
|
|
144
|
+
await this.ctx.broadcast([target], content.message);
|
|
145
|
+
(0, logger_1.debug)(this.config, `消息发送成功: ${target}`, 'queue', 'details');
|
|
146
|
+
}
|
|
147
|
+
catch (sendError) {
|
|
148
|
+
// OneBot retcode 1200: 不支持的消息格式(通常是视频)
|
|
149
|
+
const isOneBot1200 = sendError.code?.toString?.() === '1200' || sendError.message?.includes('1200');
|
|
150
|
+
if (isOneBot1200 && !content.isDowngraded) {
|
|
151
|
+
(0, logger_1.debug)(this.config, `检测到 OneBot 1200 错误,尝试降级处理`, 'queue', 'info');
|
|
152
|
+
throw { ...sendError, isMediaError: true, requiresDowngrade: true };
|
|
153
|
+
}
|
|
154
|
+
throw sendError;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 处理发送错误
|
|
159
|
+
*/
|
|
160
|
+
async handleSendError(task, error) {
|
|
161
|
+
const errorMsg = error.message || 'Unknown error';
|
|
162
|
+
// 1. 永久性错误 (Fatal) - 不需要重试
|
|
163
|
+
if (this.isFatalError(error)) {
|
|
164
|
+
await this.markTaskFailed(task.id, errorMsg);
|
|
165
|
+
(0, logger_1.debug)(this.config, `✗ 永久性失败,放弃重试: [${task.rssId}] ${task.content.title} - ${errorMsg}`, 'queue', 'error');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// 2. 降级重试 (Downgrade) - 针对媒体格式错误
|
|
169
|
+
if (error.requiresDowngrade && !task.content.isDowngraded) {
|
|
170
|
+
const downgradedContent = await this.downgradeMessage(task.content);
|
|
171
|
+
await this.updateTaskForDowngrade(task.id, downgradedContent);
|
|
172
|
+
(0, logger_1.debug)(this.config, `→ 消息已降级,立即重试: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// 3. 暂时性错误 (Transient) - 使用指数退避
|
|
176
|
+
const delay = this.backoffDelays[task.retryCount] || this.backoffDelays[this.backoffDelays.length - 1];
|
|
177
|
+
const nextTime = new Date(Date.now() + delay * 1000);
|
|
178
|
+
await this.markTaskRetry(task.id, nextTime, errorMsg);
|
|
179
|
+
(0, logger_1.debug)(this.config, `→ 任务将在 ${Math.ceil(delay / 60)} 分钟后重试: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 判断是否为永久性错误
|
|
183
|
+
*/
|
|
184
|
+
isFatalError(error) {
|
|
185
|
+
const errorCode = error.code || error.retcode;
|
|
186
|
+
// 群组不存在 / 账号不在群内
|
|
187
|
+
if (errorCode === 'UnknownGroup' || errorCode === 'GROUP_NOT_FOUND') {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// 账号被封禁 / 被拉黑
|
|
191
|
+
if (errorCode === 'UserBlock' || errorCode === 'BANNED') {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
// 权限不足
|
|
195
|
+
if (errorCode === 'PermissionDenied' || errorCode === 'NO_PERMISSION') {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// 超过最大重试次数
|
|
199
|
+
// 这个判断在调用处处理
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 降级消息(移除媒体元素)
|
|
204
|
+
*/
|
|
205
|
+
async downgradeMessage(content) {
|
|
206
|
+
// 移除 video 元素,保留视频链接
|
|
207
|
+
let downgradedMessage = content.message.replace(/<video[^>]*>.*?<\/video>/gis, (match) => {
|
|
208
|
+
const srcMatch = match.match(/src=["']([^"']+)["']/);
|
|
209
|
+
if (srcMatch) {
|
|
210
|
+
return `\n🎬 视频: ${srcMatch[1]}\n`;
|
|
211
|
+
}
|
|
212
|
+
return '\n[视频不支持]\n';
|
|
213
|
+
});
|
|
214
|
+
// 移除 img 元素,保留图片链接(可选)
|
|
215
|
+
// downgradedMessage = downgradedMessage.replace(/<img[^>]*>/gis, (match: string) => {
|
|
216
|
+
// const srcMatch = match.match(/src=["']([^"']+)["']/)
|
|
217
|
+
// if (srcMatch) {
|
|
218
|
+
// return `\n🖼️ 图片: ${srcMatch[1]}\n`
|
|
219
|
+
// }
|
|
220
|
+
// return '\n[图片不支持]\n'
|
|
221
|
+
// })
|
|
222
|
+
return {
|
|
223
|
+
...content,
|
|
224
|
+
message: downgradedMessage,
|
|
225
|
+
isDowngraded: true
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 标记任务为成功
|
|
230
|
+
*/
|
|
231
|
+
async markTaskSuccess(taskId) {
|
|
232
|
+
await this.ctx.database.set('rss_notification_queue', { id: taskId }, {
|
|
233
|
+
status: 'SUCCESS',
|
|
234
|
+
updatedAt: new Date()
|
|
235
|
+
});
|
|
236
|
+
// 可选:定期清理成功任务,避免数据库膨胀
|
|
237
|
+
// await this.ctx.database.remove(('rss_notification_queue' as any), { id: taskId })
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 标记任务为重试
|
|
241
|
+
*/
|
|
242
|
+
async markTaskRetry(taskId, nextTime, reason) {
|
|
243
|
+
await this.ctx.database.set('rss_notification_queue', { id: taskId }, {
|
|
244
|
+
status: 'RETRY',
|
|
245
|
+
nextRetryTime: nextTime,
|
|
246
|
+
retryCount: this.ctx.database.get('rss_notification_queue', { id: taskId }).then((tasks) => {
|
|
247
|
+
return (tasks[0]?.retryCount || 0) + 1;
|
|
248
|
+
}),
|
|
249
|
+
failReason: reason,
|
|
250
|
+
updatedAt: new Date()
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 更新任务为降级重试
|
|
255
|
+
*/
|
|
256
|
+
async updateTaskForDowngrade(taskId, newContent) {
|
|
257
|
+
// 获取当前任务
|
|
258
|
+
const tasks = await this.ctx.database.get('rss_notification_queue', { id: taskId });
|
|
259
|
+
if (tasks.length === 0)
|
|
260
|
+
return;
|
|
261
|
+
const currentTask = tasks[0];
|
|
262
|
+
await this.ctx.database.set('rss_notification_queue', { id: taskId }, {
|
|
263
|
+
content: newContent,
|
|
264
|
+
status: 'RETRY',
|
|
265
|
+
nextRetryTime: new Date(), // 立即重试
|
|
266
|
+
retryCount: currentTask.retryCount + 1,
|
|
267
|
+
updatedAt: new Date()
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 标记任务为失败
|
|
272
|
+
*/
|
|
273
|
+
async markTaskFailed(taskId, reason) {
|
|
274
|
+
await this.ctx.database.set('rss_notification_queue', { id: taskId }, {
|
|
275
|
+
status: 'FAILED',
|
|
276
|
+
failReason: reason,
|
|
277
|
+
updatedAt: new Date()
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 缓存成功发送的消息
|
|
282
|
+
*/
|
|
283
|
+
async cacheMessage(task) {
|
|
284
|
+
if (!this.config.cache?.enabled) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const { getMessageCache } = await Promise.resolve().then(() => __importStar(require('../utils/message-cache')));
|
|
288
|
+
const cache = getMessageCache();
|
|
289
|
+
if (!cache) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
await cache.addMessage({
|
|
294
|
+
rssId: task.rssId,
|
|
295
|
+
guildId: task.guildId,
|
|
296
|
+
platform: task.platform,
|
|
297
|
+
title: task.content.title || '',
|
|
298
|
+
content: task.content.description || '',
|
|
299
|
+
link: task.content.link || '',
|
|
300
|
+
pubDate: task.content.pubDate || new Date(),
|
|
301
|
+
imageUrl: task.content.imageUrl || '',
|
|
302
|
+
videoUrl: '',
|
|
303
|
+
finalMessage: task.content.message
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
(0, logger_1.debug)(this.config, `缓存消息失败: ${err.message}`, 'cache', 'info');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* 获取队列统计信息
|
|
312
|
+
*/
|
|
313
|
+
async getStats() {
|
|
314
|
+
const allTasks = await this.ctx.database.get('rss_notification_queue', {});
|
|
315
|
+
return {
|
|
316
|
+
pending: allTasks.filter((t) => t.status === 'PENDING').length,
|
|
317
|
+
retry: allTasks.filter((t) => t.status === 'RETRY').length,
|
|
318
|
+
failed: allTasks.filter((t) => t.status === 'FAILED').length,
|
|
319
|
+
success: allTasks.filter((t) => t.status === 'SUCCESS').length
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 重试失败的任务
|
|
324
|
+
*/
|
|
325
|
+
async retryFailedTasks(taskId) {
|
|
326
|
+
const where = taskId ? { id: taskId } : { status: 'FAILED' };
|
|
327
|
+
const tasks = await this.ctx.database.get('rss_notification_queue', where);
|
|
328
|
+
for (const task of tasks) {
|
|
329
|
+
await this.ctx.database.set('rss_notification_queue', { id: task.id }, {
|
|
330
|
+
status: 'PENDING',
|
|
331
|
+
retryCount: 0,
|
|
332
|
+
failReason: null,
|
|
333
|
+
updatedAt: new Date()
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
(0, logger_1.debug)(this.config, `已重置 ${tasks.length} 个失败任务为 PENDING 状态`, 'queue', 'info');
|
|
337
|
+
return tasks.length;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* 清理旧的成功任务
|
|
341
|
+
*/
|
|
342
|
+
async cleanupSuccessTasks(olderThanHours = 24) {
|
|
343
|
+
const cutoffTime = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
|
344
|
+
const tasks = await this.ctx.database.get('rss_notification_queue', { status: 'SUCCESS', updatedAt: { $lt: cutoffTime } });
|
|
345
|
+
for (const task of tasks) {
|
|
346
|
+
await this.ctx.database.remove('rss_notification_queue', { id: task.id });
|
|
347
|
+
}
|
|
348
|
+
(0, logger_1.debug)(this.config, `已清理 ${tasks.length} 个旧的成功任务`, 'queue', 'info');
|
|
349
|
+
return tasks.length;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
exports.NotificationQueueManager = NotificationQueueManager;
|
package/lib/core/renderer.js
CHANGED
|
@@ -148,7 +148,8 @@ async function renderHtml2Image(ctx, config, $http, htmlContent, arg) {
|
|
|
148
148
|
// 使用 domcontentloaded 避免等待视频等慢速资源
|
|
149
149
|
await page.setContent(htmlContent, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
150
150
|
if (!config.basic.autoSplitImage) {
|
|
151
|
-
|
|
151
|
+
// 使用 fullPage: true 让截图高度自适应实际内容
|
|
152
|
+
const image = await page.screenshot({ type: "png", fullPage: true });
|
|
152
153
|
// assets 模式
|
|
153
154
|
if (config.basic.imageMode === 'assets' && ctx.assets) {
|
|
154
155
|
try {
|
package/lib/database.js
CHANGED
|
@@ -34,4 +34,22 @@ function setupDatabase(ctx) {
|
|
|
34
34
|
}, {
|
|
35
35
|
autoInc: true
|
|
36
36
|
});
|
|
37
|
+
// 消息发送队列表 - 用于可靠的消息推送
|
|
38
|
+
ctx.model.extend('rss_notification_queue', {
|
|
39
|
+
id: "integer",
|
|
40
|
+
subscribeId: "string", // 关联的订阅ID(rssOwl表的id)
|
|
41
|
+
rssId: "string", // 订阅源标识(用于显示)
|
|
42
|
+
uid: "string", // 消息唯一标识 (guid 或 link)
|
|
43
|
+
guildId: "string", // 目标群组
|
|
44
|
+
platform: "string", // 目标平台
|
|
45
|
+
content: "json", // 序列化后的消息内容
|
|
46
|
+
status: "string", // 状态: PENDING | RETRY | FAILED | SUCCESS
|
|
47
|
+
retryCount: "integer", // 当前重试次数 (默认0)
|
|
48
|
+
nextRetryTime: "timestamp", // 下次重试时间
|
|
49
|
+
createdAt: "timestamp", // 创建时间
|
|
50
|
+
updatedAt: "timestamp", // 最后更新时间
|
|
51
|
+
failReason: "text", // 失败原因
|
|
52
|
+
}, {
|
|
53
|
+
autoInc: true
|
|
54
|
+
});
|
|
37
55
|
}
|