@anyul/koishi-plugin-rss 5.2.47 → 5.3.0

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.
@@ -70,6 +70,10 @@ export declare class MessageCacheManager {
70
70
  }): Promise<CacheStats>;
71
71
  /**
72
72
  * 清理缓存
73
+ *
74
+ * 注意:Koishi 的 database.remove 接受查询条件对象,会批量删除所有匹配行。
75
+ * 这里先查出保留部分的边界 id,再用 `< 边界` 的条件一次批量删除,
76
+ * 避免逐条 remove 造成的 N 次往返与中途失败留下半清理状态。
73
77
  */
74
78
  cleanup(options?: {
75
79
  guildId?: string;
@@ -84,6 +88,11 @@ export declare class MessageCacheManager {
84
88
  guildId?: string;
85
89
  platform?: string;
86
90
  }): Promise<number>;
91
+ /**
92
+ * 批量删除辅助:先按 where 统计条数,再一次 remove 删除全部匹配行。
93
+ * 用于 clearAll / cleanup,避免逐条往返与整表入内存。
94
+ */
95
+ private batchRemove;
87
96
  /**
88
97
  * 检查并自动清理
89
98
  */
@@ -122,6 +122,10 @@ class MessageCacheManager {
122
122
  }
123
123
  /**
124
124
  * 清理缓存
125
+ *
126
+ * 注意:Koishi 的 database.remove 接受查询条件对象,会批量删除所有匹配行。
127
+ * 这里先查出保留部分的边界 id,再用 `< 边界` 的条件一次批量删除,
128
+ * 避免逐条 remove 造成的 N 次往返与中途失败留下半清理状态。
125
129
  */
126
130
  async cleanup(options = {}) {
127
131
  const { guildId, rssId, platform, keepLatest = this.maxCacheSize } = options;
@@ -133,21 +137,22 @@ class MessageCacheManager {
133
137
  where.rssId = rssId;
134
138
  if (platform)
135
139
  where.platform = platform;
136
- // 获取所有匹配的消息
137
- const messages = await this.ctx.database.get('rss_message_cache', where, {
138
- sort: { createdAt: 'desc' }
140
+ // 只查到"要保留的最新 N 条"对应的边界,无需把整表拉进内存
141
+ const keepBoundary = await this.ctx.database.get('rss_message_cache', where, {
142
+ sort: { createdAt: 'desc' },
143
+ limit: 1,
144
+ offset: Math.max(0, keepLatest - 1),
139
145
  });
140
- // 如果消息数量超过限制,删除旧消息
141
- if (messages.length > keepLatest) {
142
- const toDelete = messages.slice(keepLatest);
143
- const idsToDelete = toDelete.map(m => m.id);
144
- for (const id of idsToDelete) {
145
- await this.ctx.database.remove('rss_message_cache', { id });
146
- }
147
- logger.info(`清理了 ${toDelete.length} 条缓存消息`);
148
- return toDelete.length;
149
- }
150
- return 0;
146
+ // 没有边界行,说明总量 ≤ keepLatest,无需清理
147
+ if (keepBoundary.length === 0)
148
+ return 0;
149
+ const boundaryCreatedAt = keepBoundary[0].createdAt;
150
+ // 删除比边界更旧(含同时间戳但靠后)的消息:createdAt < 边界
151
+ const deleteWhere = { ...where, createdAt: { $lt: boundaryCreatedAt } };
152
+ const removed = await this.batchRemove(deleteWhere);
153
+ if (removed > 0)
154
+ logger.info(`清理了 ${removed} 条缓存消息`);
155
+ return removed;
151
156
  }
152
157
  /**
153
158
  * 清空所有缓存
@@ -160,14 +165,24 @@ class MessageCacheManager {
160
165
  where.guildId = guildId;
161
166
  if (platform)
162
167
  where.platform = platform;
163
- // 获取所有消息
168
+ const removed = await this.batchRemove(where);
169
+ if (removed > 0)
170
+ logger.info(`清空了 ${removed} 条缓存消息`);
171
+ return removed;
172
+ }
173
+ /**
174
+ * 批量删除辅助:先按 where 统计条数,再一次 remove 删除全部匹配行。
175
+ * 用于 clearAll / cleanup,避免逐条往返与整表入内存。
176
+ */
177
+ async batchRemove(where) {
178
+ // Koishi database.get 不直接支持 count,先取一次总数
164
179
  const messages = await this.ctx.database.get('rss_message_cache', where);
165
- // 删除所有消息
166
- for (const msg of messages) {
167
- await this.ctx.database.remove('rss_message_cache', { id: msg.id });
168
- }
169
- logger.info(`清空了 ${messages.length} 条缓存消息`);
170
- return messages.length;
180
+ const count = messages.length;
181
+ if (count === 0)
182
+ return 0;
183
+ // remove 接受查询条件,会删除所有匹配行(批量语义)
184
+ await this.ctx.database.remove('rss_message_cache', where);
185
+ return count;
171
186
  }
172
187
  /**
173
188
  * 检查并自动清理
@@ -80,10 +80,23 @@ export declare function detectVideoTooBig(html: any): boolean;
80
80
  /**
81
81
  * 从 cheerio 文档里提取 "Video is too big" 占位 blockquote 内的海报图地址。
82
82
  *
83
+ * 多视频 album 时,返回**第一个**匹配 blockquote 的 poster(向后兼容)。
84
+ * 如需全部,用 extractTooBigPosters。
85
+ *
83
86
  * @param html cheerio 实例
84
87
  * @returns 找到则返回图片 URL,否则空串
85
88
  */
86
89
  export declare function extractTooBigPoster(html: any): string;
90
+ /**
91
+ * 提取所有 "Video is too big" 占位 blockquote 的海报图地址,按文档出现顺序返回。
92
+ *
93
+ * 多视频 album 场景:3 个 blockquote 各带一张 poster,
94
+ * 返回顺序与 blockquote 顺序一致,用于按位置配对注入视频。
95
+ *
96
+ * @param html cheerio 实例
97
+ * @returns poster URL 数组(无图位置返回空串占位,长度 = too-big blockquote 数)
98
+ */
99
+ export declare function extractTooBigPosters(html: any): string[];
87
100
  export interface DownloadWithTdlOptions {
88
101
  config: Config;
89
102
  /** Telegram 消息链接,如 https://t.me/anyul996/28 */
@@ -96,17 +109,18 @@ export interface DownloadWithTdlOptions {
96
109
  workDir?: string;
97
110
  }
98
111
  /**
99
- * 调用 tdl 下载单条 Telegram 消息的视频。
112
+ * 调用 tdl 下载 Telegram 消息的视频(支持多视频 album)。
100
113
  *
101
- * 命令形态:`tdl [--storage ...] [--proxy ...] dl -u <link> -d <tmpDir>`
102
- * (全局 flag 必须在子命令 dl 之前;下载产物落在 -d 指定目录)
114
+ * 命令形态:`tdl [--storage ...] [--proxy ...] dl -u <link> -d <tmpDir> --group`
115
+ * (全局 flag 必须在子命令 dl 之前;--group 自动检测相册/分组消息,下载全部媒体;
116
+ * 对单视频无副作用——自动检测到 1 项)
103
117
  *
104
118
  * 代理与会话解析优先级:
105
119
  * - `--proxy`:config.tdl.proxy(专用)→ 订阅级代理(proxyByEnv != false)
106
120
  * - `--storage`:config.tdl.storage(登录与下载必须一致,否则找不到会话)
107
121
  *
108
- * @returns 成功返回 mp4 绝对路径;二进制缺失/未登录/超时/无产物时返回 null
122
+ * @returns 成功返回视频绝对路径数组(按文件名排序 = 专辑顺序);二进制缺失/未登录/超时/无产物时返回 null
109
123
  */
110
- export declare function downloadWithTdl(opts: DownloadWithTdlOptions): Promise<string | null>;
124
+ export declare function downloadWithTdl(opts: DownloadWithTdlOptions): Promise<string[] | null>;
111
125
  /** 静默删除目录,失败不抛错 */
112
126
  export declare function safeRemoveDir(dir: string): Promise<void>;
package/lib/utils/tdl.js CHANGED
@@ -52,6 +52,7 @@ exports._resetBinaryCacheForTest = _resetBinaryCacheForTest;
52
52
  exports.parseTelegramLink = parseTelegramLink;
53
53
  exports.detectVideoTooBig = detectVideoTooBig;
54
54
  exports.extractTooBigPoster = extractTooBigPoster;
55
+ exports.extractTooBigPosters = extractTooBigPosters;
55
56
  exports.downloadWithTdl = downloadWithTdl;
56
57
  exports.safeRemoveDir = safeRemoveDir;
57
58
  const child_process_1 = require("child_process");
@@ -262,42 +263,55 @@ function detectVideoTooBig(html) {
262
263
  /**
263
264
  * 从 cheerio 文档里提取 "Video is too big" 占位 blockquote 内的海报图地址。
264
265
  *
266
+ * 多视频 album 时,返回**第一个**匹配 blockquote 的 poster(向后兼容)。
267
+ * 如需全部,用 extractTooBigPosters。
268
+ *
265
269
  * @param html cheerio 实例
266
270
  * @returns 找到则返回图片 URL,否则空串
267
271
  */
268
272
  function extractTooBigPoster(html) {
273
+ return extractTooBigPosters(html)[0] || '';
274
+ }
275
+ /**
276
+ * 提取所有 "Video is too big" 占位 blockquote 的海报图地址,按文档出现顺序返回。
277
+ *
278
+ * 多视频 album 场景:3 个 blockquote 各带一张 poster,
279
+ * 返回顺序与 blockquote 顺序一致,用于按位置配对注入视频。
280
+ *
281
+ * @param html cheerio 实例
282
+ * @returns poster URL 数组(无图位置返回空串占位,长度 = too-big blockquote 数)
283
+ */
284
+ function extractTooBigPosters(html) {
269
285
  if (!html)
270
- return '';
286
+ return [];
287
+ const posters = [];
271
288
  try {
272
- let poster = '';
273
289
  html('blockquote').each((_, el) => {
274
- if (poster)
275
- return;
276
290
  const $el = html(el);
277
291
  const text = $el.text() || '';
278
292
  if (VIDEO_TOO_BIG_PATTERN.test(text)) {
279
293
  const img = $el.find('img').first().attr('src') || '';
280
- if (img)
281
- poster = img;
294
+ posters.push(img);
282
295
  }
283
296
  });
284
- return poster;
285
297
  }
286
298
  catch {
287
- return '';
299
+ return [];
288
300
  }
301
+ return posters;
289
302
  }
290
303
  /**
291
- * 调用 tdl 下载单条 Telegram 消息的视频。
304
+ * 调用 tdl 下载 Telegram 消息的视频(支持多视频 album)。
292
305
  *
293
- * 命令形态:`tdl [--storage ...] [--proxy ...] dl -u <link> -d <tmpDir>`
294
- * (全局 flag 必须在子命令 dl 之前;下载产物落在 -d 指定目录)
306
+ * 命令形态:`tdl [--storage ...] [--proxy ...] dl -u <link> -d <tmpDir> --group`
307
+ * (全局 flag 必须在子命令 dl 之前;--group 自动检测相册/分组消息,下载全部媒体;
308
+ * 对单视频无副作用——自动检测到 1 项)
295
309
  *
296
310
  * 代理与会话解析优先级:
297
311
  * - `--proxy`:config.tdl.proxy(专用)→ 订阅级代理(proxyByEnv != false)
298
312
  * - `--storage`:config.tdl.storage(登录与下载必须一致,否则找不到会话)
299
313
  *
300
- * @returns 成功返回 mp4 绝对路径;二进制缺失/未登录/超时/无产物时返回 null
314
+ * @returns 成功返回视频绝对路径数组(按文件名排序 = 专辑顺序);二进制缺失/未登录/超时/无产物时返回 null
301
315
  */
302
316
  async function downloadWithTdl(opts) {
303
317
  const { config, link, proxyAgent } = opts;
@@ -332,7 +346,7 @@ async function downloadWithTdl(opts) {
332
346
  args.push('--proxy', proxyUrl);
333
347
  (0, logger_1.debug)(config, `tdl 使用代理: ${maskProxyUrl(proxyUrl)}`, 'tdl', 'details');
334
348
  }
335
- args.push('dl', '-u', link, '-d', workDir);
349
+ args.push('dl', '-u', link, '-d', workDir, '--group');
336
350
  const label = tdlBin === 'tdl' ? 'tdl' : tdlBin;
337
351
  (0, logger_1.debug)(config, `调用 tdl 下载: ${label} ${args.join(' ')}(超时 ${timeoutSeconds}s)`, 'tdl', 'info');
338
352
  try {
@@ -352,23 +366,24 @@ async function downloadWithTdl(opts) {
352
366
  await safeRemoveDir(workDir);
353
367
  return null;
354
368
  }
355
- // 5. 扫描产物,挑体积最大的视频文件
356
- const videoPath = pickLargestVideo(workDir);
357
- if (!videoPath) {
369
+ // 5. 扫描产物,收集全部视频文件(按文件名排序,对齐专辑顺序)
370
+ const videoPaths = pickVideoFiles(workDir);
371
+ if (videoPaths.length === 0) {
358
372
  (0, logger_1.debug)(config, `tdl 下载完成但目录内未发现视频文件: ${workDir}`, 'tdl', 'info');
359
373
  await safeRemoveDir(workDir);
360
374
  return null;
361
375
  }
362
- (0, logger_1.debug)(config, `tdl 下载成功: ${videoPath}`, 'tdl', 'info');
363
- return videoPath;
376
+ (0, logger_1.debug)(config, `tdl 下载成功(${videoPaths.length} 个视频): ${videoPaths.join(', ')}`, 'tdl', 'info');
377
+ return videoPaths;
364
378
  }
365
- /** 在目录中挑选体积最大的视频文件。
366
- * Telegram 频道视频几乎都是 mp4;tdl 也会下载同消息的配图(.jpg)和描述(.json),
367
- * 白名单只认视频扩展名。若想更严格可只认 .mp4(Telegram 视频 99% 是 mp4)。 */
368
- function pickLargestVideo(dir) {
369
- // Telegram 视频 = mp4;保留 mov/webm/mkv/m4v 兼容极少数转码产物
379
+ /** 在目录中挑选全部视频文件,按文件名升序排列。
380
+ *
381
+ * tdl --group 下文件名形如 `{dialogID}_{msgID}_{name}.mp4`,msgID 递增 = 专辑顺序,
382
+ * 按文件名排序即可对齐 RSS 描述里 blockquote 的出现顺序。
383
+ * 过滤掉 tdl 同时下载的配图(.jpg)、描述(.json);跳过 0 字节半成品。 */
384
+ function pickVideoFiles(dir) {
370
385
  const videoExts = new Set(['.mp4', '.mov', '.webm', '.mkv', '.m4v']);
371
- let best = null;
386
+ const found = [];
372
387
  try {
373
388
  const entries = fs.readdirSync(dir, { withFileTypes: true });
374
389
  for (const entry of entries) {
@@ -377,21 +392,24 @@ function pickLargestVideo(dir) {
377
392
  const ext = path.extname(entry.name).toLowerCase();
378
393
  if (!videoExts.has(ext))
379
394
  continue;
380
- const full = path.join(dir, entry.name);
395
+ // 校验非空文件(跳过 0 字节半成品)
381
396
  try {
382
- const stat = fs.statSync(full);
383
- if (!best || stat.size > best.size)
384
- best = { path: full, size: stat.size };
397
+ const stat = fs.statSync(path.join(dir, entry.name));
398
+ if (stat.size === 0)
399
+ continue;
385
400
  }
386
401
  catch {
387
- // 单文件 stat 失败忽略
402
+ continue;
388
403
  }
404
+ found.push({ path: path.join(dir, entry.name), name: entry.name });
389
405
  }
390
406
  }
391
407
  catch {
392
- return null;
408
+ return [];
393
409
  }
394
- return best?.path ?? null;
410
+ // 按文件名升序,保证多视频与 blockquote 顺序一致
411
+ found.sort((a, b) => a.name.localeCompare(b.name, 'en'));
412
+ return found.map(f => f.path);
395
413
  }
396
414
  /** 静默删除目录,失败不抛错 */
397
415
  async function safeRemoveDir(dir) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anyul/koishi-plugin-rss",
3
3
  "description": "Koishi RSS订阅器,支持多种RSS源、图片渲染、AI摘要等高级功能",
4
- "version": "5.2.47",
4
+ "version": "5.3.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "author": "Anyuluo <anyul@email.com>",