@anyul/koishi-plugin-rss 4.8.13 → 4.8.14

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/lib/index.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { Context, Schema } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Context {
4
+ assets?: {
5
+ upload(dataUrl: string, filename: string): Promise<string>;
6
+ };
7
+ }
8
+ }
2
9
  export declare const name = "@anyul/koishi-plugin-rss";
3
10
  export declare const inject: {
4
11
  required: string[];
@@ -35,8 +42,8 @@ interface BasicConfig {
35
42
  firstLoad?: boolean;
36
43
  urlDeduplication?: boolean;
37
44
  resendUpdataContent: 'disable' | 'latest' | 'all';
38
- imageMode?: 'base64' | 'File';
39
- videoMode?: 'filter' | 'href' | 'base64' | 'File';
45
+ imageMode?: 'base64' | 'File' | 'assets';
46
+ videoMode?: 'filter' | 'href' | 'base64' | 'File' | 'assets';
40
47
  autoSplitImage?: boolean;
41
48
  cacheDir?: string;
42
49
  replaceDir?: string;
@@ -102,7 +109,7 @@ export interface rssArg {
102
109
  split?: number;
103
110
  nextUpdataTime?: number;
104
111
  }
105
- export declare const usage = "\nRSS-OWL \u8BA2\u9605\u5668\u4F7F\u7528\u8BF4\u660E\n\n\u57FA\u672C\u547D\u4EE4:\n rsso <url> - \u8BA2\u9605RSS\u94FE\u63A5\n rsso -l - \u67E5\u770B\u8BA2\u9605\u5217\u8868\n rsso -l [id] - \u67E5\u770B\u8BA2\u9605\u8BE6\u60C5\n rsso -r <content> - \u5220\u9664\u8BA2\u9605(\u9700\u8981\u6743\u9650)\n rsso -T <url> - \u6D4B\u8BD5\u8BA2\u9605\n\n\u5E38\u7528\u9009\u9879:\n -i <template> - \u8BBE\u7F6E\u6D88\u606F\u6A21\u677F\n \u53EF\u9009\u503C: content(\u6587\u5B57) | default(\u56FE\u7247) | custom(\u81EA\u5B9A\u4E49) | only text | only media \u7B49\n -t <title> - \u81EA\u5B9A\u4E49\u8BA2\u9605\u6807\u9898\n -a <arg> - \u81EA\u5B9A\u4E49\u914D\u7F6E (\u683C\u5F0F: key:value,key2:value2)\n \u4F8B\u5982: -a timeout:30,merge:true\n\n\u9AD8\u7EA7\u9009\u9879:\n -f <content> - \u5173\u6CE8\u8BA2\u9605\uFF0C\u66F4\u65B0\u65F6\u63D0\u9192\n -fAll <content> - \u5168\u4F53\u5173\u6CE8(\u9700\u8981\u9AD8\u7EA7\u6743\u9650)\n -target <groupId> - \u8DE8\u7FA4\u8BA2\u9605(\u9700\u8981\u9AD8\u7EA7\u6743\u9650)\n -d <time> - \u5B9A\u65F6\u63A8\u9001 (\u683C\u5F0F: \"HH:mm/\u6570\u91CF\" \u6216 \"HH:mm\")\n \u4F8B\u5982: -d \"08:00/5\" \u8868\u793A\u6BCF\u59298\u70B9\u63A8\u90015\u6761\n -p <id> - \u624B\u52A8\u62C9\u53D6\u6700\u65B0\u5185\u5BB9\n\n\u5FEB\u901F\u8BA2\u9605:\n rsso -q - \u67E5\u770B\u5FEB\u901F\u8BA2\u9605\u5217\u8868\n rsso -q [\u7F16\u53F7] - \u67E5\u770B\u5FEB\u901F\u8BA2\u9605\u8BE6\u60C5\n rsso -T tg:channel_name - \u5FEB\u901F\u8BA2\u9605Telegram\u9891\u9053\n\n\u914D\u7F6E\u793A\u4F8B:\n rsso -T -i content \"https://example.com/rss\"\n rsso \"https://example.com/rss\" -t \"\u6211\u7684\u8BA2\u9605\" -a \"timeout:60,merge:true\"\n rsso -d \"09:00/3\" \"https://example.com/rss\"\n\n\u66F4\u591A\u4FE1\u606F\u8BF7\u8BBF\u95EE: https://github.com/borraken/koishi-plugin-rss-owl\n";
112
+ export declare const usage = "\nRSS-OWL \u8BA2\u9605\u5668\u4F7F\u7528\u8BF4\u660E\n\n\u57FA\u672C\u547D\u4EE4:\n rsso &lt;url&gt; - \u8BA2\u9605RSS\u94FE\u63A5\n rsso -l - \u67E5\u770B\u8BA2\u9605\u5217\u8868\n rsso -l [id] - \u67E5\u770B\u8BA2\u9605\u8BE6\u60C5\n rsso -r &lt;content&gt; - \u5220\u9664\u8BA2\u9605(\u9700\u8981\u6743\u9650)\n rsso -T &lt;url&gt; - \u6D4B\u8BD5\u8BA2\u9605\n\n\u5E38\u7528\u9009\u9879:\n -i &lt;template&gt; - \u8BBE\u7F6E\u6D88\u606F\u6A21\u677F\n \u53EF\u9009\u503C: content(\u6587\u5B57) | default(\u56FE\u7247) | custom(\u81EA\u5B9A\u4E49) | only text | only media \u7B49\n -t &lt;title&gt; - \u81EA\u5B9A\u4E49\u8BA2\u9605\u6807\u9898\n -a &lt;arg&gt; - \u81EA\u5B9A\u4E49\u914D\u7F6E (\u683C\u5F0F: key:value,key2:value2)\n \u4F8B\u5982: -a timeout:30,merge:true\n\n\u9AD8\u7EA7\u9009\u9879:\n -f &lt;content&gt; - \u5173\u6CE8\u8BA2\u9605\uFF0C\u66F4\u65B0\u65F6\u63D0\u9192\n -fAll &lt;content&gt; - \u5168\u4F53\u5173\u6CE8(\u9700\u8981\u9AD8\u7EA7\u6743\u9650)\n -target &lt;groupId&gt; - \u8DE8\u7FA4\u8BA2\u9605(\u9700\u8981\u9AD8\u7EA7\u6743\u9650)\n -d &lt;time&gt; - \u5B9A\u65F6\u63A8\u9001 (\u683C\u5F0F: \"HH:mm/\u6570\u91CF\" \u6216 \"HH:mm\")\n \u4F8B\u5982: -d \"08:00/5\" \u8868\u793A\u6BCF\u59298\u70B9\u63A8\u90015\u6761\n -p &lt;id&gt; - \u624B\u52A8\u62C9\u53D6\u6700\u65B0\u5185\u5BB9\n\n\u5FEB\u901F\u8BA2\u9605:\n rsso -q - \u67E5\u770B\u5FEB\u901F\u8BA2\u9605\u5217\u8868\n rsso -q [\u7F16\u53F7] - \u67E5\u770B\u5FEB\u901F\u8BA2\u9605\u8BE6\u60C5\n rsso -T tg:channel_name - \u5FEB\u901F\u8BA2\u9605Telegram\u9891\u9053\n\nAssets \u56FE\u7247/\u89C6\u9891\u670D\u52A1\u914D\u7F6E (\u63A8\u8350):\n \u4F7F\u7528 assets \u670D\u52A1\u53EF\u4EE5\u907F\u514D Base64 \u8D85\u957F\u95EE\u9898\n 1. \u5728\u63D2\u4EF6\u5E02\u573A\u5B89\u88C5 assets-xxx \u63D2\u4EF6 (\u5982 assets-local, assets-s3, assets-smms \u7B49)\n 2. \u5728\u5BF9\u5E94\u63D2\u4EF6\u4E2D\u914D\u7F6E\u5B58\u50A8\u4FE1\u606F (AccessKey, Secret, Bucket \u7B49)\n 3. \u5728 RSS-Owl \u57FA\u7840\u8BBE\u7F6E\u4E2D\u5C06 imageMode/videoMode \u8BBE\u7F6E\u4E3A 'assets'\n 4. \u63D2\u4EF6\u4F1A\u81EA\u52A8\u4E0A\u4F20\u56FE\u7247/\u89C6\u9891\u5230\u4F60\u7684\u56FE\u5E8A\u670D\u52A1\n\n\u914D\u7F6E\u793A\u4F8B:\n rsso -T -i content \"https://example.com/rss\"\n rsso \"https://example.com/rss\" -t \"\u6211\u7684\u8BA2\u9605\" -a \"timeout:60,merge:true\"\n rsso -d \"09:00/3\" \"https://example.com/rss\"\n\n";
106
113
  export declare const Config: Schema<Schemastery.ObjectS<{
107
114
  basic: Schema<Schemastery.ObjectS<{
108
115
  defaultTemplate: Schema<string, string>;
@@ -115,8 +122,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
115
122
  firstLoad: Schema<boolean, boolean>;
116
123
  urlDeduplication: Schema<boolean, boolean>;
117
124
  resendUpdataContent: Schema<"disable" | "latest" | "all", "disable" | "latest" | "all">;
118
- imageMode: Schema<"base64" | "File", "base64" | "File">;
119
- videoMode: Schema<"base64" | "File" | "filter" | "href", "base64" | "File" | "filter" | "href">;
125
+ imageMode: Schema<"assets" | "base64" | "File", "assets" | "base64" | "File">;
126
+ videoMode: Schema<"assets" | "base64" | "File" | "filter" | "href", "assets" | "base64" | "File" | "filter" | "href">;
120
127
  margeVideo: Schema<boolean, boolean>;
121
128
  usePoster: Schema<boolean, boolean>;
122
129
  autoSplitImage: Schema<boolean, boolean>;
@@ -133,8 +140,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
133
140
  firstLoad: Schema<boolean, boolean>;
134
141
  urlDeduplication: Schema<boolean, boolean>;
135
142
  resendUpdataContent: Schema<"disable" | "latest" | "all", "disable" | "latest" | "all">;
136
- imageMode: Schema<"base64" | "File", "base64" | "File">;
137
- videoMode: Schema<"base64" | "File" | "filter" | "href", "base64" | "File" | "filter" | "href">;
143
+ imageMode: Schema<"assets" | "base64" | "File", "assets" | "base64" | "File">;
144
+ videoMode: Schema<"assets" | "base64" | "File" | "filter" | "href", "assets" | "base64" | "File" | "filter" | "href">;
138
145
  margeVideo: Schema<boolean, boolean>;
139
146
  usePoster: Schema<boolean, boolean>;
140
147
  autoSplitImage: Schema<boolean, boolean>;
@@ -189,8 +196,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
189
196
  firstLoad: Schema<boolean, boolean>;
190
197
  urlDeduplication: Schema<boolean, boolean>;
191
198
  resendUpdataContent: Schema<"disable" | "latest" | "all", "disable" | "latest" | "all">;
192
- imageMode: Schema<"base64" | "File", "base64" | "File">;
193
- videoMode: Schema<"base64" | "File" | "filter" | "href", "base64" | "File" | "filter" | "href">;
199
+ imageMode: Schema<"assets" | "base64" | "File", "assets" | "base64" | "File">;
200
+ videoMode: Schema<"assets" | "base64" | "File" | "filter" | "href", "assets" | "base64" | "File" | "filter" | "href">;
194
201
  margeVideo: Schema<boolean, boolean>;
195
202
  usePoster: Schema<boolean, boolean>;
196
203
  autoSplitImage: Schema<boolean, boolean>;
@@ -207,8 +214,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
207
214
  firstLoad: Schema<boolean, boolean>;
208
215
  urlDeduplication: Schema<boolean, boolean>;
209
216
  resendUpdataContent: Schema<"disable" | "latest" | "all", "disable" | "latest" | "all">;
210
- imageMode: Schema<"base64" | "File", "base64" | "File">;
211
- videoMode: Schema<"base64" | "File" | "filter" | "href", "base64" | "File" | "filter" | "href">;
217
+ imageMode: Schema<"assets" | "base64" | "File", "assets" | "base64" | "File">;
218
+ videoMode: Schema<"assets" | "base64" | "File" | "filter" | "href", "assets" | "base64" | "File" | "filter" | "href">;
212
219
  margeVideo: Schema<boolean, boolean>;
213
220
  usePoster: Schema<boolean, boolean>;
214
221
  autoSplitImage: Schema<boolean, boolean>;
package/lib/index.js CHANGED
@@ -42,7 +42,6 @@ const koishi_1 = require("koishi");
42
42
  const axios_1 = __importDefault(require("axios"));
43
43
  const https_proxy_agent_1 = require("https-proxy-agent");
44
44
  const cheerio = __importStar(require("cheerio"));
45
- // import { } from '@koishijs/assets'
46
45
  const X2JS = require("x2js");
47
46
  const x2js = new X2JS();
48
47
  const logger = new koishi_1.Logger('rss-owl');
@@ -50,44 +49,50 @@ exports.name = '@anyul/koishi-plugin-rss';
50
49
  const url_1 = require("url");
51
50
  const fs = __importStar(require("fs"));
52
51
  const path = __importStar(require("path"));
53
- exports.inject = { required: ["database"], optional: ["puppeteer", "censor"] };
52
+ exports.inject = { required: ["database"], optional: ["puppeteer", "censor", "assets"] };
54
53
  const debugLevel = ["disable", "error", "info", "details"];
55
54
  exports.usage = `
56
55
  RSS-OWL 订阅器使用说明
57
56
 
58
57
  基本命令:
59
- rsso <url> - 订阅RSS链接
58
+ rsso &lt;url&gt; - 订阅RSS链接
60
59
  rsso -l - 查看订阅列表
61
60
  rsso -l [id] - 查看订阅详情
62
- rsso -r <content> - 删除订阅(需要权限)
63
- rsso -T <url> - 测试订阅
61
+ rsso -r &lt;content&gt; - 删除订阅(需要权限)
62
+ rsso -T &lt;url&gt; - 测试订阅
64
63
 
65
64
  常用选项:
66
- -i <template> - 设置消息模板
65
+ -i &lt;template&gt; - 设置消息模板
67
66
  可选值: content(文字) | default(图片) | custom(自定义) | only text | only media 等
68
- -t <title> - 自定义订阅标题
69
- -a <arg> - 自定义配置 (格式: key:value,key2:value2)
67
+ -t &lt;title&gt; - 自定义订阅标题
68
+ -a &lt;arg&gt; - 自定义配置 (格式: key:value,key2:value2)
70
69
  例如: -a timeout:30,merge:true
71
70
 
72
71
  高级选项:
73
- -f <content> - 关注订阅,更新时提醒
74
- -fAll <content> - 全体关注(需要高级权限)
75
- -target <groupId> - 跨群订阅(需要高级权限)
76
- -d <time> - 定时推送 (格式: "HH:mm/数量" 或 "HH:mm")
72
+ -f &lt;content&gt; - 关注订阅,更新时提醒
73
+ -fAll &lt;content&gt; - 全体关注(需要高级权限)
74
+ -target &lt;groupId&gt; - 跨群订阅(需要高级权限)
75
+ -d &lt;time&gt; - 定时推送 (格式: "HH:mm/数量" 或 "HH:mm")
77
76
  例如: -d "08:00/5" 表示每天8点推送5条
78
- -p <id> - 手动拉取最新内容
77
+ -p &lt;id&gt; - 手动拉取最新内容
79
78
 
80
79
  快速订阅:
81
80
  rsso -q - 查看快速订阅列表
82
81
  rsso -q [编号] - 查看快速订阅详情
83
82
  rsso -T tg:channel_name - 快速订阅Telegram频道
84
83
 
84
+ Assets 图片/视频服务配置 (推荐):
85
+ 使用 assets 服务可以避免 Base64 超长问题
86
+ 1. 在插件市场安装 assets-xxx 插件 (如 assets-local, assets-s3, assets-smms 等)
87
+ 2. 在对应插件中配置存储信息 (AccessKey, Secret, Bucket 等)
88
+ 3. 在 RSS-Owl 基础设置中将 imageMode/videoMode 设置为 'assets'
89
+ 4. 插件会自动上传图片/视频到你的图床服务
90
+
85
91
  配置示例:
86
92
  rsso -T -i content "https://example.com/rss"
87
93
  rsso "https://example.com/rss" -t "我的订阅" -a "timeout:60,merge:true"
88
94
  rsso -d "09:00/3" "https://example.com/rss"
89
95
 
90
- 更多信息请访问: https://github.com/borraken/koishi-plugin-rss-owl
91
96
  `;
92
97
  const templateList = ['auto', 'content', 'only text', 'only media', 'only image', 'only video', 'proto', 'default', 'only description', 'custom', 'link'];
93
98
  exports.Config = koishi_1.Schema.object({
@@ -103,8 +108,8 @@ exports.Config = koishi_1.Schema.object({
103
108
  firstLoad: koishi_1.Schema.boolean().description('首次订阅时是否发送最后的更新').default(true),
104
109
  urlDeduplication: koishi_1.Schema.boolean().description('同群组中不允许重复添加相同订阅').default(true),
105
110
  resendUpdataContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('当内容更新时再次发送').default('disable').experimental(),
106
- imageMode: koishi_1.Schema.union(['base64', 'File']).description('图片发送模式,使用File可以解决部分图片无法发送的问题,但无法在沙盒中使用').default('base64'),
107
- videoMode: koishi_1.Schema.union(['filter', 'href', 'base64', 'File']).description('视频发送模式(iframe标签内的视频无法处理)<br> \`filter\` 过滤视频,含有视频的推送将不会被发送<br> \`href\` 使用视频网络地址直接发送<br> \`base64\` 下载后以base64格式发送<br> \`File\` 下载后以文件发送').default('href'),
111
+ imageMode: koishi_1.Schema.union(['base64', 'File', 'assets']).description('图片发送模式<br>\`base64\` Base64格式(兼容性好但容易超长)<br>\`File\` 本地文件(不支持沙盒环境)<br>\`assets\` Assets服务(推荐,需安装assets-xxx插件并配置)').default('base64'),
112
+ 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'),
108
113
  margeVideo: koishi_1.Schema.boolean().default(false).description('以合并消息发送视频'),
109
114
  usePoster: koishi_1.Schema.boolean().default(false).description('加载视频封面'),
110
115
  autoSplitImage: koishi_1.Schema.boolean().description('垂直拆分大尺寸图片,解决部分适配器发不出长图的问题').default(true),
@@ -222,6 +227,25 @@ function apply(ctx, config) {
222
227
  debug('imgUrl:' + url, '', 'details');
223
228
  if (!url)
224
229
  return '';
230
+ // 如果配置了 assets 且 ctx 中有该服务,优先处理
231
+ if (config.basic.imageMode === 'assets' && ctx.assets && !useBase64Mode) {
232
+ try {
233
+ let res = await $http(url, arg, { responseType: 'arraybuffer', timeout: 30000 });
234
+ let contentType = res.headers["content-type"] || 'image/jpeg'; // 兜底 contentType
235
+ let suffix = contentType?.split('/')[1] || 'jpg';
236
+ // ★★★ 修复点:转为 Data URL 字符串再上传 ★★★
237
+ const buffer = Buffer.from(res.data, 'binary');
238
+ const base64 = `data:${contentType};base64,${buffer.toString('base64')}`;
239
+ let assetUrl = await ctx.assets.upload(base64, `rss-img-${Date.now()}.${suffix}`);
240
+ debug(`Assets 上传成功: ${assetUrl}`, 'assets', 'info');
241
+ return assetUrl;
242
+ }
243
+ catch (error) {
244
+ debug(`Assets 上传失败,降级为 Base64: ${error}`, 'assets error', 'error');
245
+ // 降级到 base64 模式(不设置 useBase64Mode 避免递归)
246
+ // 这里直接使用 base64 逻辑
247
+ }
248
+ }
225
249
  let res;
226
250
  try {
227
251
  res = await $http(url, arg, { responseType: 'arraybuffer', timeout: 30000 });
@@ -235,7 +259,7 @@ function apply(ctx, config) {
235
259
  let prefix = res.headers["content-type"] || ('image/' + (prefixList.find(i => new RegExp(i).test(url)) || 'jpeg'));
236
260
  let base64Prefix = `data:${prefix};base64,`;
237
261
  let base64Data = base64Prefix + Buffer.from(res.data, 'binary').toString('base64');
238
- if (config.basic.imageMode == 'base64' || useBase64Mode) {
262
+ if (config.basic.imageMode == 'base64' || useBase64Mode || config.basic.imageMode === 'assets') {
239
263
  return base64Data;
240
264
  }
241
265
  else if (config.basic.imageMode == 'File') {
@@ -249,29 +273,62 @@ function apply(ctx, config) {
249
273
  if (config.basic.videoMode == "href") {
250
274
  return src;
251
275
  }
252
- else {
276
+ // assets 模式
277
+ if (config.basic.videoMode === 'assets' && ctx.assets) {
253
278
  try {
254
279
  res = await $http(src, arg, { responseType: 'arraybuffer', timeout: 0 });
280
+ let contentType = res.headers["content-type"] || 'video/mp4';
281
+ let suffix = contentType?.split('/')[1] || 'mp4';
282
+ // ★★★ 修复点:转为 Data URL 字符串再上传 ★★★
283
+ const buffer = Buffer.from(res.data, 'binary');
284
+ // 注意:视频 Base64 可能会非常长,部分 assets 插件可能处理较慢,但比 Buffer 兼容性好
285
+ const base64 = `data:${contentType};base64,${buffer.toString('base64')}`;
286
+ let assetUrl = await ctx.assets.upload(base64, `rss-video-${Date.now()}.${suffix}`);
287
+ debug(`视频 Assets 上传成功: ${assetUrl}`, 'assets', 'info');
288
+ return assetUrl;
255
289
  }
256
290
  catch (error) {
257
- debug(`视频请求失败: ${error}`, 'video error', 'error');
258
- return '';
259
- }
260
- let prefix = res.headers["content-type"];
261
- let base64Prefix = `data:${prefix};base64,`;
262
- let base64Data = base64Prefix + Buffer.from(res.data, 'binary').toString('base64');
263
- if (config.basic.videoMode == 'base64') {
264
- return base64Data;
265
- }
266
- else if (config.basic.videoMode == 'File') {
267
- let fileUrl = await writeCacheFile(base64Data);
268
- return fileUrl;
291
+ debug(`视频 Assets 上传失败,降级为 Base64: ${error}`, 'assets error', 'error');
292
+ // 降级到 base64 模式
269
293
  }
270
294
  }
295
+ try {
296
+ res = await $http(src, arg, { responseType: 'arraybuffer', timeout: 0 });
297
+ }
298
+ catch (error) {
299
+ debug(`视频请求失败: ${error}`, 'video error', 'error');
300
+ return '';
301
+ }
302
+ let prefix = res.headers["content-type"];
303
+ let base64Prefix = `data:${prefix};base64,`;
304
+ let base64Data = base64Prefix + Buffer.from(res.data, 'binary').toString('base64');
305
+ if (config.basic.videoMode == 'base64' || config.basic.videoMode === 'assets') {
306
+ return base64Data;
307
+ }
308
+ else if (config.basic.videoMode == 'File') {
309
+ let fileUrl = await writeCacheFile(base64Data);
310
+ return fileUrl;
311
+ }
271
312
  };
272
313
  const puppeteerToFile = async (puppeteer) => {
273
- let base64 = /(?<=src=").+?(?=")/.exec(puppeteer)[0];
314
+ let base64 = /(?<=src=").+?(?=")/.exec(puppeteer)?.[0];
315
+ if (!base64)
316
+ return puppeteer;
274
317
  const buffer = Buffer.from(base64.substring(base64.indexOf(',') + 1), 'base64');
318
+ // assets 模式
319
+ if (config.basic.imageMode === 'assets' && ctx.assets) {
320
+ try {
321
+ // ★★★ 修复点:直接传递 base64 字符串给 upload,不要转 Buffer ★★★
322
+ // base64 变量本身就是 "data:image/png;base64,..." 格式
323
+ const url = await ctx.assets.upload(base64, `rss-screenshot-${Date.now()}.png`);
324
+ debug(`截图 Assets 上传成功: ${url}`, 'assets', 'info');
325
+ return `<img src="${url}"/>`;
326
+ }
327
+ catch (error) {
328
+ debug(`截图 Assets 上传失败,降级为 File: ${error}`, 'assets error', 'error');
329
+ // 降级到 File 模式
330
+ }
331
+ }
275
332
  const MB = buffer.length / 1e+6;
276
333
  debug("MB: " + MB, 'file size', 'details');
277
334
  return `<file src="${await writeCacheFile(base64)}"/>`;
@@ -452,6 +509,20 @@ function apply(ctx, config) {
452
509
  await page.setContent(htmlContent);
453
510
  if (!config.basic.autoSplitImage) {
454
511
  const image = await page.screenshot({ type: "png" });
512
+ // assets 模式
513
+ if (config.basic.imageMode === 'assets' && ctx.assets) {
514
+ try {
515
+ // ★★★ 修复点:Buffer 转 Data URL ★★★
516
+ const base64 = `data:image/png;base64,${image.toString('base64')}`;
517
+ const url = await ctx.assets.upload(base64, `rss-shot-${Date.now()}.png`);
518
+ debug(`HTML截图 Assets 上传成功: ${url}`, 'assets', 'info');
519
+ return koishi_1.h.image(url);
520
+ }
521
+ catch (error) {
522
+ debug(`HTML截图 Assets 上传失败,降级为 Base64: ${error}`, 'assets error', 'error');
523
+ // 降级到 base64
524
+ }
525
+ }
455
526
  return koishi_1.h.image(image, 'image/png');
456
527
  }
457
528
  let [height, width, x, y] = await page.evaluate(() => [
@@ -465,6 +536,20 @@ function apply(ctx, config) {
465
536
  const split = Math.ceil(height / size);
466
537
  if (!split) {
467
538
  const image = await page.screenshot({ type: "png", clip: { x, y, width, height } });
539
+ // assets 模式
540
+ if (config.basic.imageMode === 'assets' && ctx.assets) {
541
+ try {
542
+ // ★★★ 修复点:Buffer 转 Data URL ★★★
543
+ const base64 = `data:image/png;base64,${image.toString('base64')}`;
544
+ const url = await ctx.assets.upload(base64, `rss-shot-${Date.now()}.png`);
545
+ debug(`HTML截图 Assets 上传成功: ${url}`, 'assets', 'info');
546
+ return koishi_1.h.image(url);
547
+ }
548
+ catch (error) {
549
+ debug(`HTML截图 Assets 上传失败,降级为 Base64: ${error}`, 'assets error', 'error');
550
+ // 降级到 base64
551
+ }
552
+ }
468
553
  return koishi_1.h.image(image, 'image/png');
469
554
  }
470
555
  debug({ height, width, split }, 'split img', 'details');
@@ -479,6 +564,22 @@ function apply(ctx, config) {
479
564
  height: reduceHeight(i)
480
565
  }
481
566
  })));
567
+ // assets 模式
568
+ if (config.basic.imageMode === 'assets' && ctx.assets) {
569
+ try {
570
+ // ★★★ 修复点:Buffer 数组转 Data URL 数组 ★★★
571
+ const urls = await Promise.all(imgData.map((buf, i) => {
572
+ const base64 = `data:image/png;base64,${buf.toString('base64')}`;
573
+ return ctx.assets.upload(base64, `rss-split-${Date.now()}-${i}.png`);
574
+ }));
575
+ debug(`切割截图 Assets 上传成功: ${urls.length} 个文件`, 'assets', 'info');
576
+ return urls.map(u => koishi_1.h.image(u)).join("");
577
+ }
578
+ catch (error) {
579
+ debug(`切割截图 Assets 上传失败,降级为 Base64: ${error}`, 'assets error', 'error');
580
+ // 降级到 base64
581
+ }
582
+ }
482
583
  return imgData.map(i => koishi_1.h.image(i, 'image/png')).join("");
483
584
  }
484
585
  finally {
@@ -610,25 +711,31 @@ function apply(ctx, config) {
610
711
  }
611
712
  msg = parseContent(config.template.customRemark, { ...item, arg, description: msg });
612
713
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
613
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
714
+ // ★★★ 修复点:过滤掉没有 src 的视频 ★★★
715
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
614
716
  }
615
717
  else if (template == "content") {
616
718
  html = cheerio.load(item.description);
617
719
  let imgList = [];
618
720
  html('img').map((key, i) => imgList.push(i.attribs.src));
619
721
  imgList = [...new Set(imgList)];
722
+ // 获取所有图片链接
620
723
  let imgBufferList = Object.assign({}, ...(await Promise.all(imgList.map(async (src) => ({ [src]: await getImageUrl(src, arg) })))));
724
+ // 占位符替换
621
725
  html('img').replaceWith((key, Dom) => `<p>$img{{${imgList[key]}}}</p>`);
622
726
  msg = html.text();
727
+ // ★★★ 修复点:如果 finalUrl 为空,返回空字符串,不要生成 <img src=""/> ★★★
623
728
  item.description = msg.replace(/\$img\{\{(.*?)\}\}/g, (match) => {
624
729
  let src = match.match(/\$img\{\{(.*?)\}\}/)[1];
625
- return `<img src="${imgBufferList[src]}"/>`;
730
+ let finalUrl = imgBufferList[src];
731
+ return finalUrl ? `<img src="${finalUrl}"/>` : '';
626
732
  });
627
733
  msg = parseContent(config.template.content, { ...item, arg });
628
- // 【修复 1】: 删除了这里的 logger.info(msg),因为函数末尾还会打印一次
629
734
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
630
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
631
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('img', { src: poster })).join("");
735
+ // ★★★ 修复点:过滤掉没有 src 的视频 ★★★
736
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
737
+ // ★★★ 修复点:过滤掉没有 src 的视频封面图 ★★★
738
+ msg += videoList.filter(([src, poster]) => poster).map(([src, poster]) => (0, koishi_1.h)('img', { src: poster })).join("");
632
739
  }
633
740
  else if (template == "only text") {
634
741
  html = cheerio.load(item.description);
@@ -639,21 +746,25 @@ function apply(ctx, config) {
639
746
  let imgList = [];
640
747
  html('img').map((key, i) => imgList.push(i.attribs.src));
641
748
  imgList = await Promise.all([...new Set(imgList)].map(async (src) => await getImageUrl(src, arg)));
642
- msg = imgList.map(img => `<img src="${img}"/>`).join("");
749
+ // ★★★ 修复点:过滤空图片 ★★★
750
+ msg = imgList.filter(Boolean).map(img => `<img src="${img}"/>`).join("");
643
751
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
644
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
752
+ // ★★★ 修复点:过滤空视频 ★★★
753
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
645
754
  }
646
755
  else if (template == "only image") {
647
756
  html = cheerio.load(item.description);
648
757
  let imgList = [];
649
758
  html('img').map((key, i) => imgList.push(i.attribs.src));
650
759
  imgList = await Promise.all([...new Set(imgList)].map(async (src) => await getImageUrl(src, arg)));
651
- msg = imgList.map(img => `<img src="${img}"/>`).join("");
760
+ // ★★★ 修复点:过滤空图片 ★★★
761
+ msg = imgList.filter(Boolean).map(img => `<img src="${img}"/>`).join("");
652
762
  }
653
763
  else if (template == "only video") {
654
764
  html = cheerio.load(item.description);
655
765
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
656
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
766
+ // ★★★ 修复点:过滤掉没有 src 的视频 ★★★
767
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
657
768
  }
658
769
  else if (template == "proto") {
659
770
  msg = item.description;
@@ -676,7 +787,8 @@ function apply(ctx, config) {
676
787
  if (config.basic.imageMode == 'File')
677
788
  msg = await puppeteerToFile(msg);
678
789
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
679
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
790
+ // ★★★ 修复点:过滤掉没有 src 的视频 ★★★
791
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
680
792
  }
681
793
  else if (template == "only description") {
682
794
  item.description = parseContent(getDescriptionTemplate(config.template.bodyWidth, config.template.bodyPadding, config.template.bodyFontSize), { ...item, arg });
@@ -693,7 +805,8 @@ function apply(ctx, config) {
693
805
  msg = await puppeteerToFile(msg);
694
806
  }
695
807
  await Promise.all(html('video').map(async (v, i) => videoList.push([await getVideoUrl(i.attribs.src, arg, true, i), (i.attribs.poster && config.basic.usePoster) ? await getImageUrl(i.attribs.poster, arg, true) : ""])));
696
- msg += videoList.map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
808
+ // ★★★ 修复点:过滤掉没有 src 的视频 ★★★
809
+ msg += videoList.filter(([src]) => src).map(([src, poster]) => (0, koishi_1.h)('video', { src, poster })).join("");
697
810
  }
698
811
  else if (template == "link") {
699
812
  html = cheerio.load(item.description);
@@ -802,8 +915,11 @@ function apply(ctx, config) {
802
915
  messageList = await Promise.all(rssItemArray.reverse().map(async (i) => await parseRssItem(i, { ...rssItem, ...arg }, rssItem.author)));
803
916
  }
804
917
  let message;
805
- if (!messageList.join(""))
806
- return;
918
+ if (!messageList.join("")) {
919
+ // 如果解析不出内容,也应该更新时间,防止反复解析空内容
920
+ await ctx.database.set('rssOwl', { id: rssItem.id }, { lastPubDate, arg: originalArg, lastContent });
921
+ continue;
922
+ }
807
923
  if (arg.merge === true) {
808
924
  message = `<message forward><author id="${rssItem.author}"/>${messageList.join("")}</message>`;
809
925
  }
@@ -826,11 +942,27 @@ function apply(ctx, config) {
826
942
  if (rssItem.followers.length) {
827
943
  message += `<message>${rssItem.followers.map(followId => `<at ${followId == 'all' ? 'type' : 'id'}='${followId}'/>`)}</message>`;
828
944
  }
829
- const broadcast = await ctx.broadcast([`${rssItem.platform}:${rssItem.guildId}`], message);
830
- if (!broadcast[0])
831
- throw new Error('发送失败');
832
- await ctx.database.set('rssOwl', { id: rssItem.id }, { lastPubDate, arg: originalArg, lastContent });
833
- debug(`更新成功:${rssItem.title}`, '', 'info');
945
+ // 发送逻辑改进:捕获发送错误,防止死循环
946
+ try {
947
+ const broadcast = await ctx.broadcast([`${rssItem.platform}:${rssItem.guildId}`], message);
948
+ if (!broadcast.length) {
949
+ logger.warn(`RSS [${rssItem.title}] 消息生成成功但未发送给任何目标 (可能群不存在或Bot被禁言)`);
950
+ }
951
+ else {
952
+ debug(`更新成功:${rssItem.title}`, '', 'info');
953
+ }
954
+ }
955
+ catch (sendError) {
956
+ logger.error(`RSS推送失败 [${rssItem.title}]: ${sendError.message}`);
957
+ logger.warn(`已跳过该条 RSS 更新以防止无限重试循环。`);
958
+ }
959
+ // 关键:无论发送成功还是失败,都更新数据库状态,防止死循环
960
+ try {
961
+ await ctx.database.set('rssOwl', { id: rssItem.id }, { lastPubDate, arg: originalArg, lastContent });
962
+ }
963
+ catch (dbError) {
964
+ logger.error(`数据库更新失败: ${dbError}`);
965
+ }
834
966
  }
835
967
  catch (error) {
836
968
  debug(error, `更新失败:${JSON.stringify({ ...rssItem, lastContent: "..." })}`, 'error');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anyul/koishi-plugin-rss",
3
3
  "description": "Koishi RSS订阅器,支持多种RSS源和代理配置",
4
- "version": "4.8.13",
4
+ "version": "4.8.14",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "author": "Anyuluo <anyul@email.com>",
package/readme.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-rss-owl?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-rss-owl)
4
4
 
5
5
  rss-owl 是一个基于[koishi](https://koishi.chat/manual/starter/)的RSS订阅工具
6
-
6
+ 编译 npx tsc
7
7
  ## 使用方法
8
8
 
9
9
  ### 1. 基本使用