@anyul/koishi-plugin-rss 5.2.2 → 5.2.3

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.
Files changed (38) hide show
  1. package/lib/commands/error-handler.js +2 -5
  2. package/lib/commands/index.d.ts +17 -1
  3. package/lib/commands/index.js +388 -2
  4. package/lib/commands/subscription-edit.d.ts +7 -0
  5. package/lib/commands/subscription-edit.js +177 -0
  6. package/lib/commands/subscription-management.d.ts +12 -0
  7. package/lib/commands/subscription-management.js +176 -0
  8. package/lib/commands/utils.d.ts +13 -1
  9. package/lib/commands/utils.js +43 -2
  10. package/lib/config.js +19 -0
  11. package/lib/core/ai.d.ts +16 -2
  12. package/lib/core/ai.js +73 -6
  13. package/lib/core/feeder.d.ts +1 -1
  14. package/lib/core/feeder.js +238 -125
  15. package/lib/core/item-processor.d.ts +5 -0
  16. package/lib/core/item-processor.js +66 -136
  17. package/lib/core/notification-queue.d.ts +2 -0
  18. package/lib/core/notification-queue.js +80 -33
  19. package/lib/core/parser.js +12 -0
  20. package/lib/core/renderer.d.ts +15 -0
  21. package/lib/core/renderer.js +91 -23
  22. package/lib/index.js +28 -784
  23. package/lib/tsconfig.tsbuildinfo +1 -1
  24. package/lib/types.d.ts +24 -0
  25. package/lib/utils/common.js +52 -3
  26. package/lib/utils/error-handler.d.ts +8 -0
  27. package/lib/utils/error-handler.js +27 -0
  28. package/lib/utils/error-tracker.js +24 -8
  29. package/lib/utils/fetcher.js +68 -9
  30. package/lib/utils/logger.d.ts +4 -2
  31. package/lib/utils/logger.js +144 -6
  32. package/lib/utils/media.js +3 -6
  33. package/lib/utils/sanitizer.d.ts +58 -0
  34. package/lib/utils/sanitizer.js +227 -0
  35. package/lib/utils/security.d.ts +75 -0
  36. package/lib/utils/security.js +312 -0
  37. package/lib/utils/structured-logger.js +3 -20
  38. package/package.json +2 -1
@@ -43,6 +43,8 @@ const renderer_1 = require("./renderer");
43
43
  const template_1 = require("../utils/template");
44
44
  const ai_1 = require("./ai");
45
45
  const marked_1 = require("marked");
46
+ const sanitizer_1 = require("../utils/sanitizer");
47
+ const security_1 = require("../utils/security");
46
48
  class RssItemProcessor {
47
49
  ctx;
48
50
  config;
@@ -59,6 +61,11 @@ class RssItemProcessor {
59
61
  let html;
60
62
  let videoList = [];
61
63
  item.description = item.description?.join?.('') || item.description;
64
+ // HTML 安全清理
65
+ const sanitizer = (0, sanitizer_1.createSanitizer)(this.config);
66
+ if (sanitizer.isEnabled() && item.description) {
67
+ item.description = sanitizer.sanitize(item.description);
68
+ }
62
69
  // --- AI 逻辑 START ---
63
70
  let aiSummary = "";
64
71
  let formattedAiSummary = "";
@@ -205,41 +212,7 @@ class RssItemProcessor {
205
212
  await Promise.all(html('img').map(async (v, i) => i.attribs.src = await (0, media_1.getImageUrl)(this.ctx, this.config, this.$http, i.attribs.src, arg, true)).get());
206
213
  }
207
214
  html('img').attr('style', 'object-fit:scale-down;max-width:100%;');
208
- let msg = "";
209
- const imageMode = this.config.basic?.imageMode;
210
- if (imageMode == 'base64') {
211
- (0, logger_1.debug)(this.config, '使用 base64 模式渲染', 'render mode', 'info');
212
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, html.html(), arg)).toString();
213
- }
214
- else if (imageMode == 'File' || imageMode == 'assets') {
215
- if (!this.ctx.puppeteer) {
216
- (0, logger_1.debug)(this.config, '未安装 puppeteer 插件,跳过图片渲染', 'puppeteer error', 'error');
217
- msg = html.html();
218
- }
219
- else {
220
- try {
221
- (0, logger_1.debug)(this.config, `使用 ${imageMode} 模式渲染`, 'render mode', 'info');
222
- let processedHtml = await (0, renderer_1.preprocessHtmlImages)(this.ctx, this.config, this.$http, html.html(), arg);
223
- if ((this.config.template?.deviceScaleFactor ?? 1) !== 1) {
224
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, processedHtml, arg)).toString();
225
- }
226
- else {
227
- msg = await this.ctx.puppeteer.render(processedHtml);
228
- }
229
- msg = await (0, media_1.puppeteerToFile)(this.ctx, this.config, msg);
230
- (0, logger_1.debug)(this.config, 'puppeteer 渲染完成', 'render success', 'info');
231
- }
232
- catch (error) {
233
- (0, logger_1.debug)(this.config, `puppeteer render 失败: ${error}`, 'puppeteer error', 'error');
234
- msg = html.html();
235
- }
236
- }
237
- }
238
- else {
239
- // 未知 imageMode,回退到 HTML
240
- (0, logger_1.debug)(this.config, `未知的 imageMode: ${imageMode},回退到 HTML`, 'render warning', 'error');
241
- msg = html.html();
242
- }
215
+ let msg = await this.renderImage(html.html(), arg);
243
216
  return parseContent(this.config.template?.customRemark || '', { ...item, arg, description: msg });
244
217
  }
245
218
  async processContentTemplate(item, arg, html, parseContent) {
@@ -300,42 +273,7 @@ class RssItemProcessor {
300
273
  }
301
274
  html('img').attr('style', 'object-fit:scale-down;max-width:100%;');
302
275
  (0, logger_1.debug)(this.config, `当前 imageMode: ${this.config.basic?.imageMode}`, 'imageMode', 'info');
303
- let msg = "";
304
- const imageMode = this.config.basic?.imageMode;
305
- if (imageMode == 'base64') {
306
- (0, logger_1.debug)(this.config, '使用 base64 模式渲染', 'render mode', 'info');
307
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, html.html(), arg)).toString();
308
- }
309
- else if (imageMode == 'File' || imageMode == 'assets') {
310
- if (!this.ctx.puppeteer) {
311
- (0, logger_1.debug)(this.config, '未安装 puppeteer 插件,跳过图片渲染', 'puppeteer error', 'error');
312
- msg = html.html();
313
- }
314
- else {
315
- try {
316
- (0, logger_1.debug)(this.config, `使用 ${imageMode} 模式渲染`, 'render mode', 'info');
317
- let processedHtml = await (0, renderer_1.preprocessHtmlImages)(this.ctx, this.config, this.$http, html.html(), arg);
318
- if ((this.config.template?.deviceScaleFactor ?? 1) !== 1) {
319
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, processedHtml, arg)).toString();
320
- }
321
- else {
322
- msg = await this.ctx.puppeteer.render(processedHtml);
323
- }
324
- (0, logger_1.debug)(this.config, `puppeteer.render() 返回: ${msg.substring(0, 100)}...`, 'puppeteer result', 'info');
325
- msg = await (0, media_1.puppeteerToFile)(this.ctx, this.config, msg);
326
- (0, logger_1.debug)(this.config, `puppeteerToFile 转换完成`, 'puppeteer', 'info');
327
- }
328
- catch (error) {
329
- (0, logger_1.debug)(this.config, `puppeteer render 失败: ${error}`, 'puppeteer error', 'error');
330
- msg = html.html();
331
- }
332
- }
333
- }
334
- else {
335
- // 未知 imageMode,回退到 HTML
336
- (0, logger_1.debug)(this.config, `未知的 imageMode: ${imageMode},回退到 HTML`, 'render warning', 'error');
337
- msg = html.html();
338
- }
276
+ let msg = await this.renderImage(html.html(), arg);
339
277
  return msg;
340
278
  }
341
279
  async processOnlyDescriptionTemplate(item, arg, html, parseContent) {
@@ -369,82 +307,34 @@ class RssItemProcessor {
369
307
  await Promise.all(html('img').map(async (v, i) => i.attribs.src = await (0, media_1.getImageUrl)(this.ctx, this.config, this.$http, i.attribs.src, arg, true)).get());
370
308
  }
371
309
  html('img').attr('style', 'object-fit:scale-down;max-width:100%;');
372
- let msg = "";
373
- const imageMode = this.config.basic?.imageMode;
374
- if (imageMode == 'base64') {
375
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, html.html(), arg)).toString();
376
- }
377
- else if (imageMode == 'File' || imageMode == 'assets') {
378
- if (!this.ctx.puppeteer) {
379
- (0, logger_1.debug)(this.config, '未安装 puppeteer 插件,跳过图片渲染', 'puppeteer error', 'error');
380
- msg = html.html();
381
- }
382
- else {
383
- try {
384
- let processedHtml = await (0, renderer_1.preprocessHtmlImages)(this.ctx, this.config, this.$http, html.html(), arg);
385
- if ((this.config.template?.deviceScaleFactor ?? 1) !== 1) {
386
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, processedHtml, arg)).toString();
387
- }
388
- else {
389
- msg = await this.ctx.puppeteer.render(processedHtml);
390
- }
391
- msg = await (0, media_1.puppeteerToFile)(this.ctx, this.config, msg);
392
- }
393
- catch (error) {
394
- (0, logger_1.debug)(this.config, `puppeteer render 失败: ${error}`, 'puppeteer error', 'error');
395
- msg = html.html();
396
- }
397
- }
398
- }
399
- else {
400
- // 未知 imageMode,回退到 HTML
401
- (0, logger_1.debug)(this.config, `未知的 imageMode: ${imageMode},回退到 HTML`, 'render warning', 'error');
402
- msg = html.html();
403
- }
310
+ let msg = await this.renderImage(html.html(), arg);
404
311
  return msg;
405
312
  }
406
313
  async processLinkTemplate(item, arg) {
407
314
  let html = cheerio.load(item.description);
408
315
  let src = html('a')[0].attribs.href;
409
316
  (0, logger_1.debug)(this.config, src, 'link src', 'info');
317
+ // URL 安全验证
318
+ try {
319
+ (0, security_1.validateUrlOrThrow)(src, (0, security_1.getSecurityOptions)(this.config));
320
+ }
321
+ catch (error) {
322
+ if (error instanceof security_1.SecurityError) {
323
+ (0, logger_1.debug)(this.config, `链接 URL 安全验证失败: ${error.message}`, 'security', 'error');
324
+ return `链接安全验证失败: ${error.message}`;
325
+ }
326
+ throw error;
327
+ }
410
328
  let html2 = cheerio.load((await this.$http(src, arg)).data);
411
329
  if (arg?.proxyAgent?.enabled) {
412
330
  await Promise.all(html2('img').map(async (v, i) => i.attribs.src = await (0, media_1.getImageUrl)(this.ctx, this.config, this.$http, i.attribs.src, arg, true)).get());
413
331
  }
414
332
  html2('img').attr('style', 'object-fit:scale-down;max-width:100%;');
415
- html2('body').attr('style', `width:${this.config.template?.bodyWidth || 600}px;padding:${this.config.template?.bodyPadding || 20}px;`);
416
- let msg = "";
417
- const imageMode = this.config.basic?.imageMode;
418
- if (imageMode == 'base64') {
419
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, html2.xml(), arg)).toString();
420
- }
421
- else if (imageMode == 'File' || imageMode == 'assets') {
422
- if (!this.ctx.puppeteer) {
423
- (0, logger_1.debug)(this.config, '未安装 puppeteer 插件,跳过图片渲染', 'puppeteer error', 'error');
424
- msg = html2.xml();
425
- }
426
- else {
427
- try {
428
- let processedHtml = await (0, renderer_1.preprocessHtmlImages)(this.ctx, this.config, this.$http, html2.xml(), arg);
429
- if ((this.config.template?.deviceScaleFactor ?? 1) !== 1) {
430
- msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, processedHtml, arg)).toString();
431
- }
432
- else {
433
- msg = await this.ctx.puppeteer.render(processedHtml);
434
- }
435
- msg = await (0, media_1.puppeteerToFile)(this.ctx, this.config, msg);
436
- }
437
- catch (error) {
438
- (0, logger_1.debug)(this.config, `puppeteer render 失败: ${error}`, 'puppeteer error', 'error');
439
- msg = html2.xml();
440
- }
441
- }
442
- }
443
- else {
444
- // 未知 imageMode,回退到 HTML
445
- (0, logger_1.debug)(this.config, `未知的 imageMode: ${imageMode},回退到 HTML`, 'render warning', 'error');
446
- msg = html2.xml();
447
- }
333
+ // link 模板使用订阅级参数设置 body 样式
334
+ const bodyWidth = arg?.bodyWidth ?? this.config.template?.bodyWidth ?? 600;
335
+ const bodyPadding = arg?.bodyPadding ?? this.config.template?.bodyPadding ?? 20;
336
+ html2('body').attr('style', `width:${bodyWidth}px;padding:${bodyPadding}px;`);
337
+ let msg = await this.renderImage(html2.xml(), arg);
448
338
  return msg;
449
339
  }
450
340
  async processVideos(html, arg, videoList) {
@@ -464,5 +354,45 @@ class RssItemProcessor {
464
354
  return (0, koishi_1.h)('video', { src, poster });
465
355
  }).join('');
466
356
  }
357
+ /**
358
+ * 统一的图片渲染方法
359
+ * 提取了 custom、default、only description、link 模板中重复的图片渲染逻辑
360
+ */
361
+ async renderImage(htmlContent, arg) {
362
+ const imageMode = this.config.basic?.imageMode;
363
+ // base64 模式
364
+ if (imageMode === 'base64') {
365
+ (0, logger_1.debug)(this.config, '使用 base64 模式渲染', 'render mode', 'info');
366
+ return (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, htmlContent, arg)).toString();
367
+ }
368
+ // File 或 assets 模式
369
+ if (imageMode === 'File' || imageMode === 'assets') {
370
+ if (!this.ctx.puppeteer) {
371
+ (0, logger_1.debug)(this.config, '未安装 puppeteer 插件,跳过图片渲染', 'puppeteer error', 'error');
372
+ return htmlContent;
373
+ }
374
+ try {
375
+ (0, logger_1.debug)(this.config, `使用 ${imageMode} 模式渲染`, 'render mode', 'info');
376
+ const processedHtml = await (0, renderer_1.preprocessHtmlImages)(this.ctx, this.config, this.$http, htmlContent, arg);
377
+ let msg;
378
+ if ((this.config.template?.deviceScaleFactor ?? 1) !== 1) {
379
+ msg = (await (0, renderer_1.renderHtml2Image)(this.ctx, this.config, this.$http, processedHtml, arg)).toString();
380
+ }
381
+ else {
382
+ msg = await this.ctx.puppeteer.render(processedHtml);
383
+ }
384
+ msg = await (0, media_1.puppeteerToFile)(this.ctx, this.config, msg);
385
+ (0, logger_1.debug)(this.config, 'puppeteer 渲染完成', 'render success', 'info');
386
+ return msg;
387
+ }
388
+ catch (error) {
389
+ (0, logger_1.debug)(this.config, `puppeteer render 失败: ${error}`, 'puppeteer error', 'error');
390
+ return htmlContent;
391
+ }
392
+ }
393
+ // 未知模式,回退到 HTML
394
+ (0, logger_1.debug)(this.config, `未知的 imageMode: ${imageMode},回退到 HTML`, 'render warning', 'error');
395
+ return htmlContent;
396
+ }
467
397
  }
468
398
  exports.RssItemProcessor = RssItemProcessor;
@@ -47,6 +47,8 @@ export declare class NotificationQueueManager {
47
47
  private batchSize;
48
48
  private backoffDelays;
49
49
  constructor(ctx: Context, config: Config);
50
+ private buildTaskLogContext;
51
+ private createTaskDebug;
50
52
  /**
51
53
  * 添加任务到队列
52
54
  */
@@ -38,6 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.NotificationQueueManager = void 0;
41
+ const error_handler_1 = require("../utils/error-handler");
42
+ const error_tracker_1 = require("../utils/error-tracker");
41
43
  const logger_1 = require("../utils/logger");
42
44
  /**
43
45
  * 消息发送队列管理器
@@ -54,6 +56,26 @@ class NotificationQueueManager {
54
56
  this.ctx = ctx;
55
57
  this.config = config;
56
58
  }
59
+ buildTaskLogContext(task) {
60
+ const context = {
61
+ subscribeId: task.subscribeId,
62
+ rssId: task.rssId,
63
+ uid: task.uid,
64
+ guildId: task.guildId,
65
+ platform: task.platform,
66
+ retryCount: task.retryCount,
67
+ };
68
+ if (task.id !== undefined) {
69
+ context.taskId = String(task.id);
70
+ }
71
+ if (task.platform && task.guildId) {
72
+ context.target = `${task.platform}:${task.guildId}`;
73
+ }
74
+ return context;
75
+ }
76
+ createTaskDebug(task) {
77
+ return (0, logger_1.createDebugWithContext)(this.config, this.buildTaskLogContext(task));
78
+ }
57
79
  /**
58
80
  * 添加任务到队列
59
81
  */
@@ -65,8 +87,9 @@ class NotificationQueueManager {
65
87
  createdAt: new Date(),
66
88
  updatedAt: new Date()
67
89
  };
90
+ const taskDebug = this.createTaskDebug(queueTask);
68
91
  await this.ctx.database.create('rss_notification_queue', queueTask);
69
- (0, logger_1.debug)(this.config, `任务已加入队列: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
92
+ taskDebug(`任务已加入队列: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
70
93
  return queueTask;
71
94
  }
72
95
  /**
@@ -74,7 +97,7 @@ class NotificationQueueManager {
74
97
  */
75
98
  async processQueue() {
76
99
  if (this.processing) {
77
- (0, logger_1.debug)(this.config, '队列正在处理中,跳过本次', 'queue', 'details');
100
+ (0, logger_1.debug)(this.config, '队列正在处理中,跳过本次', 'queue', 'details', { processing: true });
78
101
  return;
79
102
  }
80
103
  this.processing = true;
@@ -84,15 +107,16 @@ class NotificationQueueManager {
84
107
  if (tasks.length === 0) {
85
108
  return;
86
109
  }
87
- (0, logger_1.debug)(this.config, `开始处理 ${tasks.length} 个待发送任务`, 'queue', 'info');
110
+ (0, logger_1.debug)(this.config, `开始处理 ${tasks.length} 个待发送任务`, 'queue', 'info', { taskCount: tasks.length });
88
111
  // 2. 逐个处理任务
89
112
  for (const task of tasks) {
90
113
  await this.processTask(task);
91
114
  }
92
115
  }
93
116
  catch (err) {
94
- (0, logger_1.debug)(this.config, `队列处理异常: ${err.message}`, 'queue', 'error');
95
- console.error('Queue processing error:', err);
117
+ const normalizedError = (0, error_handler_1.normalizeError)(err);
118
+ (0, logger_1.debug)(this.config, `队列处理异常: ${normalizedError.message}`, 'queue', 'error', { processing: true });
119
+ (0, error_tracker_1.trackError)(normalizedError, { operation: 'processQueue' });
96
120
  }
97
121
  finally {
98
122
  this.processing = false;
@@ -118,13 +142,14 @@ class NotificationQueueManager {
118
142
  * 处理单个任务
119
143
  */
120
144
  async processTask(task) {
121
- (0, logger_1.debug)(this.config, `处理任务 [${task.rssId}] ${task.content.title} (重试${task.retryCount}次)`, 'queue', 'details');
145
+ const taskDebug = this.createTaskDebug(task);
146
+ taskDebug(`处理任务 [${task.rssId}] ${task.content.title} (重试${task.retryCount}次)`, 'queue', 'details');
122
147
  try {
123
148
  // 尝试发送消息
124
149
  await this.sendMessage(task);
125
150
  // 发送成功:标记为 SUCCESS
126
151
  await this.markTaskSuccess(task.id);
127
- (0, logger_1.debug)(this.config, `✓ 任务发送成功: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
152
+ taskDebug(`✓ 任务发送成功: [${task.rssId}] ${task.content.title}`, 'queue', 'info');
128
153
  // 写入缓存
129
154
  await this.cacheMessage(task);
130
155
  }
@@ -139,16 +164,17 @@ class NotificationQueueManager {
139
164
  async sendMessage(task) {
140
165
  const { guildId, platform, content } = task;
141
166
  const target = `${platform}:${guildId}`;
167
+ const taskDebug = this.createTaskDebug(task);
142
168
  try {
143
169
  // 第一次尝试:发送原始消息
144
170
  await this.ctx.broadcast([target], content.message);
145
- (0, logger_1.debug)(this.config, `消息发送成功: ${target}`, 'queue', 'details');
171
+ taskDebug(`消息发送成功: ${target}`, 'queue', 'details');
146
172
  }
147
173
  catch (sendError) {
148
174
  // OneBot retcode 1200: 不支持的消息格式(通常是视频)
149
175
  const isOneBot1200 = sendError.code?.toString?.() === '1200' || sendError.message?.includes('1200');
150
176
  if (isOneBot1200 && !content.isDowngraded) {
151
- (0, logger_1.debug)(this.config, `检测到 OneBot 1200 错误,尝试降级处理`, 'queue', 'info');
177
+ taskDebug(`检测到 OneBot 1200 错误,尝试降级处理`, 'queue', 'info', { errorCode: '1200' });
152
178
  throw { ...sendError, isMediaError: true, requiresDowngrade: true };
153
179
  }
154
180
  throw sendError;
@@ -158,25 +184,40 @@ class NotificationQueueManager {
158
184
  * 处理发送错误
159
185
  */
160
186
  async handleSendError(task, error) {
161
- const errorMsg = error.message || 'Unknown error';
187
+ const taskDebug = this.createTaskDebug(task);
188
+ const normalizedError = (0, error_handler_1.normalizeError)(error);
189
+ const errorMsg = normalizedError.message || 'Unknown error';
190
+ (0, error_tracker_1.trackError)(normalizedError, {
191
+ ...this.buildTaskLogContext(task),
192
+ failReason: errorMsg,
193
+ requiresDowngrade: Boolean(error?.requiresDowngrade),
194
+ });
162
195
  // 1. 永久性错误 (Fatal) - 不需要重试
163
196
  if (this.isFatalError(error)) {
164
197
  await this.markTaskFailed(task.id, errorMsg);
165
- (0, logger_1.debug)(this.config, `✗ 永久性失败,放弃重试: [${task.rssId}] ${task.content.title} - ${errorMsg}`, 'queue', 'error');
198
+ taskDebug(`✗ 永久性失败,放弃重试: [${task.rssId}] ${task.content.title} - ${errorMsg}`, 'queue', 'error', {
199
+ fatal: true,
200
+ failReason: errorMsg,
201
+ });
166
202
  return;
167
203
  }
168
204
  // 2. 降级重试 (Downgrade) - 针对媒体格式错误
169
205
  if (error.requiresDowngrade && !task.content.isDowngraded) {
170
206
  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');
207
+ await this.updateTaskForDowngrade(task, downgradedContent);
208
+ taskDebug(`→ 消息已降级,立即重试: [${task.rssId}] ${task.content.title}`, 'queue', 'info', {
209
+ requiresDowngrade: true,
210
+ });
173
211
  return;
174
212
  }
175
213
  // 3. 暂时性错误 (Transient) - 使用指数退避
176
214
  const delay = this.backoffDelays[task.retryCount] || this.backoffDelays[this.backoffDelays.length - 1];
177
215
  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');
216
+ await this.markTaskRetry(task, nextTime, errorMsg);
217
+ taskDebug(`→ 任务将在 ${Math.ceil(delay / 60)} 分钟后重试: [${task.rssId}] ${task.content.title}`, 'queue', 'info', {
218
+ nextRetryTime: nextTime.toISOString(),
219
+ failReason: errorMsg,
220
+ });
180
221
  }
181
222
  /**
182
223
  * 判断是否为永久性错误
@@ -239,13 +280,11 @@ class NotificationQueueManager {
239
280
  /**
240
281
  * 标记任务为重试
241
282
  */
242
- async markTaskRetry(taskId, nextTime, reason) {
243
- await this.ctx.database.set('rss_notification_queue', { id: taskId }, {
283
+ async markTaskRetry(task, nextTime, reason) {
284
+ await this.ctx.database.set('rss_notification_queue', { id: task.id }, {
244
285
  status: 'RETRY',
245
286
  nextRetryTime: nextTime,
246
- retryCount: this.ctx.database.get('rss_notification_queue', { id: taskId }).then((tasks) => {
247
- return (tasks[0]?.retryCount || 0) + 1;
248
- }),
287
+ retryCount: (task.retryCount || 0) + 1,
249
288
  failReason: reason,
250
289
  updatedAt: new Date()
251
290
  });
@@ -253,17 +292,12 @@ class NotificationQueueManager {
253
292
  /**
254
293
  * 更新任务为降级重试
255
294
  */
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 }, {
295
+ async updateTaskForDowngrade(task, newContent) {
296
+ await this.ctx.database.set('rss_notification_queue', { id: task.id }, {
263
297
  content: newContent,
264
298
  status: 'RETRY',
265
299
  nextRetryTime: new Date(), // 立即重试
266
- retryCount: currentTask.retryCount + 1,
300
+ retryCount: (task.retryCount || 0) + 1,
267
301
  updatedAt: new Date()
268
302
  });
269
303
  }
@@ -284,6 +318,7 @@ class NotificationQueueManager {
284
318
  if (!this.config.cache?.enabled) {
285
319
  return;
286
320
  }
321
+ const taskDebug = this.createTaskDebug(task);
287
322
  const { getMessageCache } = await Promise.resolve().then(() => __importStar(require('../utils/message-cache')));
288
323
  const cache = getMessageCache();
289
324
  if (!cache) {
@@ -304,7 +339,12 @@ class NotificationQueueManager {
304
339
  });
305
340
  }
306
341
  catch (err) {
307
- (0, logger_1.debug)(this.config, `缓存消息失败: ${err.message}`, 'cache', 'info');
342
+ const normalizedError = (0, error_handler_1.normalizeError)(err);
343
+ taskDebug(`缓存消息失败: ${normalizedError.message}`, 'cache', 'info');
344
+ (0, error_tracker_1.trackError)(normalizedError, {
345
+ ...this.buildTaskLogContext(task),
346
+ operation: 'cacheMessage',
347
+ });
308
348
  }
309
349
  }
310
350
  /**
@@ -325,7 +365,8 @@ class NotificationQueueManager {
325
365
  async retryFailedTasks(taskId) {
326
366
  const where = taskId ? { id: taskId } : { status: 'FAILED' };
327
367
  const tasks = await this.ctx.database.get('rss_notification_queue', where);
328
- for (const task of tasks) {
368
+ const failedTasks = tasks.filter(task => task.status === 'FAILED');
369
+ for (const task of failedTasks) {
329
370
  await this.ctx.database.set('rss_notification_queue', { id: task.id }, {
330
371
  status: 'PENDING',
331
372
  retryCount: 0,
@@ -333,8 +374,11 @@ class NotificationQueueManager {
333
374
  updatedAt: new Date()
334
375
  });
335
376
  }
336
- (0, logger_1.debug)(this.config, `已重置 ${tasks.length} 个失败任务为 PENDING 状态`, 'queue', 'info');
337
- return tasks.length;
377
+ (0, logger_1.debug)(this.config, `已重置 ${failedTasks.length} 个失败任务为 PENDING 状态`, 'queue', 'info', {
378
+ resetCount: failedTasks.length,
379
+ taskId,
380
+ });
381
+ return failedTasks.length;
338
382
  }
339
383
  /**
340
384
  * 清理旧的成功任务
@@ -345,7 +389,10 @@ class NotificationQueueManager {
345
389
  for (const task of tasks) {
346
390
  await this.ctx.database.remove('rss_notification_queue', { id: task.id });
347
391
  }
348
- (0, logger_1.debug)(this.config, `已清理 ${tasks.length} 个旧的成功任务`, 'queue', 'info');
392
+ (0, logger_1.debug)(this.config, `已清理 ${tasks.length} 个旧的成功任务`, 'queue', 'info', {
393
+ cleanupCount: tasks.length,
394
+ olderThanHours,
395
+ });
349
396
  return tasks.length;
350
397
  }
351
398
  }
@@ -37,10 +37,22 @@ exports.getRssData = getRssData;
37
37
  const cheerio = __importStar(require("cheerio"));
38
38
  const logger_1 = require("../utils/logger");
39
39
  const common_1 = require("../utils/common");
40
+ const security_1 = require("../utils/security");
40
41
  const X2JS = require("x2js");
41
42
  const x2js = new X2JS();
42
43
  async function getRssData(ctx, config, $http, url, arg) {
43
44
  try {
45
+ // URL 安全验证
46
+ try {
47
+ (0, security_1.validateUrlOrThrow)(url, (0, security_1.getSecurityOptions)(config));
48
+ }
49
+ catch (error) {
50
+ if (error instanceof security_1.SecurityError) {
51
+ (0, logger_1.debug)(config, `URL 安全验证失败: ${error.message}`, 'security', 'error');
52
+ throw error;
53
+ }
54
+ throw error;
55
+ }
44
56
  // --- HTML 抓取预处理 START ---
45
57
  let rssData;
46
58
  let contentType = '';
@@ -1,4 +1,19 @@
1
1
  import { Context } from 'koishi';
2
2
  import { Config, rssArg } from '../types';
3
+ export interface RenderContentMetrics {
4
+ bodyScrollHeight: number;
5
+ bodyOffsetHeight: number;
6
+ documentScrollHeight: number;
7
+ contentRangeHeight: number;
8
+ maxElementBottom: number;
9
+ paddingTop: number;
10
+ paddingBottom: number;
11
+ marginTop: number;
12
+ marginBottom: number;
13
+ marginLeft: number;
14
+ bodyWidth: number;
15
+ viewportHeight: number;
16
+ }
17
+ export declare function calculateContentHeight(metrics: RenderContentMetrics): number;
3
18
  export declare function preprocessHtmlImages(ctx: Context, config: Config, $http: any, htmlContent: string, arg?: rssArg): Promise<string>;
4
19
  export declare function renderHtml2Image(ctx: Context, config: Config, $http: any, htmlContent: string, arg?: rssArg): Promise<any>;