koishi-plugin-video-parser-all 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare const Config: Schema<{
15
15
  tempDir?: string | null | undefined;
16
16
  maxVideoSize?: number | null | undefined;
17
17
  forceDownloadVideo?: boolean | null | undefined;
18
+ videoLoadWaitTime?: number | null | undefined;
18
19
  } & {
19
20
  timeout?: number | null | undefined;
20
21
  videoSendTimeout?: number | null | undefined;
@@ -46,6 +47,7 @@ export declare const Config: Schema<{
46
47
  tempDir: string;
47
48
  maxVideoSize: number;
48
49
  forceDownloadVideo: boolean;
50
+ videoLoadWaitTime: number;
49
51
  } & {
50
52
  timeout: number;
51
53
  videoSendTimeout: number;
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ const promises_1 = __importDefault(require("fs/promises"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const fs_1 = require("fs");
13
13
  const promises_2 = require("stream/promises");
14
+ const lru_cache_1 = require("lru-cache");
14
15
  exports.name = 'video-parser-all';
15
16
  exports.Config = koishi_1.Schema.intersect([
16
17
  koishi_1.Schema.object({
@@ -25,24 +26,25 @@ exports.Config = koishi_1.Schema.intersect([
25
26
  koishi_1.Schema.object({
26
27
  showImageText: koishi_1.Schema.boolean().default(true).description('是否发送解析后的文字内容'),
27
28
  showVideoFile: koishi_1.Schema.boolean().default(true).description('是否发送视频文件(关闭则只发送视频链接)'),
28
- maxDescLength: koishi_1.Schema.number().default(200).description('简介内容最大长度(字符),超出自动截断'),
29
- videoDownloadTimeout: koishi_1.Schema.number().default(120000).description('视频下载超时(毫秒)'),
29
+ maxDescLength: koishi_1.Schema.number().min(0).step(1).default(200).description('简介内容最大长度(字符),超出自动截断'),
30
+ videoDownloadTimeout: koishi_1.Schema.number().min(0).step(1).default(120000).description('视频下载超时(毫秒)'),
30
31
  tempDir: koishi_1.Schema.string().default('./temp_videos').description('临时视频存储目录'),
31
32
  maxVideoSize: koishi_1.Schema.number().min(0).step(1).default(0).description('最大下载视频大小(MB),0 为不限制大小'),
32
- forceDownloadVideo: koishi_1.Schema.boolean().default(true).description('强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题)'),
33
+ forceDownloadVideo: koishi_1.Schema.boolean().default(false).description('强制下载视频后发送'),
34
+ videoLoadWaitTime: koishi_1.Schema.number().min(0).step(1).default(180000).description('视频链接加载等待时间(毫秒),获取到视频链接后等待指定时间再发送,0为不等待'),
33
35
  }).description('内容显示设置'),
34
36
  koishi_1.Schema.object({
35
- timeout: koishi_1.Schema.number().min(0).default(180000).description('API 请求超时(毫秒)'),
36
- videoSendTimeout: koishi_1.Schema.number().min(0).default(60000).description('视频消息发送超时(毫秒,0 为不限制)'),
37
- userAgent: koishi_1.Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36').description('API 请求 UA'),
37
+ timeout: koishi_1.Schema.number().min(0).step(1).default(180000).description('API 请求超时(毫秒)'),
38
+ videoSendTimeout: koishi_1.Schema.number().min(0).step(1).default(60000).description('视频消息发送超时(毫秒,0 为不限制)'),
39
+ userAgent: koishi_1.Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36').description('API 请求 UA'),
38
40
  }).description('网络与 API 设置'),
39
41
  koishi_1.Schema.object({
40
42
  ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败,避免插件崩溃'),
41
- retryTimes: koishi_1.Schema.number().min(0).default(3).description('API 请求及消息发送失败时的重试次数'),
42
- retryInterval: koishi_1.Schema.number().min(0).default(1000).description('重试间隔(毫秒,同时用于消息发送重试)'),
43
+ retryTimes: koishi_1.Schema.number().min(0).step(1).default(3).description('API 请求及消息发送失败时的重试次数'),
44
+ retryInterval: koishi_1.Schema.number().min(0).step(1).default(1000).description('重试间隔(毫秒,同时用于消息发送重试)'),
43
45
  }).description('错误与重试设置'),
44
46
  koishi_1.Schema.object({
45
- enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅 OneBot 平台),视频会单独发送'),
47
+ enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅 OneBot 平台)'),
46
48
  }).description('发送方式设置'),
47
49
  koishi_1.Schema.object({
48
50
  waitingTipText: koishi_1.Schema.string().default('正在解析视频,请稍候...').description('解析等待提示'),
@@ -58,39 +60,49 @@ function debugLog(level, ...args) {
58
60
  if (!debugEnabled)
59
61
  return;
60
62
  const timestamp = new Date().toISOString();
61
- const message = `[${timestamp}] [${level}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}`;
63
+ const message = `[${timestamp}] [${level}] ${args.map(a => {
64
+ if (typeof a === 'object') {
65
+ try {
66
+ return JSON.stringify(a, null, 2);
67
+ }
68
+ catch {
69
+ return String(a);
70
+ }
71
+ }
72
+ return String(a);
73
+ }).join(' ')}`;
62
74
  logger.info(message);
63
75
  }
64
76
  function linkTypeParser(content) {
65
77
  content = content.replace(/\\\//g, '/');
66
78
  const rules = [
67
79
  { pattern: /bilibili\.com\/video\/([ab]v[0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://www.bilibili.com/video/${id}` },
68
- { pattern: /b23\.tv(?:\\)?\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://b23.tv/${id}` },
69
- { pattern: /bili(?:22|23|33)\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili23.cn/${id}` },
70
- { pattern: /bili2233\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili2233.cn/${id}` },
71
- { pattern: /douyin\.com\/video\/(\d+)/gi, type: 'douyin', buildUrl: (id) => `https://www.douyin.com/video/${id}` },
72
- { pattern: /v\.douyin\.com\/([0-9a-zA-Z]+)/gi, type: 'douyin', buildUrl: (id) => `https://v.douyin.com/${id}` },
73
- { pattern: /kuaishou\.com\/short-video\/([0-9a-zA-Z]+)/gi, type: 'kuaishou', buildUrl: (id) => `https://www.kuaishou.com/short-video/${id}` },
74
- { pattern: /v\.kuaishou\.com\/([0-9a-zA-Z]+)/gi, type: 'kuaishou', buildUrl: (id) => `https://v.kuaishou.com/${id}` },
75
- { pattern: /xiaohongshu\.com\/discovery\/item\/([0-9a-zA-Z]+)/gi, type: 'xiaohongshu', buildUrl: (id) => `https://www.xiaohongshu.com/discovery/item/${id}` },
76
- { pattern: /xhslink\.com\/([0-9a-zA-Z]+)/gi, type: 'xiaohongshu', buildUrl: (id) => `https://xhslink.com/${id}` },
77
- { pattern: /weibo\.com\/\d+\/([0-9a-zA-Z]+)/gi, type: 'weibo', buildUrl: (id) => `https://weibo.com/${id}` },
78
- { pattern: /video\.weibo\.com\/show\?fid=([0-9a-zA-Z]+)/gi, type: 'weibo', buildUrl: (id) => `https://video.weibo.com/show?fid=${id}` },
79
- { pattern: /ixigua\.com\/(\d+)/gi, type: 'xigua', buildUrl: (id) => `https://www.ixigua.com/${id}` },
80
- { pattern: /youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/gi, type: 'youtube', buildUrl: (id) => `https://www.youtube.com/watch?v=${id}` },
81
- { pattern: /youtu\.be\/([a-zA-Z0-9_-]+)/gi, type: 'youtube', buildUrl: (id) => `https://youtu.be/${id}` },
82
- { pattern: /tiktok\.com\/@[\w.]+\/video\/(\d+)/gi, type: 'tiktok', buildUrl: (id) => `https://www.tiktok.com/@user/video/${id}` },
83
- { pattern: /vm\.tiktok\.com\/([0-9a-zA-Z]+)/gi, type: 'tiktok', buildUrl: (id) => `https://vm.tiktok.com/${id}` },
84
- { pattern: /acfun\.cn\/v\/(ac\d+)/gi, type: 'acfun', buildUrl: (id) => `https://www.acfun.cn/v/${id}` },
85
- { pattern: /zhihu\.com\/video\/(\d+)/gi, type: 'zhihu', buildUrl: (id) => `https://www.zhihu.com/video/${id}` },
86
- { pattern: /weishi\.qq\.com\/weishi\/feed\/([0-9a-zA-Z]+)/gi, type: 'weishi', buildUrl: (id) => `https://weishi.qq.com/weishi/feed/${id}` },
87
- { pattern: /huya\.com\/video\/([0-9a-zA-Z]+)/gi, type: 'huya', buildUrl: (id) => `https://www.huya.com/video/${id}` },
88
- { pattern: /haokan\.baidu\.com\/v\?vid=([0-9a-zA-Z]+)/gi, type: 'haokan', buildUrl: (id) => `https://haokan.baidu.com/v?vid=${id}` },
89
- { pattern: /meipai\.com\/media\/(\d+)/gi, type: 'meipai', buildUrl: (id) => `https://www.meipai.com/media/${id}` },
90
- { pattern: /twitter\.com\/\w+\/status\/(\d+)/gi, type: 'twitter', buildUrl: (id) => `https://twitter.com/i/status/${id}` },
91
- { pattern: /x\.com\/\w+\/status\/(\d+)/gi, type: 'twitter', buildUrl: (id) => `https://x.com/i/status/${id}` },
92
- { pattern: /instagram\.com\/p\/([0-9a-zA-Z_-]+)/gi, type: 'instagram', buildUrl: (id) => `https://www.instagram.com/p/${id}` },
93
- { pattern: /doubao\.com\/video\/(\d+)/gi, type: 'doubao', buildUrl: (id) => `https://www.doubao.com/video/${id}` },
80
+ { pattern: /b23\.tv\/([0-9a-zA-Z]{5,})/gi, type: 'bilibili', buildUrl: (id) => `https://b23.tv/${id}` },
81
+ { pattern: /bili(?:22|23|33)\.cn\/([0-9a-zA-Z]{5,})/gi, type: 'bilibili', buildUrl: (id) => `https://bili23.cn/${id}` },
82
+ { pattern: /bili2233\.cn\/([0-9a-zA-Z]{5,})/gi, type: 'bilibili', buildUrl: (id) => `https://bili2233.cn/${id}` },
83
+ { pattern: /douyin\.com\/video\/(\d{10,})/gi, type: 'douyin', buildUrl: (id) => `https://www.douyin.com/video/${id}` },
84
+ { pattern: /v\.douyin\.com\/([0-9a-zA-Z]{8,})/gi, type: 'douyin', buildUrl: (id) => `https://v.douyin.com/${id}/` },
85
+ { pattern: /kuaishou\.com\/short-video\/([0-9a-zA-Z]{10,})/gi, type: 'kuaishou', buildUrl: (id) => `https://www.kuaishou.com/short-video/${id}` },
86
+ { pattern: /v\.kuaishou\.com\/([0-9a-zA-Z]{8,})/gi, type: 'kuaishou', buildUrl: (id) => `https://v.kuaishou.com/${id}` },
87
+ { pattern: /xiaohongshu\.com\/discovery\/item\/([0-9a-zA-Z]{10,})/gi, type: 'xiaohongshu', buildUrl: (id) => `https://www.xiaohongshu.com/discovery/item/${id}` },
88
+ { pattern: /xhslink\.com\/([0-9a-zA-Z]{8,})/gi, type: 'xiaohongshu', buildUrl: (id) => `https://xhslink.com/${id}` },
89
+ { pattern: /weibo\.com\/\d+\/([0-9a-zA-Z]{10,})/gi, type: 'weibo', buildUrl: (id) => `https://weibo.com/${id}` },
90
+ { pattern: /video\.weibo\.com\/show\?fid=([0-9a-zA-Z]{10,})/gi, type: 'weibo', buildUrl: (id) => `https://video.weibo.com/show?fid=${id}` },
91
+ { pattern: /ixigua\.com\/(\d{10,})/gi, type: 'xigua', buildUrl: (id) => `https://www.ixigua.com/${id}` },
92
+ { pattern: /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/gi, type: 'youtube', buildUrl: (id) => `https://www.youtube.com/watch?v=${id}` },
93
+ { pattern: /youtu\.be\/([a-zA-Z0-9_-]{11})/gi, type: 'youtube', buildUrl: (id) => `https://youtu.be/${id}` },
94
+ { pattern: /tiktok\.com\/@[\w.]+\/video\/(\d{10,})/gi, type: 'tiktok', buildUrl: (id) => `https://www.tiktok.com/@user/video/${id}` },
95
+ { pattern: /vm\.tiktok\.com\/([0-9a-zA-Z]{8,})/gi, type: 'tiktok', buildUrl: (id) => `https://vm.tiktok.com/${id}` },
96
+ { pattern: /acfun\.cn\/v\/(ac\d{10,})/gi, type: 'acfun', buildUrl: (id) => `https://www.acfun.cn/v/${id}` },
97
+ { pattern: /zhihu\.com\/video\/(\d{10,})/gi, type: 'zhihu', buildUrl: (id) => `https://www.zhihu.com/video/${id}` },
98
+ { pattern: /weishi\.qq\.com\/weishi\/feed\/([0-9a-zA-Z]{10,})/gi, type: 'weishi', buildUrl: (id) => `https://weishi.qq.com/weishi/feed/${id}` },
99
+ { pattern: /huya\.com\/video\/([0-9a-zA-Z]{10,})/gi, type: 'huya', buildUrl: (id) => `https://www.huya.com/video/${id}` },
100
+ { pattern: /haokan\.baidu\.com\/v\?vid=([0-9a-zA-Z]{10,})/gi, type: 'haokan', buildUrl: (id) => `https://haokan.baidu.com/v?vid=${id}` },
101
+ { pattern: /meipai\.com\/media\/(\d{10,})/gi, type: 'meipai', buildUrl: (id) => `https://www.meipai.com/media/${id}` },
102
+ { pattern: /twitter\.com\/\w+\/status\/(\d{10,})/gi, type: 'twitter', buildUrl: (id) => `https://twitter.com/i/status/${id}` },
103
+ { pattern: /x\.com\/\w+\/status\/(\d{10,})/gi, type: 'twitter', buildUrl: (id) => `https://x.com/i/status/${id}` },
104
+ { pattern: /instagram\.com\/p\/([0-9a-zA-Z_-]{10,})/gi, type: 'instagram', buildUrl: (id) => `https://www.instagram.com/p/${id}` },
105
+ { pattern: /doubao\.com\/video\/(\d{10,})/gi, type: 'doubao', buildUrl: (id) => `https://www.doubao.com/video/${id}` },
94
106
  ];
95
107
  const matches = [];
96
108
  const seen = new Set();
@@ -108,17 +120,30 @@ function linkTypeParser(content) {
108
120
  return matches;
109
121
  }
110
122
  function extractUrl(content) {
111
- const urlMatches = content.match(/https?:\/\/[^\s\"\'\>]+/gi) || [];
123
+ if (!content)
124
+ return [];
125
+ const urlMatches = content.match(/https?:\/\/[^\s<>"'(){}[\]]+/gi) || [];
112
126
  return urlMatches.filter(url => {
113
127
  try {
114
- const hostname = new URL(url).hostname.toLowerCase();
115
- if (hostname === 'multimedia.nt.qq.com.cn')
128
+ const urlObj = new URL(url);
129
+ const hostname = urlObj.hostname.toLowerCase();
130
+ if (hostname.includes('multimedia.nt.qq.com.cn') ||
131
+ hostname.includes('grouptalk.qq.com') ||
132
+ hostname.includes('qpic.cn') ||
133
+ hostname.includes('qlogo.cn')) {
134
+ return false;
135
+ }
136
+ if (hostname === 'v.douyin.com' && urlObj.pathname.length < 3)
137
+ return false;
138
+ if (hostname === 'www.douyin.com' && urlObj.pathname === '/')
116
139
  return false;
117
140
  return true;
118
141
  }
119
142
  catch {
120
143
  return false;
121
144
  }
145
+ }).map(url => {
146
+ return url.replace(/[.,;:!?)]+$/, '');
122
147
  });
123
148
  }
124
149
  function extractAllUrlsFromMessage(session) {
@@ -138,10 +163,11 @@ function extractAllUrlsFromMessage(session) {
138
163
  if (session.elements) {
139
164
  for (const elem of session.elements) {
140
165
  if (elem.type === 'xml' && elem.data) {
141
- const urlRegex = /https?:\/\/[^\s<>"']+/gi;
166
+ const urlRegex = /https?:\/\/[^\s<>"'(){}[\]]+/gi;
142
167
  let match;
143
168
  while ((match = urlRegex.exec(elem.data)) !== null) {
144
- urls.push(match[0]);
169
+ const cleanUrl = match[0].replace(/[.,;:!?)]+$/, '');
170
+ urls.push(cleanUrl);
145
171
  }
146
172
  }
147
173
  else if (elem.type === 'json' && elem.data) {
@@ -152,9 +178,13 @@ function extractAllUrlsFromMessage(session) {
152
178
  return;
153
179
  for (const val of Object.values(obj)) {
154
180
  if (typeof val === 'string') {
155
- const match = val.match(/https?:\/\/[^\s<>"']+/gi);
156
- if (match)
157
- urls.push(...match);
181
+ const match = val.match(/https?:\/\/[^\s<>"'(){}[\]]+/gi);
182
+ if (match) {
183
+ match.forEach(url => {
184
+ const cleanUrl = url.replace(/[.,;:!?)]+$/, '');
185
+ urls.push(cleanUrl);
186
+ });
187
+ }
158
188
  }
159
189
  else if (typeof val === 'object')
160
190
  extractFromObject(val);
@@ -162,24 +192,49 @@ function extractAllUrlsFromMessage(session) {
162
192
  };
163
193
  extractFromObject(json);
164
194
  }
165
- catch { }
195
+ catch (e) {
196
+ debugLog('WARN', '解析JSON卡片失败:', e);
197
+ }
166
198
  }
167
199
  }
168
200
  }
169
- return [...new Set(urls)];
201
+ return [...new Set(urls)].filter(url => {
202
+ try {
203
+ const urlObj = new URL(url);
204
+ if (urlObj.hostname === 'v.douyin.com' && urlObj.pathname.length < 3)
205
+ return false;
206
+ if (urlObj.hostname === 'www.douyin.com' && urlObj.pathname === '/')
207
+ return false;
208
+ return true;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ });
170
214
  }
171
215
  function cleanUrl(url) {
172
216
  try {
173
217
  url = url.replace(/&amp;/g, '&');
174
218
  const urlObj = new URL(url);
219
+ if (urlObj.protocol === 'http:') {
220
+ urlObj.protocol = 'https:';
221
+ }
175
222
  if (urlObj.hostname.includes('douyin.com') || urlObj.hostname.includes('v.douyin.com')) {
176
- urlObj.searchParams.delete('source');
177
- urlObj.searchParams.delete('share_type');
223
+ ['source', 'share_type', 'share_token', 'timestamp', 'from', 'isappinstalled'].forEach(p => {
224
+ urlObj.searchParams.delete(p);
225
+ });
226
+ return urlObj.origin + urlObj.pathname;
227
+ }
228
+ if (urlObj.hostname.includes('bilibili.com') || urlObj.hostname.includes('b23.tv')) {
229
+ ['share_source', 'share_medium', 'share_plat', 'share_session_id', 'share_tag', 'timestamp'].forEach(p => {
230
+ urlObj.searchParams.delete(p);
231
+ });
178
232
  return urlObj.origin + urlObj.pathname;
179
233
  }
180
- return url;
234
+ return urlObj.toString();
181
235
  }
182
236
  catch (e) {
237
+ debugLog('WARN', '清理URL失败:', e, '原始URL:', url);
183
238
  return url.replace(/&amp;/g, '&').replace(/\?.*/, '');
184
239
  }
185
240
  }
@@ -198,6 +253,7 @@ async function resolveShortUrl(url) {
198
253
  return cleanUrl(finalUrl);
199
254
  }
200
255
  catch (e) {
256
+ debugLog('WARN', '解析短链接失败:', e, '原始URL:', url);
201
257
  return cleanUrl(url);
202
258
  }
203
259
  }
@@ -209,7 +265,7 @@ function formatDuration(seconds) {
209
265
  const s = Math.floor(seconds % 60);
210
266
  if (h > 0)
211
267
  return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
212
- return `${m}:${s.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
268
+ return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
213
269
  }
214
270
  function formatPublishTime(ms) {
215
271
  if (!ms)
@@ -222,8 +278,13 @@ function pickBestQuality(videoBackup) {
222
278
  if (!Array.isArray(videoBackup))
223
279
  return [];
224
280
  return videoBackup
225
- .map(v => ({ quality: v.quality || v.label, url: v.url, bit_rate: v.bit_rate || 0 }))
226
- .sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
281
+ .filter(v => v && v.url)
282
+ .map(v => ({
283
+ quality: v.quality || v.label || 'unknown',
284
+ url: v.url,
285
+ bit_rate: Number(v.bit_rate || 0)
286
+ }))
287
+ .sort((a, b) => b.bit_rate - a.bit_rate);
227
288
  }
228
289
  function parseApiResponse(raw, maxDescLen) {
229
290
  debugLog('DEBUG', '原始API返回数据:', raw);
@@ -253,24 +314,44 @@ function parseApiResponse(raw, maxDescLen) {
253
314
  avatar = data.avatar || '';
254
315
  }
255
316
  const title = data.title || '';
256
- const desc = (data.desc || data.description || '').slice(0, maxDescLen);
317
+ const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
257
318
  const cover = data.cover || '';
258
319
  let video = '';
259
320
  let videos = [];
260
321
  if (Array.isArray(data.video_backup) && data.video_backup.length) {
261
322
  const bestQ = pickBestQuality(data.video_backup);
262
323
  videos = bestQ;
263
- video = bestQ[0]?.url || data.url || '';
324
+ video = bestQ[0]?.url || '';
264
325
  }
265
- else if (Array.isArray(data.videos) && data.videos.length) {
266
- video = data.videos[0]?.url || '';
267
- videos = data.videos.map((v) => ({ quality: v.accept?.[0] || 'unknown', url: v.url }));
326
+ if (!video && Array.isArray(data.videos) && data.videos.length) {
327
+ const validVideos = data.videos.filter((v) => v && v.url);
328
+ if (validVideos.length) {
329
+ video = validVideos[0].url;
330
+ videos = validVideos.map((v) => ({
331
+ quality: v.accept?.[0] || 'unknown',
332
+ url: v.url
333
+ }));
334
+ }
268
335
  }
269
- else {
270
- video = data.url || '';
336
+ if (!video && data.url) {
337
+ video = data.url;
338
+ }
339
+ if (video && !video.startsWith('http')) {
340
+ video = 'https:' + video;
271
341
  }
272
- const images = Array.isArray(data.images) ? data.images : [];
273
- const live_photo = Array.isArray(data.live_photo) ? data.live_photo : [];
342
+ const images = Array.isArray(data.images)
343
+ ? data.images.filter((img) => img && typeof img === 'string').map((img) => {
344
+ if (!img.startsWith('http'))
345
+ return 'https:' + img;
346
+ return img;
347
+ })
348
+ : [];
349
+ const live_photo = Array.isArray(data.live_photo)
350
+ ? data.live_photo.filter((lp) => lp && lp.image).map((lp) => ({
351
+ image: lp.image.startsWith('http') ? lp.image : 'https:' + lp.image,
352
+ video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
353
+ }))
354
+ : [];
274
355
  const music = {
275
356
  title: data.music?.title || data.music?.name || '',
276
357
  author: data.music?.author || data.music?.artist || '',
@@ -301,6 +382,10 @@ function parseApiResponse(raw, maxDescLen) {
301
382
  else if (extra.create_time) {
302
383
  publishTime = extra.create_time * 1000;
303
384
  }
385
+ debugLog('DEBUG', '解析后的数据:', {
386
+ type, title, author, video: video.substring(0, 100) + '...',
387
+ images: images.length, live_photo: live_photo.length
388
+ });
304
389
  return {
305
390
  type, title, desc, author, uid, avatar, cover,
306
391
  video, videos, images, live_photo, music,
@@ -324,6 +409,7 @@ function generateFormattedText(p, format) {
324
409
  '图片数量': String(imageCount),
325
410
  '作者ID': p.uid,
326
411
  '封面': p.cover,
412
+ '视频链接': p.video,
327
413
  };
328
414
  const lines = format.split('\n');
329
415
  const resultLines = [];
@@ -359,24 +445,46 @@ function buildForwardNode(session, content, botName) {
359
445
  messageContent = [content];
360
446
  else
361
447
  messageContent = [koishi_1.h.text(String(content))];
362
- return (0, koishi_1.h)('node', { user: { nickname: botName.substring(0, 15), user_id: session.selfId } }, messageContent);
448
+ return (0, koishi_1.h)('node', {
449
+ user: {
450
+ nickname: botName.substring(0, 15),
451
+ user_id: session.selfId
452
+ }
453
+ }, messageContent);
363
454
  }
364
- const urlCache = new Map();
365
- const CACHE_TTL = 10 * 60 * 1000;
455
+ const urlCache = new lru_cache_1.LRUCache({
456
+ max: 500,
457
+ ttl: 10 * 60 * 1000,
458
+ updateAgeOnGet: false,
459
+ });
366
460
  async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
461
+ if (!videoUrl)
462
+ throw new Error('视频链接为空');
367
463
  await promises_1.default.mkdir(tempDir, { recursive: true });
368
464
  const fileName = `video_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.mp4`;
369
- const filePath = path_1.default.join(tempDir, fileName);
465
+ const filePath = path_1.default.resolve(tempDir, fileName);
466
+ debugLog('INFO', `开始下载视频: ${videoUrl.substring(0, 100)}...`);
467
+ debugLog('INFO', `临时文件路径: ${filePath}`);
370
468
  const writer = (0, fs_1.createWriteStream)(filePath);
371
- const response = await (0, axios_1.default)({
372
- method: 'GET',
373
- url: videoUrl,
374
- responseType: 'stream',
375
- timeout: timeout,
376
- headers: {
377
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
378
- }
379
- });
469
+ let response;
470
+ try {
471
+ response = await (0, axios_1.default)({
472
+ method: 'GET',
473
+ url: videoUrl,
474
+ responseType: 'stream',
475
+ timeout: timeout,
476
+ headers: {
477
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
478
+ 'Referer': 'https://www.bilibili.com/',
479
+ },
480
+ validateStatus: (status) => status >= 200 && status < 300,
481
+ });
482
+ }
483
+ catch (e) {
484
+ writer.destroy();
485
+ await promises_1.default.unlink(filePath).catch(() => { });
486
+ throw new Error(`下载视频失败: ${getErrorMessage(e)}`);
487
+ }
380
488
  const maxSizeBytes = maxSizeMB * 1024 * 1024;
381
489
  const contentLength = Number(response.headers['content-length'] || 0);
382
490
  if (maxSizeMB > 0 && contentLength > maxSizeBytes) {
@@ -394,8 +502,15 @@ async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
394
502
  throw new Error(`视频文件过大,超过限制(${maxSizeMB}MB)`);
395
503
  }
396
504
  });
397
- await (0, promises_2.pipeline)(response.data, writer);
398
- return filePath;
505
+ try {
506
+ await (0, promises_2.pipeline)(response.data, writer);
507
+ debugLog('INFO', `视频下载完成,大小: ${Math.round(downloadedSize / 1024 / 1024)}MB`);
508
+ return filePath;
509
+ }
510
+ catch (e) {
511
+ await promises_1.default.unlink(filePath).catch(() => { });
512
+ throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
513
+ }
399
514
  }
400
515
  function getErrorMessage(error) {
401
516
  if (error instanceof Error)
@@ -411,7 +526,9 @@ function isSpecialPlatformVideo(url) {
411
526
  'xhslink.com',
412
527
  'zhihu.com',
413
528
  'weibo.com',
414
- 'sinaimg.cn'
529
+ 'sinaimg.cn',
530
+ 'ixigua.com',
531
+ 'toutiao.com',
415
532
  ];
416
533
  return specialHosts.some(host => url.includes(host));
417
534
  }
@@ -428,7 +545,7 @@ function apply(ctx, config) {
428
545
  const http = axios_1.default.create({
429
546
  timeout: config.timeout,
430
547
  headers: {
431
- 'User-Agent': config.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
548
+ 'User-Agent': config.userAgent,
432
549
  'Referer': 'https://www.baidu.com/',
433
550
  'Content-Type': 'application/x-www-form-urlencoded'
434
551
  }
@@ -448,13 +565,16 @@ function apply(ctx, config) {
448
565
  params: { url },
449
566
  timeout: config.timeout
450
567
  });
451
- debugLog('DEBUG', `API响应: ${JSON.stringify(res.data)}`);
568
+ debugLog('DEBUG', `API响应状态: ${res.status}`);
452
569
  if (res.data && (res.data.code === 200 || res.data.code === 0)) {
453
570
  const parsed = parseApiResponse(res.data, config.maxDescLength);
454
- urlCache.set(cacheKey, { data: parsed, expire: Date.now() + CACHE_TTL });
571
+ urlCache.set(cacheKey, {
572
+ data: parsed,
573
+ expire: Date.now() + 10 * 60 * 1000
574
+ });
455
575
  return parsed;
456
576
  }
457
- throw new Error(res.data?.msg || '解析失败');
577
+ throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
458
578
  }
459
579
  catch (error) {
460
580
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -472,7 +592,10 @@ function apply(ctx, config) {
472
592
  for (const candidate of [...new Set(candidates)]) {
473
593
  try {
474
594
  const info = await fetchApi(candidate);
475
- return { success: true, data: info };
595
+ if (info.video || info.images.length > 0) {
596
+ return { success: true, data: info };
597
+ }
598
+ debugLog('WARN', `解析成功但无有效内容: ${candidate}`);
476
599
  }
477
600
  catch (error) {
478
601
  debugLog('ERROR', `候选链接解析失败: ${candidate}`, getErrorMessage(error));
@@ -482,10 +605,17 @@ function apply(ctx, config) {
482
605
  }
483
606
  async function processSingleUrl(url) {
484
607
  const result = await parseUrl(url);
485
- if (!result.success)
486
- return result;
608
+ if (!result.success) {
609
+ return { success: false, msg: result.msg, url };
610
+ }
487
611
  const text = generateFormattedText(result.data, config.unifiedMessageFormat);
488
- return { success: true, data: { text, parsed: result.data } };
612
+ return {
613
+ success: true,
614
+ data: {
615
+ text,
616
+ parsed: result.data
617
+ }
618
+ };
489
619
  }
490
620
  async function sendWithTimeout(session, content, customRetries) {
491
621
  const maxRetries = customRetries ?? config.retryTimes ?? 3;
@@ -528,24 +658,34 @@ function apply(ctx, config) {
528
658
  async function sendVideoFile(session, videoUrl) {
529
659
  if (!videoUrl)
530
660
  throw new Error('视频链接为空');
531
- const shouldForceDownload = config.forceDownloadVideo || isSpecialPlatformVideo(videoUrl);
532
- if (!shouldForceDownload) {
661
+ if (config.videoLoadWaitTime > 0) {
662
+ await delay(config.videoLoadWaitTime);
663
+ }
664
+ await sendWithTimeout(session, `视频链接:${videoUrl}`).catch(() => { });
665
+ if (!config.showVideoFile) {
666
+ return null;
667
+ }
668
+ if (!config.forceDownloadVideo) {
533
669
  try {
534
670
  debugLog('INFO', `尝试直接发送视频URL: ${videoUrl.substring(0, 100)}...`);
535
- return await sendWithTimeout(session, koishi_1.h.video(videoUrl));
671
+ const result = await sendWithTimeout(session, koishi_1.h.video(videoUrl));
672
+ if (result) {
673
+ debugLog('INFO', '直接发送视频URL成功');
674
+ return result;
675
+ }
536
676
  }
537
677
  catch (err) {
538
678
  debugLog('ERROR', `直接发送URL失败,开始下载视频: ${getErrorMessage(err)}`);
539
679
  }
540
680
  }
541
681
  else {
542
- debugLog('INFO', `检测到特殊平台视频,强制下载后发送: ${videoUrl.substring(0, 100)}...`);
682
+ debugLog('INFO', `强制下载视频后发送: ${videoUrl.substring(0, 100)}...`);
543
683
  }
544
684
  let tempFilePath = null;
545
685
  try {
546
686
  tempFilePath = await downloadVideoFile(videoUrl, config.tempDir || './temp_videos', config.videoDownloadTimeout || 120000, config.maxVideoSize || 0);
547
- const localFile = `file://${path_1.default.resolve(tempFilePath)}`;
548
- debugLog('INFO', `视频下载完成,发送本地文件: ${localFile}`);
687
+ const localFile = `file://${tempFilePath}`;
688
+ debugLog('INFO', `发送本地视频文件: ${localFile}`);
549
689
  return await sendWithTimeout(session, koishi_1.h.video(localFile));
550
690
  }
551
691
  finally {
@@ -556,40 +696,39 @@ function apply(ctx, config) {
556
696
  }
557
697
  async function flush(session, urls) {
558
698
  const uniqueUrls = [...new Set(urls)];
699
+ debugLog('INFO', `开始解析 ${uniqueUrls.length} 个链接`);
559
700
  const items = [];
560
701
  const errors = [];
561
- const concurrency = 3;
562
- const chunks = [];
563
- for (let i = 0; i < uniqueUrls.length; i += concurrency) {
564
- chunks.push(uniqueUrls.slice(i, i + concurrency));
565
- }
566
- for (const chunk of chunks) {
567
- const results = await Promise.all(chunk.map(url => processSingleUrl(url)));
568
- for (let idx = 0; idx < results.length; idx++) {
569
- const res = results[idx];
570
- if (res.success) {
571
- items.push(res.data);
572
- }
573
- else {
574
- const url = chunk[idx];
575
- const item = texts.parseErrorItemFormat
576
- .replace(/\$\{url\}/g, url.length > 50 ? url.slice(0, 50) + '...' : url)
577
- .replace(/\$\{msg\}/g, res.msg);
578
- errors.push(item);
579
- }
702
+ for (let i = 0; i < uniqueUrls.length; i++) {
703
+ const url = uniqueUrls[i];
704
+ debugLog('INFO', `正在解析第 ${i + 1}/${uniqueUrls.length} 个链接: ${url}`);
705
+ const result = await processSingleUrl(url);
706
+ if (result.success) {
707
+ items.push(result.data);
708
+ }
709
+ else {
710
+ const item = texts.parseErrorItemFormat
711
+ .replace(/\$\{url\}/g, url.length > 50 ? url.slice(0, 50) + '...' : url)
712
+ .replace(/\$\{msg\}/g, result.msg);
713
+ errors.push(item);
714
+ }
715
+ if (i < uniqueUrls.length - 1) {
716
+ await delay(500);
580
717
  }
581
718
  }
582
719
  if (errors.length) {
583
720
  await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
584
721
  await delay(500);
585
722
  }
586
- if (!items.length)
723
+ if (!items.length) {
724
+ debugLog('INFO', '没有成功解析的内容');
587
725
  return;
726
+ }
588
727
  const enableForward = config.enableForward && session.platform === 'onebot';
589
728
  const botName = config.botName || '视频解析机器人';
590
- const videoItems = [];
591
729
  if (enableForward) {
592
730
  const forwardMessages = [];
731
+ const videoItems = [];
593
732
  for (const item of items) {
594
733
  const p = item.parsed;
595
734
  const text = item.text;
@@ -606,12 +745,14 @@ function apply(ctx, config) {
606
745
  }
607
746
  }
608
747
  if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
748
+ forwardMessages.push(buildForwardNode(session, `视频链接:${p.video}`, botName));
609
749
  videoItems.push(p);
610
750
  }
611
751
  }
612
752
  if (forwardMessages.length) {
613
753
  const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
614
754
  try {
755
+ debugLog('INFO', `发送合并转发消息,包含 ${forwardMessages.length} 条内容`);
615
756
  await sendWithTimeout(session, forwardMsg, config.retryTimes);
616
757
  }
617
758
  catch (err) {
@@ -627,8 +768,7 @@ function apply(ctx, config) {
627
768
  await sendVideoFile(session, p.video);
628
769
  }
629
770
  catch (err) {
630
- debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
631
- await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
771
+ debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
632
772
  }
633
773
  await delay(500);
634
774
  }
@@ -645,13 +785,12 @@ function apply(ctx, config) {
645
785
  await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
646
786
  await delay(300);
647
787
  }
648
- if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
788
+ if (p.video && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
649
789
  try {
650
790
  await sendVideoFile(session, p.video);
651
791
  }
652
792
  catch (err) {
653
- debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
654
- await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
793
+ debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
655
794
  }
656
795
  await delay(500);
657
796
  }
@@ -664,41 +803,78 @@ function apply(ctx, config) {
664
803
  }
665
804
  }
666
805
  }
806
+ debugLog('INFO', '所有内容处理完成');
667
807
  }
668
808
  ctx.on('message', async (session) => {
669
809
  if (!config.enable)
670
810
  return;
671
- // 修复:使用正确的小写subtype属性名
672
811
  if (session.subtype === 'file_upload')
673
812
  return;
674
813
  if (session.elements?.some(elem => elem.type === 'file' || elem.type === 'folder'))
675
814
  return;
815
+ if (session.selfId === session.userId)
816
+ return;
676
817
  const urls = extractAllUrlsFromMessage(session);
677
818
  if (!urls.length)
678
819
  return;
820
+ debugLog('INFO', `检测到 ${urls.length} 个链接,开始处理`);
679
821
  if (config.showWaitingTip) {
680
822
  try {
681
823
  await sendWithTimeout(session, texts.waitingTipText);
682
824
  }
683
- catch { }
825
+ catch (e) {
826
+ debugLog('WARN', '发送等待提示失败:', e);
827
+ }
684
828
  }
685
829
  await flush(session, urls);
686
830
  });
687
831
  ctx.command('parse <url>', '手动解析视频').action(async ({ session }, url) => {
832
+ if (!url) {
833
+ await sendWithTimeout(session, texts.invalidLinkText);
834
+ return;
835
+ }
688
836
  const us = extractUrl(url);
689
837
  if (!us.length) {
690
838
  await sendWithTimeout(session, texts.invalidLinkText);
691
839
  return;
692
840
  }
841
+ if (config.showWaitingTip) {
842
+ try {
843
+ await sendWithTimeout(session, texts.waitingTipText);
844
+ }
845
+ catch { }
846
+ }
693
847
  await flush(session, us);
694
848
  });
695
- setInterval(() => {
696
- const now = Date.now();
697
- for (const [key, { expire }] of urlCache.entries()) {
698
- if (expire <= now)
699
- urlCache.delete(key);
849
+ const tempCleanupInterval = setInterval(async () => {
850
+ try {
851
+ const tempDir = config.tempDir || './temp_videos';
852
+ const files = await promises_1.default.readdir(tempDir);
853
+ const now = Date.now();
854
+ let deletedCount = 0;
855
+ for (const file of files) {
856
+ if (file.startsWith('video_') && file.endsWith('.mp4')) {
857
+ const filePath = path_1.default.join(tempDir, file);
858
+ const stats = await promises_1.default.stat(filePath);
859
+ if (now - stats.mtimeMs > 3600000) {
860
+ await promises_1.default.unlink(filePath).catch(() => { });
861
+ deletedCount++;
862
+ }
863
+ }
864
+ }
865
+ if (deletedCount > 0) {
866
+ debugLog('INFO', `清理了 ${deletedCount} 个过期临时视频文件`);
867
+ }
700
868
  }
701
- }, 60000);
869
+ catch (e) {
870
+ debugLog('WARN', '清理临时文件失败:', e);
871
+ }
872
+ }, 3600000);
873
+ ctx.on('dispose', () => {
874
+ clearInterval(tempCleanupInterval);
875
+ urlCache.clear();
876
+ debugLog('INFO', '插件已卸载,资源已清理');
877
+ });
702
878
  process.on('exit', async () => {
703
879
  try {
704
880
  const tempDir = config.tempDir || './temp_videos';
@@ -708,6 +884,7 @@ function apply(ctx, config) {
708
884
  await promises_1.default.unlink(path_1.default.join(tempDir, file)).catch(() => { });
709
885
  }
710
886
  }
887
+ debugLog('INFO', '进程退出,已清理所有临时视频文件');
711
888
  }
712
889
  catch { }
713
890
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-video-parser-all",
3
3
  "description": "Koishi 全平台视频解析插件,支持抖音/快手/B站/微博/小红书/剪映/YouTube/TikTok等20+平台",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -11,8 +11,8 @@
11
11
  - 📤 支持OneBot平台消息合并转发,优化多图文展示体验
12
12
  - 💬 所有提示文案均可自定义,适配多语言场景
13
13
  - 🔁 消息发送支持自动重试,与API重试配置联动,增强稳定性
14
- - 🚀 内置内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
15
- - ⚡ 智能视频发送策略:普通平台优先直接发送URL,特殊平台自动降级为本地文件发送
14
+ - 🚀 内置LRU内存缓存,避免短时间内重复解析同一链接;串行解析防止API限流
15
+ - ⚡ 智能视频发送策略:优先直接发送URL,失败自动降级为本地文件发送
16
16
  - 🛡️ 可选视频大小限制,防止超大文件占满服务器磁盘;自动清理所有临时文件
17
17
 
18
18
  ### English
@@ -24,8 +24,8 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
24
24
  - 📤 Support OneBot message forwarding for better image/video display
25
25
  - 💬 All prompt texts are customizable for multilingual scenarios
26
26
  - 🔁 Message sending supports automatic retries, linked with API retry configuration for improved stability
27
- - 🚀 Built-in memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
28
- - ⚡ Smart video sending strategy: priority to send URL directly for common platforms, auto downgrade to local file for special platforms
27
+ - 🚀 Built-in LRU memory cache to avoid repeated parsing of the same URL; serial parsing to prevent API rate limiting
28
+ - ⚡ Smart video sending strategy: priority to send URL directly, auto downgrade to local file on failure
29
29
  - 🛡️ Optional video size limit to prevent oversized files from filling up server disk; automatic cleanup of all temporary files
30
30
 
31
31
  ## 项目仓库 (Repository)
@@ -62,14 +62,15 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
62
62
  | `videoDownloadTimeout` | number | 120000 | 视频下载超时(毫秒) |
63
63
  | `tempDir` | string | `./temp_videos` | 临时视频存储目录 |
64
64
  | `maxVideoSize` | number | 0 | 最大下载视频大小(MB),0 为不限制大小 |
65
- | `forceDownloadVideo` | boolean | true | 强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题) |
65
+ | `forceDownloadVideo` | boolean | false | 强制下载视频后发送 |
66
+ | `videoLoadWaitTime` | number | 180000 | 视频链接加载等待时间(毫秒),获取到视频链接后等待指定时间再发送,0为不等待 |
66
67
 
67
68
  ### 网络与 API 设置
68
69
  | 配置项 | 类型 | 默认值 | 说明 |
69
70
  |--------|------|--------|------|
70
71
  | `timeout` | number | 180000 | API 请求超时时间(毫秒) |
71
72
  | `videoSendTimeout` | number | 60000 | 视频消息发送超时时间(毫秒,0 为不限制) |
72
- | `userAgent` | string | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36` | API 请求使用的 User-Agent |
73
+ | `userAgent` | string | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36` | API 请求使用的 User-Agent |
73
74
 
74
75
  ### 错误与重试设置
75
76
  | 配置项 | 类型 | 默认值 | 说明 |
@@ -110,6 +111,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
110
111
  | `${图片数量}` | 图集/实况图片数量 | 图集/实况 |
111
112
  | `${作者ID}` | 作者唯一标识ID | 部分平台 |
112
113
  | `${封面}` | 封面图片地址 | 所有平台 |
114
+ | `${视频链接}` | 视频原始链接 | 视频 |
113
115
 
114
116
  > 注:部分变量可能因平台API返回数据不同而显示为空,某行所有变量为空(或为"0")时该行会自动隐藏。
115
117