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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
33
  forceDownloadVideo: koishi_1.Schema.boolean().default(true).description('强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题)'),
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,14 +60,24 @@ 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}` },
80
+ { pattern: /b23\.tv\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://b23.tv/${id}` },
69
81
  { pattern: /bili(?:22|23|33)\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili23.cn/${id}` },
70
82
  { pattern: /bili2233\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili2233.cn/${id}` },
71
83
  { pattern: /douyin\.com\/video\/(\d+)/gi, type: 'douyin', buildUrl: (id) => `https://www.douyin.com/video/${id}` },
@@ -108,17 +120,26 @@ 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')) {
116
134
  return false;
135
+ }
117
136
  return true;
118
137
  }
119
138
  catch {
120
139
  return false;
121
140
  }
141
+ }).map(url => {
142
+ return url.replace(/[.,;:!?)]+$/, '');
122
143
  });
123
144
  }
124
145
  function extractAllUrlsFromMessage(session) {
@@ -138,10 +159,11 @@ function extractAllUrlsFromMessage(session) {
138
159
  if (session.elements) {
139
160
  for (const elem of session.elements) {
140
161
  if (elem.type === 'xml' && elem.data) {
141
- const urlRegex = /https?:\/\/[^\s<>"']+/gi;
162
+ const urlRegex = /https?:\/\/[^\s<>"'(){}[\]]+/gi;
142
163
  let match;
143
164
  while ((match = urlRegex.exec(elem.data)) !== null) {
144
- urls.push(match[0]);
165
+ const cleanUrl = match[0].replace(/[.,;:!?)]+$/, '');
166
+ urls.push(cleanUrl);
145
167
  }
146
168
  }
147
169
  else if (elem.type === 'json' && elem.data) {
@@ -152,9 +174,13 @@ function extractAllUrlsFromMessage(session) {
152
174
  return;
153
175
  for (const val of Object.values(obj)) {
154
176
  if (typeof val === 'string') {
155
- const match = val.match(/https?:\/\/[^\s<>"']+/gi);
156
- if (match)
157
- urls.push(...match);
177
+ const match = val.match(/https?:\/\/[^\s<>"'(){}[\]]+/gi);
178
+ if (match) {
179
+ match.forEach(url => {
180
+ const cleanUrl = url.replace(/[.,;:!?)]+$/, '');
181
+ urls.push(cleanUrl);
182
+ });
183
+ }
158
184
  }
159
185
  else if (typeof val === 'object')
160
186
  extractFromObject(val);
@@ -162,7 +188,9 @@ function extractAllUrlsFromMessage(session) {
162
188
  };
163
189
  extractFromObject(json);
164
190
  }
165
- catch { }
191
+ catch (e) {
192
+ debugLog('WARN', '解析JSON卡片失败:', e);
193
+ }
166
194
  }
167
195
  }
168
196
  }
@@ -172,14 +200,25 @@ function cleanUrl(url) {
172
200
  try {
173
201
  url = url.replace(/&amp;/g, '&');
174
202
  const urlObj = new URL(url);
203
+ if (urlObj.protocol === 'http:') {
204
+ urlObj.protocol = 'https:';
205
+ }
175
206
  if (urlObj.hostname.includes('douyin.com') || urlObj.hostname.includes('v.douyin.com')) {
176
- urlObj.searchParams.delete('source');
177
- urlObj.searchParams.delete('share_type');
207
+ ['source', 'share_type', 'share_token', 'timestamp', 'from', 'isappinstalled'].forEach(p => {
208
+ urlObj.searchParams.delete(p);
209
+ });
210
+ return urlObj.origin + urlObj.pathname;
211
+ }
212
+ if (urlObj.hostname.includes('bilibili.com') || urlObj.hostname.includes('b23.tv')) {
213
+ ['share_source', 'share_medium', 'share_plat', 'share_session_id', 'share_tag', 'timestamp'].forEach(p => {
214
+ urlObj.searchParams.delete(p);
215
+ });
178
216
  return urlObj.origin + urlObj.pathname;
179
217
  }
180
- return url;
218
+ return urlObj.toString();
181
219
  }
182
220
  catch (e) {
221
+ debugLog('WARN', '清理URL失败:', e, '原始URL:', url);
183
222
  return url.replace(/&amp;/g, '&').replace(/\?.*/, '');
184
223
  }
185
224
  }
@@ -198,6 +237,7 @@ async function resolveShortUrl(url) {
198
237
  return cleanUrl(finalUrl);
199
238
  }
200
239
  catch (e) {
240
+ debugLog('WARN', '解析短链接失败:', e, '原始URL:', url);
201
241
  return cleanUrl(url);
202
242
  }
203
243
  }
@@ -209,7 +249,7 @@ function formatDuration(seconds) {
209
249
  const s = Math.floor(seconds % 60);
210
250
  if (h > 0)
211
251
  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')}`;
252
+ return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
213
253
  }
214
254
  function formatPublishTime(ms) {
215
255
  if (!ms)
@@ -222,8 +262,13 @@ function pickBestQuality(videoBackup) {
222
262
  if (!Array.isArray(videoBackup))
223
263
  return [];
224
264
  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));
265
+ .filter(v => v && v.url)
266
+ .map(v => ({
267
+ quality: v.quality || v.label || 'unknown',
268
+ url: v.url,
269
+ bit_rate: Number(v.bit_rate || 0)
270
+ }))
271
+ .sort((a, b) => b.bit_rate - a.bit_rate);
227
272
  }
228
273
  function parseApiResponse(raw, maxDescLen) {
229
274
  debugLog('DEBUG', '原始API返回数据:', raw);
@@ -253,24 +298,44 @@ function parseApiResponse(raw, maxDescLen) {
253
298
  avatar = data.avatar || '';
254
299
  }
255
300
  const title = data.title || '';
256
- const desc = (data.desc || data.description || '').slice(0, maxDescLen);
301
+ const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
257
302
  const cover = data.cover || '';
258
303
  let video = '';
259
304
  let videos = [];
260
305
  if (Array.isArray(data.video_backup) && data.video_backup.length) {
261
306
  const bestQ = pickBestQuality(data.video_backup);
262
307
  videos = bestQ;
263
- video = bestQ[0]?.url || data.url || '';
308
+ video = bestQ[0]?.url || '';
264
309
  }
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 }));
310
+ if (!video && Array.isArray(data.videos) && data.videos.length) {
311
+ const validVideos = data.videos.filter((v) => v && v.url);
312
+ if (validVideos.length) {
313
+ video = validVideos[0].url;
314
+ videos = validVideos.map((v) => ({
315
+ quality: v.accept?.[0] || 'unknown',
316
+ url: v.url
317
+ }));
318
+ }
268
319
  }
269
- else {
270
- video = data.url || '';
320
+ if (!video && data.url) {
321
+ video = data.url;
322
+ }
323
+ if (video && !video.startsWith('http')) {
324
+ video = 'https:' + video;
271
325
  }
272
- const images = Array.isArray(data.images) ? data.images : [];
273
- const live_photo = Array.isArray(data.live_photo) ? data.live_photo : [];
326
+ const images = Array.isArray(data.images)
327
+ ? data.images.filter((img) => img && typeof img === 'string').map((img) => {
328
+ if (!img.startsWith('http'))
329
+ return 'https:' + img;
330
+ return img;
331
+ })
332
+ : [];
333
+ const live_photo = Array.isArray(data.live_photo)
334
+ ? data.live_photo.filter((lp) => lp && lp.image).map((lp) => ({
335
+ image: lp.image.startsWith('http') ? lp.image : 'https:' + lp.image,
336
+ video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
337
+ }))
338
+ : [];
274
339
  const music = {
275
340
  title: data.music?.title || data.music?.name || '',
276
341
  author: data.music?.author || data.music?.artist || '',
@@ -301,6 +366,10 @@ function parseApiResponse(raw, maxDescLen) {
301
366
  else if (extra.create_time) {
302
367
  publishTime = extra.create_time * 1000;
303
368
  }
369
+ debugLog('DEBUG', '解析后的数据:', {
370
+ type, title, author, video: video.substring(0, 100) + '...',
371
+ images: images.length, live_photo: live_photo.length
372
+ });
304
373
  return {
305
374
  type, title, desc, author, uid, avatar, cover,
306
375
  video, videos, images, live_photo, music,
@@ -324,6 +393,7 @@ function generateFormattedText(p, format) {
324
393
  '图片数量': String(imageCount),
325
394
  '作者ID': p.uid,
326
395
  '封面': p.cover,
396
+ '视频链接': p.video,
327
397
  };
328
398
  const lines = format.split('\n');
329
399
  const resultLines = [];
@@ -359,24 +429,46 @@ function buildForwardNode(session, content, botName) {
359
429
  messageContent = [content];
360
430
  else
361
431
  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);
432
+ return (0, koishi_1.h)('node', {
433
+ user: {
434
+ nickname: botName.substring(0, 15),
435
+ user_id: session.selfId
436
+ }
437
+ }, messageContent);
363
438
  }
364
- const urlCache = new Map();
365
- const CACHE_TTL = 10 * 60 * 1000;
439
+ const urlCache = new lru_cache_1.LRUCache({
440
+ max: 500,
441
+ ttl: 10 * 60 * 1000,
442
+ updateAgeOnGet: false,
443
+ });
366
444
  async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
445
+ if (!videoUrl)
446
+ throw new Error('视频链接为空');
367
447
  await promises_1.default.mkdir(tempDir, { recursive: true });
368
448
  const fileName = `video_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.mp4`;
369
- const filePath = path_1.default.join(tempDir, fileName);
449
+ const filePath = path_1.default.resolve(tempDir, fileName);
450
+ debugLog('INFO', `开始下载视频: ${videoUrl.substring(0, 100)}...`);
451
+ debugLog('INFO', `临时文件路径: ${filePath}`);
370
452
  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
- });
453
+ let response;
454
+ try {
455
+ response = await (0, axios_1.default)({
456
+ method: 'GET',
457
+ url: videoUrl,
458
+ responseType: 'stream',
459
+ timeout: timeout,
460
+ headers: {
461
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
462
+ 'Referer': 'https://www.baidu.com/',
463
+ },
464
+ validateStatus: (status) => status >= 200 && status < 300,
465
+ });
466
+ }
467
+ catch (e) {
468
+ writer.destroy();
469
+ await promises_1.default.unlink(filePath).catch(() => { });
470
+ throw new Error(`下载视频失败: ${getErrorMessage(e)}`);
471
+ }
380
472
  const maxSizeBytes = maxSizeMB * 1024 * 1024;
381
473
  const contentLength = Number(response.headers['content-length'] || 0);
382
474
  if (maxSizeMB > 0 && contentLength > maxSizeBytes) {
@@ -394,8 +486,15 @@ async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
394
486
  throw new Error(`视频文件过大,超过限制(${maxSizeMB}MB)`);
395
487
  }
396
488
  });
397
- await (0, promises_2.pipeline)(response.data, writer);
398
- return filePath;
489
+ try {
490
+ await (0, promises_2.pipeline)(response.data, writer);
491
+ debugLog('INFO', `视频下载完成,大小: ${Math.round(downloadedSize / 1024 / 1024)}MB`);
492
+ return filePath;
493
+ }
494
+ catch (e) {
495
+ await promises_1.default.unlink(filePath).catch(() => { });
496
+ throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
497
+ }
399
498
  }
400
499
  function getErrorMessage(error) {
401
500
  if (error instanceof Error)
@@ -411,10 +510,36 @@ function isSpecialPlatformVideo(url) {
411
510
  'xhslink.com',
412
511
  'zhihu.com',
413
512
  'weibo.com',
414
- 'sinaimg.cn'
513
+ 'sinaimg.cn',
514
+ 'ixigua.com',
515
+ 'toutiao.com',
415
516
  ];
416
517
  return specialHosts.some(host => url.includes(host));
417
518
  }
519
+ async function getShortUrl(url) {
520
+ if (!url)
521
+ return '';
522
+ try {
523
+ const urlObj = new URL(url);
524
+ if (urlObj.hostname.includes('bilibili.com')) {
525
+ const bvMatch = url.match(/\/video\/(bv[0-9a-zA-Z]+)/i);
526
+ if (bvMatch)
527
+ return `https://b23.tv/${bvMatch[1]}`;
528
+ }
529
+ if (urlObj.hostname.includes('douyin.com')) {
530
+ const idMatch = url.match(/\/video\/(\d+)/);
531
+ if (idMatch)
532
+ return `https://v.douyin.com/${idMatch[1].substring(0, 8)}/`;
533
+ }
534
+ if (urlObj.hostname.includes('xiaohongshu.com')) {
535
+ const idMatch = url.match(/\/item\/([0-9a-zA-Z]+)/);
536
+ if (idMatch)
537
+ return `https://xhslink.com/${idMatch[1].substring(0, 8)}`;
538
+ }
539
+ }
540
+ catch { }
541
+ return url;
542
+ }
418
543
  function apply(ctx, config) {
419
544
  debugEnabled = config.debug || false;
420
545
  debugLog('INFO', '插件初始化开始');
@@ -428,7 +553,7 @@ function apply(ctx, config) {
428
553
  const http = axios_1.default.create({
429
554
  timeout: config.timeout,
430
555
  headers: {
431
- 'User-Agent': config.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
556
+ 'User-Agent': config.userAgent,
432
557
  'Referer': 'https://www.baidu.com/',
433
558
  'Content-Type': 'application/x-www-form-urlencoded'
434
559
  }
@@ -448,13 +573,16 @@ function apply(ctx, config) {
448
573
  params: { url },
449
574
  timeout: config.timeout
450
575
  });
451
- debugLog('DEBUG', `API响应: ${JSON.stringify(res.data)}`);
576
+ debugLog('DEBUG', `API响应状态: ${res.status}`);
452
577
  if (res.data && (res.data.code === 200 || res.data.code === 0)) {
453
578
  const parsed = parseApiResponse(res.data, config.maxDescLength);
454
- urlCache.set(cacheKey, { data: parsed, expire: Date.now() + CACHE_TTL });
579
+ urlCache.set(cacheKey, {
580
+ data: parsed,
581
+ expire: Date.now() + 10 * 60 * 1000
582
+ });
455
583
  return parsed;
456
584
  }
457
- throw new Error(res.data?.msg || '解析失败');
585
+ throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
458
586
  }
459
587
  catch (error) {
460
588
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -472,7 +600,10 @@ function apply(ctx, config) {
472
600
  for (const candidate of [...new Set(candidates)]) {
473
601
  try {
474
602
  const info = await fetchApi(candidate);
475
- return { success: true, data: info };
603
+ if (info.video || info.images.length > 0) {
604
+ return { success: true, data: info };
605
+ }
606
+ debugLog('WARN', `解析成功但无有效内容: ${candidate}`);
476
607
  }
477
608
  catch (error) {
478
609
  debugLog('ERROR', `候选链接解析失败: ${candidate}`, getErrorMessage(error));
@@ -482,10 +613,19 @@ function apply(ctx, config) {
482
613
  }
483
614
  async function processSingleUrl(url) {
484
615
  const result = await parseUrl(url);
485
- if (!result.success)
486
- return result;
616
+ if (!result.success) {
617
+ return { success: false, msg: result.msg, url };
618
+ }
487
619
  const text = generateFormattedText(result.data, config.unifiedMessageFormat);
488
- return { success: true, data: { text, parsed: result.data } };
620
+ const shortUrl = await getShortUrl(result.data.video);
621
+ return {
622
+ success: true,
623
+ data: {
624
+ text,
625
+ parsed: result.data,
626
+ shortUrl
627
+ }
628
+ };
489
629
  }
490
630
  async function sendWithTimeout(session, content, customRetries) {
491
631
  const maxRetries = customRetries ?? config.retryTimes ?? 3;
@@ -525,9 +665,18 @@ function apply(ctx, config) {
525
665
  }
526
666
  return null;
527
667
  }
528
- async function sendVideoFile(session, videoUrl) {
668
+ async function sendVideoFile(session, videoUrl, shortUrl) {
529
669
  if (!videoUrl)
530
670
  throw new Error('视频链接为空');
671
+ if (config.videoLoadWaitTime > 0) {
672
+ await delay(config.videoLoadWaitTime);
673
+ }
674
+ if (shortUrl) {
675
+ await sendWithTimeout(session, `视频链接:${shortUrl}`).catch(() => { });
676
+ }
677
+ if (!config.showVideoFile) {
678
+ return null;
679
+ }
531
680
  const shouldForceDownload = config.forceDownloadVideo || isSpecialPlatformVideo(videoUrl);
532
681
  if (!shouldForceDownload) {
533
682
  try {
@@ -544,8 +693,8 @@ function apply(ctx, config) {
544
693
  let tempFilePath = null;
545
694
  try {
546
695
  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}`);
696
+ const localFile = `file://${tempFilePath}`;
697
+ debugLog('INFO', `发送本地视频文件: ${localFile}`);
549
698
  return await sendWithTimeout(session, koishi_1.h.video(localFile));
550
699
  }
551
700
  finally {
@@ -556,6 +705,7 @@ function apply(ctx, config) {
556
705
  }
557
706
  async function flush(session, urls) {
558
707
  const uniqueUrls = [...new Set(urls)];
708
+ debugLog('INFO', `开始解析 ${uniqueUrls.length} 个链接`);
559
709
  const items = [];
560
710
  const errors = [];
561
711
  const concurrency = 3;
@@ -583,16 +733,19 @@ function apply(ctx, config) {
583
733
  await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
584
734
  await delay(500);
585
735
  }
586
- if (!items.length)
736
+ if (!items.length) {
737
+ debugLog('INFO', '没有成功解析的内容');
587
738
  return;
739
+ }
588
740
  const enableForward = config.enableForward && session.platform === 'onebot';
589
741
  const botName = config.botName || '视频解析机器人';
590
- const videoItems = [];
591
742
  if (enableForward) {
592
743
  const forwardMessages = [];
744
+ const videoItems = [];
593
745
  for (const item of items) {
594
746
  const p = item.parsed;
595
747
  const text = item.text;
748
+ const shortUrl = item.shortUrl;
596
749
  if (text && config.showImageText) {
597
750
  forwardMessages.push(buildForwardNode(session, text, botName));
598
751
  }
@@ -606,12 +759,16 @@ function apply(ctx, config) {
606
759
  }
607
760
  }
608
761
  if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
609
- videoItems.push(p);
762
+ videoItems.push({ parsed: p, shortUrl });
763
+ if (shortUrl) {
764
+ forwardMessages.push(buildForwardNode(session, `视频链接:${shortUrl}`, botName));
765
+ }
610
766
  }
611
767
  }
612
768
  if (forwardMessages.length) {
613
769
  const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
614
770
  try {
771
+ debugLog('INFO', `发送合并转发消息,包含 ${forwardMessages.length} 条内容`);
615
772
  await sendWithTimeout(session, forwardMsg, config.retryTimes);
616
773
  }
617
774
  catch (err) {
@@ -622,13 +779,12 @@ function apply(ctx, config) {
622
779
  }
623
780
  }
624
781
  }
625
- for (const p of videoItems) {
782
+ for (const item of videoItems) {
626
783
  try {
627
- await sendVideoFile(session, p.video);
784
+ await sendVideoFile(session, item.parsed.video, item.shortUrl);
628
785
  }
629
786
  catch (err) {
630
- debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
631
- await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
787
+ debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
632
788
  }
633
789
  await delay(500);
634
790
  }
@@ -637,6 +793,7 @@ function apply(ctx, config) {
637
793
  for (const item of items) {
638
794
  const p = item.parsed;
639
795
  const text = item.text;
796
+ const shortUrl = item.shortUrl;
640
797
  if (text && config.showImageText) {
641
798
  await sendWithTimeout(session, text);
642
799
  await delay(300);
@@ -645,13 +802,12 @@ function apply(ctx, config) {
645
802
  await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
646
803
  await delay(300);
647
804
  }
648
- if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
805
+ if (p.video && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
649
806
  try {
650
- await sendVideoFile(session, p.video);
807
+ await sendVideoFile(session, p.video, shortUrl);
651
808
  }
652
809
  catch (err) {
653
- debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
654
- await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
810
+ debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
655
811
  }
656
812
  await delay(500);
657
813
  }
@@ -664,41 +820,78 @@ function apply(ctx, config) {
664
820
  }
665
821
  }
666
822
  }
823
+ debugLog('INFO', '所有内容处理完成');
667
824
  }
668
825
  ctx.on('message', async (session) => {
669
826
  if (!config.enable)
670
827
  return;
671
- // 修复:使用正确的小写subtype属性名
672
828
  if (session.subtype === 'file_upload')
673
829
  return;
674
830
  if (session.elements?.some(elem => elem.type === 'file' || elem.type === 'folder'))
675
831
  return;
832
+ if (session.selfId === session.userId)
833
+ return;
676
834
  const urls = extractAllUrlsFromMessage(session);
677
835
  if (!urls.length)
678
836
  return;
837
+ debugLog('INFO', `检测到 ${urls.length} 个链接,开始处理`);
679
838
  if (config.showWaitingTip) {
680
839
  try {
681
840
  await sendWithTimeout(session, texts.waitingTipText);
682
841
  }
683
- catch { }
842
+ catch (e) {
843
+ debugLog('WARN', '发送等待提示失败:', e);
844
+ }
684
845
  }
685
846
  await flush(session, urls);
686
847
  });
687
848
  ctx.command('parse <url>', '手动解析视频').action(async ({ session }, url) => {
849
+ if (!url) {
850
+ await sendWithTimeout(session, texts.invalidLinkText);
851
+ return;
852
+ }
688
853
  const us = extractUrl(url);
689
854
  if (!us.length) {
690
855
  await sendWithTimeout(session, texts.invalidLinkText);
691
856
  return;
692
857
  }
858
+ if (config.showWaitingTip) {
859
+ try {
860
+ await sendWithTimeout(session, texts.waitingTipText);
861
+ }
862
+ catch { }
863
+ }
693
864
  await flush(session, us);
694
865
  });
695
- setInterval(() => {
696
- const now = Date.now();
697
- for (const [key, { expire }] of urlCache.entries()) {
698
- if (expire <= now)
699
- urlCache.delete(key);
866
+ const tempCleanupInterval = setInterval(async () => {
867
+ try {
868
+ const tempDir = config.tempDir || './temp_videos';
869
+ const files = await promises_1.default.readdir(tempDir);
870
+ const now = Date.now();
871
+ let deletedCount = 0;
872
+ for (const file of files) {
873
+ if (file.startsWith('video_') && file.endsWith('.mp4')) {
874
+ const filePath = path_1.default.join(tempDir, file);
875
+ const stats = await promises_1.default.stat(filePath);
876
+ if (now - stats.mtimeMs > 3600000) {
877
+ await promises_1.default.unlink(filePath).catch(() => { });
878
+ deletedCount++;
879
+ }
880
+ }
881
+ }
882
+ if (deletedCount > 0) {
883
+ debugLog('INFO', `清理了 ${deletedCount} 个过期临时视频文件`);
884
+ }
700
885
  }
701
- }, 60000);
886
+ catch (e) {
887
+ debugLog('WARN', '清理临时文件失败:', e);
888
+ }
889
+ }, 3600000);
890
+ ctx.on('dispose', () => {
891
+ clearInterval(tempCleanupInterval);
892
+ urlCache.clear();
893
+ debugLog('INFO', '插件已卸载,资源已清理');
894
+ });
702
895
  process.on('exit', async () => {
703
896
  try {
704
897
  const tempDir = config.tempDir || './temp_videos';
@@ -708,6 +901,7 @@ function apply(ctx, config) {
708
901
  await promises_1.default.unlink(path_1.default.join(tempDir, file)).catch(() => { });
709
902
  }
710
903
  }
904
+ debugLog('INFO', '进程退出,已清理所有临时视频文件');
711
905
  }
712
906
  catch { }
713
907
  });
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.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - 📤 支持OneBot平台消息合并转发,优化多图文展示体验
12
12
  - 💬 所有提示文案均可自定义,适配多语言场景
13
13
  - 🔁 消息发送支持自动重试,与API重试配置联动,增强稳定性
14
- - 🚀 内置内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
14
+ - 🚀 内置LRU内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
15
15
  - ⚡ 智能视频发送策略:普通平台优先直接发送URL,特殊平台自动降级为本地文件发送
16
16
  - 🛡️ 可选视频大小限制,防止超大文件占满服务器磁盘;自动清理所有临时文件
17
17
 
@@ -24,7 +24,7 @@ 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
27
+ - 🚀 Built-in LRU memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
28
28
  - ⚡ Smart video sending strategy: priority to send URL directly for common platforms, auto downgrade to local file for special platforms
29
29
  - 🛡️ Optional video size limit to prevent oversized files from filling up server disk; automatic cleanup of all temporary files
30
30
 
@@ -63,13 +63,14 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
63
63
  | `tempDir` | string | `./temp_videos` | 临时视频存储目录 |
64
64
  | `maxVideoSize` | number | 0 | 最大下载视频大小(MB),0 为不限制大小 |
65
65
  | `forceDownloadVideo` | boolean | true | 强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题) |
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