@anyul/koishi-plugin-rss 5.3.4 → 5.3.10

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.
@@ -287,7 +287,7 @@ async function handleCacheList(cmdCtx, args, config) {
287
287
  }
288
288
  }
289
289
  async function handleCacheMessage(cmdCtx, args, config) {
290
- const { cache, logContext } = cmdCtx;
290
+ const { logContext } = cmdCtx;
291
291
  const realId = parseInt(args[0]);
292
292
  if (!realId || realId < 1) {
293
293
  return '请提供真实 ID\n用法: rsso.cache message <ID>\n示例: rsso.cache message 42\n💡 提示:使用 "rsso.cache list" 查看方括号中的真实 ID';
@@ -1,4 +1,5 @@
1
1
  import { Context } from 'koishi';
2
+ import { SubscriptionStore } from '../core/subscription-store';
2
3
  import type { Config } from '../types';
3
4
  export interface QuickListItem {
4
5
  prefix: string;
@@ -20,6 +21,7 @@ export declare function resolveQuickItem(input: string, quickList: QuickListItem
20
21
  export interface SubscriptionCreateCommandDeps {
21
22
  ctx: Context;
22
23
  config: Config;
24
+ store: SubscriptionStore;
23
25
  usage: string;
24
26
  quickList: QuickListItem[];
25
27
  parseQuickUrl: (url: string) => string;
@@ -71,7 +71,7 @@ function registerSubscriptionCreateCommand(deps) {
71
71
  if (!(0, utils_1.isValidUrl)((0, common_1.ensureUrlProtocol)(url))) {
72
72
  return '❌ URL 格式不正确,请以 http:// 或 https:// 开头';
73
73
  }
74
- const rssList = await deps.ctx.database.get('rssOwl', { platform, guildId });
74
+ const rssList = await deps.store.findByGuild(platform, guildId);
75
75
  if (rssList.find(item => item.url === url))
76
76
  return '❌ 该订阅已存在';
77
77
  const rawArg = deps.formatArg(options);
@@ -137,7 +137,7 @@ function registerSubscriptionCreateCommand(deps) {
137
137
  else if (deps.config.basic.urlDeduplication && rssList.find(item => item.rssId === rssItem.rssId)) {
138
138
  return `❌ 订阅已存在: ${rssItem.rssId}`;
139
139
  }
140
- await deps.ctx.database.create('rssOwl', rssItem);
140
+ await deps.store.create(rssItem);
141
141
  if (deps.config.basic.firstLoad && arg.firstLoad !== false && rssItemList.length > 0) {
142
142
  let itemArray = rssItemList.sort((a, b) => deps.parsePubDate(b.pubDate).getTime() - deps.parsePubDate(a.pubDate).getTime());
143
143
  if (arg.reverse)
@@ -1,7 +1,9 @@
1
1
  import { Context } from 'koishi';
2
+ import { SubscriptionStore } from '../core/subscription-store';
2
3
  import type { Config } from '../types';
3
4
  export interface SubscriptionEditCommandDeps {
4
5
  ctx: Context;
5
6
  config: Config;
7
+ store: SubscriptionStore;
6
8
  }
7
9
  export declare function registerSubscriptionEditCommand(deps: SubscriptionEditCommandDeps): void;
@@ -37,7 +37,7 @@ function registerSubscriptionEditCommand(deps) {
37
37
  .example('rsso.edit 1 -t "新标题"')
38
38
  .action(async ({ session, options }, id) => {
39
39
  const { guildId, platform, authority } = (0, utils_1.extractSessionInfo)(session);
40
- const rssList = await deps.ctx.database.get('rssOwl', { platform, guildId });
40
+ const rssList = await deps.store.findByGuild(platform, guildId);
41
41
  const listIndex = id - 1;
42
42
  if (listIndex < 0 || listIndex >= rssList.length) {
43
43
  return `❌ 序号 ${id} 不存在\n当前共有 ${rssList.length} 个订阅\n\n使用 rsso.list 查看完整列表`;
@@ -62,7 +62,7 @@ function registerSubscriptionEditCommand(deps) {
62
62
  if (options.test) {
63
63
  return buildEditPreview(rssItem, id, options, parseResult.targets);
64
64
  }
65
- return saveSubscriptionChanges(deps.ctx, rssItem, id, options, parseResult.targets);
65
+ return saveSubscriptionChanges(deps.store, rssItem, id, options, parseResult.targets);
66
66
  });
67
67
  }
68
68
  function buildEditPreview(rssItem, id, options, parsedTargets) {
@@ -90,7 +90,7 @@ function buildEditPreview(rssItem, id, options, parsedTargets) {
90
90
  testOutput += '\n⚠️ 测试模式:不会保存修改\n去掉 --test 选项保存更改';
91
91
  return testOutput;
92
92
  }
93
- async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets) {
93
+ async function saveSubscriptionChanges(store, rssItem, id, options, parsedTargets) {
94
94
  const updates = {};
95
95
  if (options.title)
96
96
  updates.title = options.title;
@@ -114,7 +114,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
114
114
  const [newPlatform, newGuildId] = parsedTargets[0].split(/[::]/);
115
115
  updates.platform = newPlatform;
116
116
  updates.guildId = newGuildId;
117
- await ctx.database.set('rssOwl', rssItem.id, updates);
117
+ await store.update(rssItem.id, updates);
118
118
  let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
119
119
  if (options.title)
120
120
  result += `标题: ${rssItem.title} → ${options.title}\n`;
@@ -131,14 +131,10 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
131
131
  let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
132
132
  result += `已创建 ${parsedTargets.length} 个推送目标:\n\n`;
133
133
  result += `1. 原订阅 (本群): ${originalTarget}\n`;
134
- await ctx.database.set('rssOwl', rssItem.id, updates);
134
+ await store.update(rssItem.id, updates);
135
135
  for (let i = 0; i < parsedTargets.length; i++) {
136
136
  const [newPlatform, newGuildId] = parsedTargets[i].split(/[::]/);
137
- const existing = await ctx.database.get('rssOwl', {
138
- platform: newPlatform,
139
- guildId: newGuildId,
140
- url: rssItem.url
141
- });
137
+ const existing = await store.findByGuildAndUrl(newPlatform, newGuildId, rssItem.url);
142
138
  if (existing.length > 0) {
143
139
  result += `${i + 2}. ⚠️ ${newPlatform}:${newGuildId} (订阅已存在,已跳过)\n`;
144
140
  continue;
@@ -149,7 +145,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
149
145
  platform: newPlatform,
150
146
  guildId: newGuildId,
151
147
  };
152
- const created = await ctx.database.create('rssOwl', newSubscription);
148
+ const created = await store.create(newSubscription);
153
149
  result += `${i + 2}. ✅ ${newPlatform}:${newGuildId} (新订阅ID: ${created.id})\n`;
154
150
  }
155
151
  if (options.title)
@@ -159,7 +155,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
159
155
  result += '\n💡 提示: 可以使用 rsso.list 查看所有订阅';
160
156
  return result.trim();
161
157
  }
162
- await ctx.database.set('rssOwl', rssItem.id, updates);
158
+ await store.update(rssItem.id, updates);
163
159
  let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
164
160
  if (options.title)
165
161
  result += `标题: ${rssItem.title} → ${options.title}\n`;
@@ -1,8 +1,10 @@
1
1
  import { Context } from 'koishi';
2
+ import { SubscriptionStore } from '../core/subscription-store';
2
3
  import type { Config } from '../types';
3
4
  export interface SubscriptionCommandDeps {
4
5
  ctx: Context;
5
6
  config: Config;
7
+ store: SubscriptionStore;
6
8
  parsePubDate: (pubDate: any) => Date;
7
9
  parseQuickUrl: (url: string) => string;
8
10
  getRssData: (url: string, arg: any) => Promise<any[]>;
@@ -23,7 +23,7 @@ function registerListCommand(deps) {
23
23
  .example('rsso.list')
24
24
  .action(async ({ session }, id) => {
25
25
  const { guildId, platform } = (0, utils_1.extractSessionInfo)(session);
26
- const rssList = await getGuildSubscriptions(deps.ctx, platform, guildId);
26
+ const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
27
27
  if (id === undefined) {
28
28
  if (rssList.length === 0)
29
29
  return '当前没有任何订阅';
@@ -69,14 +69,14 @@ function registerRemoveCommand(deps) {
69
69
  const authorityCheck = (0, utils_1.checkAuthority)(authority, deps.config.basic.authority, `权限不足!当前权限: ${authority},需要权限: ${deps.config.basic.authority} 或以上`);
70
70
  if (!authorityCheck.success)
71
71
  return authorityCheck.message;
72
- await deps.ctx.database.remove('rssOwl', { platform, guildId });
72
+ await deps.store.removeAllByGuild(platform, guildId);
73
73
  return '✅ 已删除全部订阅';
74
74
  }
75
- const rssList = await getGuildSubscriptions(deps.ctx, platform, guildId);
75
+ const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
76
76
  const rssItem = getSubscriptionByIndex(rssList, id);
77
77
  if (!rssItem)
78
78
  return getSubscriptionNotFoundMessage(id, rssList.length);
79
- await deps.ctx.database.remove('rssOwl', { id: rssItem.id });
79
+ await deps.store.remove(rssItem.id);
80
80
  return `✅ 已删除订阅: ${rssItem.title}`;
81
81
  });
82
82
  }
@@ -92,7 +92,7 @@ function registerPullCommand(deps) {
92
92
  .example('rsso.pull 1')
93
93
  .action(async ({ session }, id) => {
94
94
  const { guildId, platform } = (0, utils_1.extractSessionInfo)(session);
95
- const rssList = await getGuildSubscriptions(deps.ctx, platform, guildId);
95
+ const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
96
96
  const rssItem = getSubscriptionByIndex(rssList, id);
97
97
  if (!rssItem)
98
98
  return getSubscriptionNotFoundMessage(id, rssList.length);
@@ -138,7 +138,7 @@ function registerFollowCommand(deps) {
138
138
  .example('rsso.follow 1')
139
139
  .action(async ({ session, options }, id) => {
140
140
  const { guildId, platform, authorId, authority } = (0, utils_1.extractSessionInfo)(session);
141
- const rssList = await getGuildSubscriptions(deps.ctx, platform, guildId);
141
+ const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
142
142
  const rssItem = getSubscriptionByIndex(rssList, id);
143
143
  if (!rssItem)
144
144
  return getSubscriptionNotFoundMessage(id, rssList.length);
@@ -150,18 +150,18 @@ function registerFollowCommand(deps) {
150
150
  if (followers.includes('all'))
151
151
  return '💡 已经设置全员提醒';
152
152
  followers.push('all');
153
- await deps.ctx.database.set('rssOwl', { id: rssItem.id }, { followers });
153
+ await deps.store.update(rssItem.id, { followers });
154
154
  return '✅ 已设置全员提醒';
155
155
  }
156
156
  if (followers.includes(authorId))
157
157
  return '💡 已经关注过了';
158
158
  followers.push(authorId);
159
- await deps.ctx.database.set('rssOwl', { id: rssItem.id }, { followers });
159
+ await deps.store.update(rssItem.id, { followers });
160
160
  return '✅ 关注成功';
161
161
  });
162
162
  }
163
- async function getGuildSubscriptions(ctx, platform, guildId) {
164
- return ctx.database.get('rssOwl', { platform, guildId });
163
+ async function getGuildSubscriptions(store, platform, guildId) {
164
+ return store.findByGuild(platform, guildId);
165
165
  }
166
166
  function getSubscriptionByIndex(rssList, id) {
167
167
  const listIndex = id - 1;
@@ -1,4 +1,5 @@
1
1
  import { Context } from 'koishi';
2
+ import { SubscriptionStore } from '../core/subscription-store';
2
3
  import type { Config, TemplateType, rssArg } from '../types';
3
4
  type DebugType = 'disable' | 'error' | 'info' | 'details';
4
5
  type HtmlMonitorArg = rssArg & {
@@ -21,6 +22,7 @@ interface WatchCommandOptions {
21
22
  export interface WebMonitorCommandDeps {
22
23
  ctx: Context;
23
24
  config: Config;
25
+ store: SubscriptionStore;
24
26
  debug: (message: any, name?: string, type?: DebugType, context?: Record<string, any>) => void;
25
27
  mixinArg: (arg: Record<string, any>) => rssArg;
26
28
  getRssData: (url: string, arg: Record<string, any>) => Promise<any[]>;
@@ -55,7 +55,7 @@ HTML 网页监控功能,使用 CSS 选择器提取内容
55
55
  return '❌ 未找到符合选择器的元素';
56
56
  return buildItemsPreview(items, `找到 ${items.length} 个元素:`, true);
57
57
  }
58
- const rssList = await deps.ctx.database.get('rssOwl', { platform, guildId });
58
+ const rssList = await deps.store.findByGuild(platform, guildId);
59
59
  if (rssList.find(item => item.url === normalizedUrl))
60
60
  return '❌ 该订阅已存在';
61
61
  const htmlItems = await deps.getRssData(normalizedUrl, arg);
@@ -77,7 +77,7 @@ HTML 网页监控功能,使用 CSS 选择器提取内容
77
77
  if (deps.config.basic.urlDeduplication && rssList.find(item => item.rssId === rssItem.rssId)) {
78
78
  return `❌ 订阅已存在: ${rssItem.rssId}`;
79
79
  }
80
- await deps.ctx.database.create('rssOwl', rssItem);
80
+ await deps.store.create(rssItem);
81
81
  if (deps.config.basic.firstLoad && arg.firstLoad !== false && htmlItems.length > 0) {
82
82
  await broadcastInitialItems(deps, `${platform}:${guildId}`, htmlItems, rssItem);
83
83
  }
package/lib/config.js CHANGED
@@ -54,10 +54,12 @@ exports.Config = koishi_1.Schema.object({
54
54
  maxRssItem: koishi_1.Schema.number().description('限制更新时的最大推送数量上限,超出上限时较早的更新会被忽略').default(10),
55
55
  firstLoad: koishi_1.Schema.boolean().description('首次订阅时是否发送最后的更新').default(true),
56
56
  urlDeduplication: koishi_1.Schema.boolean().description('同群组中不允许重复添加相同订阅').default(true),
57
- resendUpdataContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('当内容更新时再次发送').default('disable').experimental(),
57
+ resendUpdatedContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('当内容更新时再次发送').default('disable'),
58
+ resendUpdataContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('(已弃用,请使用 resendUpdatedContent)').hidden().deprecated(),
58
59
  imageMode: koishi_1.Schema.union(['base64', 'File', 'assets']).description('图片发送模式<br>\`base64\` Base64格式(兼容性好但容易超长)<br>\`File\` 本地文件(不支持沙盒环境)<br>\`assets\` Assets服务(推荐,需安装assets-xxx插件并配置)').default('base64'),
59
60
  videoMode: koishi_1.Schema.union(['filter', 'href', 'base64', 'File', 'assets']).description('视频发送模式(iframe标签内的视频无法处理)<br>\`filter\` 过滤视频,含有视频的推送将不会被发送<br>\`href\` 使用视频网络地址直接发送<br>\`base64\` 下载后以base64格式发送<br>\`File\` 下载后以文件发送<br>\`assets\` 上传到assets服务(需安装assets-xxx插件并配置)').default('href'),
60
- margeVideo: koishi_1.Schema.boolean().default(false).description('以合并消息发送视频'),
61
+ mergeVideo: koishi_1.Schema.boolean().default(false).description('以合并消息发送视频'),
62
+ margeVideo: koishi_1.Schema.boolean().description('(已弃用,请使用 mergeVideo)').hidden().deprecated(),
61
63
  usePoster: koishi_1.Schema.boolean().default(false).description('加载视频封面'),
62
64
  autoSplitImage: koishi_1.Schema.boolean().description('垂直拆分大尺寸图片,解决部分适配器发不出长图的问题').default(true),
63
65
  cacheDir: koishi_1.Schema.string().description('File模式时使用的缓存路径').default('data/cache/rssOwl'),
@@ -53,6 +53,12 @@ Object.defineProperty(exports, "findRssItem", { enumerable: true, get: function
53
53
  Object.defineProperty(exports, "getLastContent", { enumerable: true, get: function () { return feeder_runtime_2.getLastContent; } });
54
54
  let interval = null;
55
55
  let queueInterval = null;
56
+ let cleanupInterval = null;
57
+ // 重入栅栏:feeder() 单轮可能跑很久(订阅多 + 网络/tdl 慢),
58
+ // 若耗时超过 refreshInterval,下一次 interval tick 会并发启动第二个 feeder(),
59
+ // 导致同一消息被重复入队(addTask 的读-写去重非原子)→ 重复推送。
60
+ // 仿照 NotificationQueueManager.processQueue 的 processing 标志,单轮串行。
61
+ let feederRunning = false;
56
62
  function shouldSkipByInterval(rssItem, arg, originalArg) {
57
63
  if (!rssItem.arg.interval)
58
64
  return false;
@@ -174,11 +180,36 @@ async function feeder(deps, processor) {
174
180
  }
175
181
  }
176
182
  function startFeeder(ctx, config, $http, processor, queueManager) {
183
+ // 幂等清理:若上一实例的 interval 尚未清理(未走 dispose 就再次 startFeeder),
184
+ // 先清掉旧句柄,避免 ghost 定时器泄漏。
185
+ if (interval) {
186
+ clearInterval(interval);
187
+ interval = null;
188
+ }
189
+ if (queueInterval) {
190
+ clearInterval(queueInterval);
191
+ queueInterval = null;
192
+ }
193
+ if (cleanupInterval) {
194
+ clearInterval(cleanupInterval);
195
+ cleanupInterval = null;
196
+ }
177
197
  const deps = { ctx, config, $http, queueManager };
178
198
  const lifecycleDebug = (0, logger_1.createDebugWithContext)(config, { lifecycle: 'feeder' });
179
199
  const queueRuntimeConfig = (0, notification_queue_retry_1.getQueueRuntimeConfig)(config);
200
+ // 受重入栅栏保护的 feeder 运行:避免上一轮未跑完时下一轮并发启动
201
+ const runFeederGuarded = () => {
202
+ if (feederRunning) {
203
+ (0, logger_1.debug)(config, 'feeder 上一轮仍在运行,跳过本次 tick', 'feeder', 'details', { skipped: true });
204
+ return Promise.resolve();
205
+ }
206
+ feederRunning = true;
207
+ return feeder(deps, processor).finally(() => {
208
+ feederRunning = false;
209
+ });
210
+ };
180
211
  // Initial run
181
- feeder(deps, processor).catch(err => {
212
+ runFeederGuarded().catch(err => {
182
213
  const normalizedError = (0, error_handler_1.normalizeError)(err);
183
214
  lifecycleDebug(`Initial feeder run failed: ${normalizedError.message}`, 'feeder', 'error', {
184
215
  operation: 'initial-feeder-run',
@@ -191,7 +222,7 @@ function startFeeder(ctx, config, $http, processor, queueManager) {
191
222
  const { delCache } = await Promise.resolve().then(() => __importStar(require('../utils/media')));
192
223
  await delCache(config);
193
224
  }
194
- await feeder(deps, processor);
225
+ await runFeederGuarded();
195
226
  }, refreshInterval);
196
227
  // 启动消费者定时器(处理发送队列)
197
228
  // 频率更高,确保消息快速发送
@@ -199,6 +230,20 @@ function startFeeder(ctx, config, $http, processor, queueManager) {
199
230
  queueInterval = setInterval(async () => {
200
231
  await queueManager.processQueue();
201
232
  }, queueProcessInterval);
233
+ // 启动清理定时器:周期性删除过期的 SUCCESS/FAILED 任务,
234
+ // 防止 rss_notification_queue 表无限膨胀拖慢查询(此前只手动命令清理)。
235
+ // 每小时一次,cleanupHours 控制具体保留时长。
236
+ cleanupInterval = setInterval(async () => {
237
+ try {
238
+ await queueManager.runAutomaticCleanup();
239
+ }
240
+ catch (err) {
241
+ const normalizedError = (0, error_handler_1.normalizeError)(err);
242
+ lifecycleDebug(`自动清理失败: ${normalizedError.message}`, 'queue', 'error', {
243
+ operation: 'automatic-cleanup',
244
+ });
245
+ }
246
+ }, 60 * 60 * 1000);
202
247
  // 立即处理一次队列(启动时)
203
248
  queueManager.processQueue().catch(err => {
204
249
  const normalizedError = (0, error_handler_1.normalizeError)(err);
@@ -220,6 +265,12 @@ function stopFeeder(config) {
220
265
  clearInterval(queueInterval);
221
266
  queueInterval = null;
222
267
  }
268
+ if (cleanupInterval) {
269
+ clearInterval(cleanupInterval);
270
+ cleanupInterval = null;
271
+ }
272
+ // 复位栅栏:避免上一轮未跑完就 stopFeeder 后,下次 startFeeder 时栅栏卡死
273
+ feederRunning = false;
223
274
  if (config) {
224
275
  const lifecycleDebug = (0, logger_1.createDebugWithContext)(config, { lifecycle: 'feeder' });
225
276
  lifecycleDebug('Feeder stopped', 'feeder', 'info');
@@ -16,4 +16,11 @@ export declare class NotificationQueueStore {
16
16
  getStats(): Promise<QueueStats>;
17
17
  retryFailedTasks(taskId?: number): Promise<number>;
18
18
  cleanupSuccessTasks(olderThanHours?: number): Promise<number>;
19
+ /**
20
+ * 清理旧的 FAILED 任务。
21
+ *
22
+ * 与 SUCCESS 对称:FAILED 任务此前只进不出(无清理命令、无定时器),
23
+ * 长期运行会无限堆积,拖慢 getStats / getPendingTasks 的全表查询。
24
+ */
25
+ cleanupFailedTasks(olderThanHours?: number): Promise<number>;
19
26
  }
@@ -46,13 +46,18 @@ class NotificationQueueStore {
46
46
  };
47
47
  }
48
48
  async getPendingTasks() {
49
- const now = new Date();
49
+ const now = Date.now();
50
50
  const pendingTasks = await this.ctx.database.get(exports.RSS_NOTIFICATION_QUEUE_TABLE, { status: 'PENDING' }, { limit: this.batchSize });
51
51
  const retryTasks = await this.ctx.database.get(exports.RSS_NOTIFICATION_QUEUE_TABLE, { status: 'RETRY' }, { limit: this.batchSize });
52
- const readyRetryTasks = retryTasks.filter(task => task.nextRetryTime && new Date(task.nextRetryTime) <= now);
53
- return [...pendingTasks, ...readyRetryTasks]
54
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
55
- .slice(0, this.batchSize);
52
+ const readyRetryTasks = retryTasks.filter(task => task.nextRetryTime && new Date(task.nextRetryTime).getTime() <= now);
53
+ // PENDING 优先于 RETRY:避免一个持续高频失败的源因其 RETRY 任务 createdAt 较老
54
+ // 而长期占用 batchSize 槽位,把新订阅的新消息饿死(延迟数分钟才发)。
55
+ // 各组内部仍按 createdAt 升序(先入先出)。
56
+ const byCreatedAsc = (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
57
+ return [
58
+ ...pendingTasks.sort(byCreatedAsc),
59
+ ...readyRetryTasks.sort(byCreatedAsc),
60
+ ].slice(0, this.batchSize);
56
61
  }
57
62
  async markTaskSuccess(taskId) {
58
63
  await this.ctx.database.set(exports.RSS_NOTIFICATION_QUEUE_TABLE, { id: taskId }, {
@@ -90,7 +95,10 @@ class NotificationQueueStore {
90
95
  });
91
96
  }
92
97
  async recoverRetryTasksWithoutNextRetryTime() {
93
- const retryTasks = await this.ctx.database.get(exports.RSS_NOTIFICATION_QUEUE_TABLE, { status: 'RETRY' }, { limit: this.batchSize });
98
+ // 不限制 limit:损坏的 RETRY 任务可能 > batchSize(老版本升级 / DB 手工修改),
99
+ // 若只修前 batchSize 条,剩余的会因 nextRetryTime 永远为空而既不重试也不失败,永久卡住。
100
+ // 本操作幂等(只修 nextRetryTime 为空的),可安全重复执行。
101
+ const retryTasks = await this.ctx.database.get(exports.RSS_NOTIFICATION_QUEUE_TABLE, { status: 'RETRY' });
94
102
  const invalidTasks = retryTasks.filter(task => !task.nextRetryTime);
95
103
  for (const task of invalidTasks) {
96
104
  await this.ctx.database.set(exports.RSS_NOTIFICATION_QUEUE_TABLE, { id: task.id }, {
@@ -133,5 +141,19 @@ class NotificationQueueStore {
133
141
  }
134
142
  return tasks.length;
135
143
  }
144
+ /**
145
+ * 清理旧的 FAILED 任务。
146
+ *
147
+ * 与 SUCCESS 对称:FAILED 任务此前只进不出(无清理命令、无定时器),
148
+ * 长期运行会无限堆积,拖慢 getStats / getPendingTasks 的全表查询。
149
+ */
150
+ async cleanupFailedTasks(olderThanHours = 168) {
151
+ const cutoffTime = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
152
+ const tasks = await this.ctx.database.get(exports.RSS_NOTIFICATION_QUEUE_TABLE, { status: 'FAILED', updatedAt: { $lt: cutoffTime } });
153
+ for (const task of tasks) {
154
+ await this.ctx.database.remove(exports.RSS_NOTIFICATION_QUEUE_TABLE, { id: task.id });
155
+ }
156
+ return tasks.length;
157
+ }
136
158
  }
137
159
  exports.NotificationQueueStore = NotificationQueueStore;
@@ -57,4 +57,14 @@ export declare class NotificationQueueManager {
57
57
  * 清理旧的成功任务
58
58
  */
59
59
  cleanupSuccessTasks(olderThanHours?: number): Promise<number>;
60
+ /**
61
+ * 自动清理:周期性删除过期的 SUCCESS / FAILED 任务。
62
+ *
63
+ * SUCCESS 用 cleanupHours,FAILED 用 7×cleanupHours(失败记录保留更久便于排查,
64
+ * 但仍需回收,否则永久堆积拖慢全表查询)。供 feeder 定时器调用。
65
+ */
66
+ runAutomaticCleanup(): Promise<{
67
+ success: number;
68
+ failed: number;
69
+ }>;
60
70
  }
@@ -211,5 +211,23 @@ class NotificationQueueManager {
211
211
  });
212
212
  return cleanupCount;
213
213
  }
214
+ /**
215
+ * 自动清理:周期性删除过期的 SUCCESS / FAILED 任务。
216
+ *
217
+ * SUCCESS 用 cleanupHours,FAILED 用 7×cleanupHours(失败记录保留更久便于排查,
218
+ * 但仍需回收,否则永久堆积拖慢全表查询)。供 feeder 定时器调用。
219
+ */
220
+ async runAutomaticCleanup() {
221
+ const success = await this.store.cleanupSuccessTasks(this.cleanupHours);
222
+ // FAILED 保留时长为 SUCCESS 的 7 倍(默认 7 天),兼顾排查与回收
223
+ const failed = await this.store.cleanupFailedTasks(this.cleanupHours * 7);
224
+ if (success > 0 || failed > 0) {
225
+ (0, logger_1.debug)(this.config, `自动清理:${success} 个成功任务、${failed} 个失败任务`, 'queue', 'info', {
226
+ cleanupSuccess: success,
227
+ cleanupFailed: failed,
228
+ });
229
+ }
230
+ return { success, failed };
231
+ }
214
232
  }
215
233
  exports.NotificationQueueManager = NotificationQueueManager;
@@ -76,7 +76,7 @@ async function getRssData(ctx, config, $http, url, arg) {
76
76
  try {
77
77
  await page.waitForSelector(arg.waitSelector, { timeout: 5000 });
78
78
  }
79
- catch (e) { }
79
+ catch { }
80
80
  }
81
81
  else if (arg.waitFor) {
82
82
  await new Promise(r => setTimeout(r, arg.waitFor));
@@ -93,7 +93,7 @@ async function getRssData(ctx, config, $http, url, arg) {
93
93
  try {
94
94
  await page.close();
95
95
  }
96
- catch (e) { /* 忽略页面已关闭的错误 */ }
96
+ catch { /* 忽略页面已关闭的错误 */ }
97
97
  }
98
98
  }
99
99
  else {
@@ -113,7 +113,7 @@ async function getRssData(ctx, config, $http, url, arg) {
113
113
  rssData = JSON.parse(rssData);
114
114
  isJson = true;
115
115
  }
116
- catch (e) { /* ignore */ }
116
+ catch { /* ignore */ }
117
117
  }
118
118
  if (isJson) {
119
119
  (0, logger_1.debug)(config, rssData, 'JSON Feed Response', 'details');
@@ -178,7 +178,7 @@ async function getRssData(ctx, config, $http, url, arg) {
178
178
  try {
179
179
  link = new URL(link, url).href;
180
180
  }
181
- catch (e) { }
181
+ catch { }
182
182
  }
183
183
  // 3. 提取内容
184
184
  const description = arg.textOnly ? $el.text().trim() : ($el.html() || '').trim();
@@ -217,7 +217,7 @@ async function getRssData(ctx, config, $http, url, arg) {
217
217
  }
218
218
  else if (rssJson.feed) {
219
219
  // Atom
220
- let rss = { channel: {} };
220
+ const rss = { channel: {} };
221
221
  let item = rssJson.feed.entry.map(i => ({
222
222
  ...i,
223
223
  title: (0, common_1.parseContent)(i.title),
@@ -137,7 +137,7 @@ async function preprocessHtmlImages(ctx, config, $http, htmlContent, arg) {
137
137
  return $.html();
138
138
  }
139
139
  async function renderHtml2Image(ctx, config, $http, htmlContent, arg) {
140
- let page = await ctx.puppeteer.page();
140
+ const page = await ctx.puppeteer.page();
141
141
  try {
142
142
  (0, logger_1.debug)(config, htmlContent, 'htmlContent', 'details');
143
143
  const initialViewportHeight = 2000;
@@ -265,15 +265,15 @@ async function renderHtml2Image(ctx, config, $http, htmlContent, arg) {
265
265
  return koishi_1.h.image(image, 'image/png');
266
266
  }
267
267
  let height = actualHeight;
268
- let width = clipWidth;
269
- let x = clipX;
270
- let y = clipY;
268
+ const width = clipWidth;
269
+ const x = clipX;
270
+ const y = clipY;
271
271
  // 保守处理:如果高度异常,使用更小的值
272
272
  if (height > 5000) {
273
273
  (0, logger_1.debug)(config, `检测到异常高度 ${height},进行裁剪`, 'height warn', 'info');
274
274
  height = Math.min(height, 2000);
275
275
  }
276
- let size = 10000;
276
+ const size = 10000;
277
277
  (0, logger_1.debug)(config, [height, width, x, y], 'pptr img size', 'details');
278
278
  const split = Math.ceil(height / size);
279
279
  if (!split) {
@@ -298,7 +298,7 @@ async function renderHtml2Image(ctx, config, $http, htmlContent, arg) {
298
298
  const reduceY = (index) => Math.floor(height / split * index);
299
299
  // 最后一份使用完整高度减去前面所有份的高度,确保覆盖全部内容
300
300
  const reduceHeight = (index) => index === split - 1 ? height - reduceY(index) : Math.floor(height / split);
301
- let imgData = await Promise.all(Array.from({ length: split }, async (v, i) => await page.screenshot({
301
+ const imgData = await Promise.all(Array.from({ length: split }, async (v, i) => await page.screenshot({
302
302
  type: "png",
303
303
  clip: {
304
304
  x,
@@ -329,6 +329,6 @@ async function renderHtml2Image(ctx, config, $http, htmlContent, arg) {
329
329
  try {
330
330
  await page.close();
331
331
  }
332
- catch (e) { /* 忽略页面已关闭的错误 */ }
332
+ catch { /* 忽略页面已关闭的错误 */ }
333
333
  }
334
334
  }
@@ -1,3 +1,3 @@
1
1
  import { SearchResponse } from './search-types';
2
2
  export declare function formatSearchResults(response: SearchResponse): string;
3
- export declare function buildPromptWithSearchContext(originalPrompt: string, searchResults: SearchResponse, searchQuery: string): string;
3
+ export declare function buildPromptWithSearchContext(originalPrompt: string, searchResults: SearchResponse, _searchQuery: string): string;
@@ -21,7 +21,7 @@ function formatSearchResults(response) {
21
21
  });
22
22
  return lines.join('\n');
23
23
  }
24
- function buildPromptWithSearchContext(originalPrompt, searchResults, searchQuery) {
24
+ function buildPromptWithSearchContext(originalPrompt, searchResults, _searchQuery) {
25
25
  if (!searchResults.success || searchResults.results.length === 0) {
26
26
  return originalPrompt;
27
27
  }
@@ -0,0 +1,44 @@
1
+ import { Context } from 'koishi';
2
+ import type { RssOwlRow } from '../types';
3
+ /**
4
+ * 订阅数据访问仓储(Repository)。
5
+ *
6
+ * 设计目的:`rssOwl` 是订阅核心表,原本被命令层(subscription-create /
7
+ * subscription-edit / subscription-management / web-monitor)的 15 处
8
+ * 直接 `ctx.database.*('rssOwl', ...)` 调用散落访问,违反「命令层只复用
9
+ * 核心函数」的分层约定。本类作为该表的唯一封装,命令层通过依赖注入拿到
10
+ * 实例,不再直接触碰 `ctx.database`,与 `NotificationQueueStore` /
11
+ * `MessageCacheManager` 的既有分层保持一致。
12
+ *
13
+ * 注意:feeder(`core/feeder.ts`)也在核心层直接读写 `rssOwl`,那是核心层
14
+ * 对自身数据的合法访问,不强制走本仓储;强制收口会破坏 feeder 作为纯函数
15
+ * 的可测试性(见 5.3.8 的 disposed 评估取舍)。
16
+ */
17
+ export declare class SubscriptionStore {
18
+ private ctx;
19
+ constructor(ctx: Context);
20
+ /**
21
+ * 按群组查询订阅列表(命令层最常用的读取入口)。
22
+ */
23
+ findByGuild(platform: string, guildId: string): Promise<RssOwlRow[]>;
24
+ /**
25
+ * 按 群组 + URL 查询(用于编辑推送目标时判断目标群是否已有同源订阅)。
26
+ */
27
+ findByGuildAndUrl(platform: string, guildId: string, url: string): Promise<RssOwlRow[]>;
28
+ /**
29
+ * 创建一条订阅,返回含生成主键的完整行。
30
+ */
31
+ create(data: Partial<RssOwlRow>): Promise<RssOwlRow>;
32
+ /**
33
+ * 按主键更新任意字段。
34
+ */
35
+ update(id: number, patch: Partial<RssOwlRow>): Promise<void>;
36
+ /**
37
+ * 按主键删除单条订阅。
38
+ */
39
+ remove(id: number): Promise<void>;
40
+ /**
41
+ * 删除某个群组的全部订阅(rsso.remove --all)。
42
+ */
43
+ removeAllByGuild(platform: string, guildId: string): Promise<void>;
44
+ }