koishi-plugin-video-parser-all 0.9.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -11,6 +11,9 @@ export declare const Config: Schema<{
11
11
  showImageText?: boolean | null | undefined;
12
12
  showVideoFile?: boolean | null | undefined;
13
13
  maxDescLength?: number | null | undefined;
14
+ videoDownloadTimeout?: number | null | undefined;
15
+ tempDir?: string | null | undefined;
16
+ maxVideoSize?: number | null | undefined;
14
17
  } & {
15
18
  timeout?: number | null | undefined;
16
19
  videoSendTimeout?: number | null | undefined;
@@ -38,6 +41,9 @@ export declare const Config: Schema<{
38
41
  showImageText: boolean;
39
42
  showVideoFile: boolean;
40
43
  maxDescLength: number;
44
+ videoDownloadTimeout: number;
45
+ tempDir: string;
46
+ maxVideoSize: number;
41
47
  } & {
42
48
  timeout: number;
43
49
  videoSendTimeout: number;
package/lib/index.js CHANGED
@@ -7,6 +7,10 @@ exports.Config = exports.name = void 0;
7
7
  exports.apply = apply;
8
8
  const koishi_1 = require("koishi");
9
9
  const axios_1 = __importDefault(require("axios"));
10
+ const promises_1 = __importDefault(require("fs/promises"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const fs_1 = require("fs");
13
+ const promises_2 = require("stream/promises");
10
14
  exports.name = 'video-parser-all';
11
15
  exports.Config = koishi_1.Schema.intersect([
12
16
  koishi_1.Schema.object({
@@ -22,6 +26,9 @@ exports.Config = koishi_1.Schema.intersect([
22
26
  showImageText: koishi_1.Schema.boolean().default(true).description('是否发送解析后的文字内容'),
23
27
  showVideoFile: koishi_1.Schema.boolean().default(true).description('是否发送视频文件(关闭则只发送视频链接)'),
24
28
  maxDescLength: koishi_1.Schema.number().default(200).description('简介内容最大长度(字符),超出自动截断'),
29
+ videoDownloadTimeout: koishi_1.Schema.number().default(120000).description('视频下载超时(毫秒)'),
30
+ tempDir: koishi_1.Schema.string().default('./temp_videos').description('临时视频存储目录'),
31
+ maxVideoSize: koishi_1.Schema.number().min(0).step(1).default(0).description('最大下载视频大小(MB),0 为不限制大小'),
25
32
  }).description('内容显示设置'),
26
33
  koishi_1.Schema.object({
27
34
  timeout: koishi_1.Schema.number().min(0).default(180000).description('API 请求超时(毫秒)'),
@@ -99,6 +106,20 @@ function linkTypeParser(content) {
99
106
  }
100
107
  return matches;
101
108
  }
109
+ function extractUrl(content) {
110
+ const urlMatches = content.match(/https?:\/\/[^\s\"\'\>]+/gi) || [];
111
+ return urlMatches.filter(url => {
112
+ try {
113
+ const hostname = new URL(url).hostname.toLowerCase();
114
+ if (hostname === 'multimedia.nt.qq.com.cn')
115
+ return false;
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ });
122
+ }
102
123
  function extractAllUrlsFromMessage(session) {
103
124
  const content = session.content?.trim() || '';
104
125
  const urls = [];
@@ -116,8 +137,11 @@ function extractAllUrlsFromMessage(session) {
116
137
  if (session.elements) {
117
138
  for (const elem of session.elements) {
118
139
  if (elem.type === 'xml' && elem.data) {
119
- const xmlUrls = extractUrlsFromXmlLegacy(elem.data);
120
- urls.push(...xmlUrls);
140
+ const urlRegex = /https?:\/\/[^\s<>"']+/gi;
141
+ let match;
142
+ while ((match = urlRegex.exec(elem.data)) !== null) {
143
+ urls.push(match[0]);
144
+ }
121
145
  }
122
146
  else if (elem.type === 'json' && elem.data) {
123
147
  try {
@@ -143,29 +167,6 @@ function extractAllUrlsFromMessage(session) {
143
167
  }
144
168
  return [...new Set(urls)];
145
169
  }
146
- function extractUrlsFromXmlLegacy(xml) {
147
- const urls = [];
148
- const urlRegex = /https?:\/\/[^\s<>"']+/gi;
149
- let match;
150
- while ((match = urlRegex.exec(xml)) !== null) {
151
- urls.push(match[0]);
152
- }
153
- return urls;
154
- }
155
- function extractUrl(content) {
156
- const urlMatches = content.match(/https?:\/\/[^\s\"\'\>]+/gi) || [];
157
- return urlMatches.filter(url => {
158
- try {
159
- const hostname = new URL(url).hostname.toLowerCase();
160
- if (hostname === 'multimedia.nt.qq.com.cn')
161
- return false;
162
- return true;
163
- }
164
- catch {
165
- return false;
166
- }
167
- });
168
- }
169
170
  function cleanUrl(url) {
170
171
  try {
171
172
  url = url.replace(/&amp;/g, '&');
@@ -207,7 +208,7 @@ function formatDuration(seconds) {
207
208
  const s = Math.floor(seconds % 60);
208
209
  if (h > 0)
209
210
  return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
210
- return `${m}:${s.toString().padStart(2, '0')}`;
211
+ return `${m}:${s.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
211
212
  }
212
213
  function formatPublishTime(ms) {
213
214
  if (!ms)
@@ -361,6 +362,45 @@ function buildForwardNode(session, content, botName) {
361
362
  }
362
363
  const urlCache = new Map();
363
364
  const CACHE_TTL = 10 * 60 * 1000;
365
+ async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
366
+ await promises_1.default.mkdir(tempDir, { recursive: true });
367
+ const fileName = `video_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.mp4`;
368
+ const filePath = path_1.default.join(tempDir, fileName);
369
+ const writer = (0, fs_1.createWriteStream)(filePath);
370
+ const response = await (0, axios_1.default)({
371
+ method: 'GET',
372
+ url: videoUrl,
373
+ responseType: 'stream',
374
+ timeout: timeout,
375
+ headers: {
376
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
377
+ }
378
+ });
379
+ const maxSizeBytes = maxSizeMB * 1024 * 1024;
380
+ const contentLength = Number(response.headers['content-length'] || 0);
381
+ if (maxSizeMB > 0 && contentLength > maxSizeBytes) {
382
+ writer.destroy();
383
+ await promises_1.default.unlink(filePath).catch(() => { });
384
+ throw new Error(`视频文件过大(${Math.round(contentLength / 1024 / 1024)}MB),超过限制(${maxSizeMB}MB)`);
385
+ }
386
+ let downloadedSize = 0;
387
+ response.data.on('data', (chunk) => {
388
+ downloadedSize += chunk.length;
389
+ if (maxSizeMB > 0 && downloadedSize > maxSizeBytes) {
390
+ response.data.destroy();
391
+ writer.destroy();
392
+ promises_1.default.unlink(filePath).catch(() => { });
393
+ throw new Error(`视频文件过大,超过限制(${maxSizeMB}MB)`);
394
+ }
395
+ });
396
+ await (0, promises_2.pipeline)(response.data, writer);
397
+ return filePath;
398
+ }
399
+ function getErrorMessage(error) {
400
+ if (error instanceof Error)
401
+ return error.message;
402
+ return String(error);
403
+ }
364
404
  function apply(ctx, config) {
365
405
  debugEnabled = config.debug || false;
366
406
  debugLog('INFO', '插件初始化开始');
@@ -471,6 +511,29 @@ function apply(ctx, config) {
471
511
  }
472
512
  return null;
473
513
  }
514
+ async function sendVideoFile(session, videoUrl) {
515
+ if (!videoUrl)
516
+ throw new Error('视频链接为空');
517
+ try {
518
+ debugLog('INFO', `尝试直接发送视频URL: ${videoUrl.substring(0, 100)}...`);
519
+ return await sendWithTimeout(session, koishi_1.h.video(videoUrl));
520
+ }
521
+ catch (err) {
522
+ debugLog('ERROR', `直接发送URL失败,开始下载视频: ${getErrorMessage(err)}`);
523
+ let tempFilePath = null;
524
+ try {
525
+ tempFilePath = await downloadVideoFile(videoUrl, config.tempDir || './temp_videos', config.videoDownloadTimeout || 120000, config.maxVideoSize || 0);
526
+ const localFile = `file://${path_1.default.resolve(tempFilePath)}`;
527
+ debugLog('INFO', `视频下载完成,发送本地文件: ${localFile}`);
528
+ return await sendWithTimeout(session, koishi_1.h.video(localFile));
529
+ }
530
+ finally {
531
+ if (tempFilePath) {
532
+ promises_1.default.unlink(tempFilePath).catch(e => debugLog('WARN', `删除临时文件失败: ${e}`));
533
+ }
534
+ }
535
+ }
536
+ }
474
537
  async function flush(session, urls) {
475
538
  const uniqueUrls = [...new Set(urls)];
476
539
  const items = [];
@@ -504,44 +567,75 @@ function apply(ctx, config) {
504
567
  return;
505
568
  const enableForward = config.enableForward && session.platform === 'onebot';
506
569
  const botName = config.botName || '视频解析机器人';
507
- const forwardMessages = [];
508
- for (const item of items) {
509
- const p = item.parsed;
510
- const text = item.text;
511
- if (text && config.showImageText) {
512
- if (enableForward)
570
+ if (enableForward) {
571
+ const forwardMessages = [];
572
+ for (const item of items) {
573
+ const p = item.parsed;
574
+ const text = item.text;
575
+ if (text && config.showImageText) {
513
576
  forwardMessages.push(buildForwardNode(session, text, botName));
514
- else {
515
- await sendWithTimeout(session, text);
516
- await delay(300);
517
577
  }
518
- }
519
- if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
520
- if (enableForward)
578
+ if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
521
579
  forwardMessages.push(buildForwardNode(session, koishi_1.h.image(p.cover), botName));
522
- else {
523
- await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
524
- await delay(300);
580
+ }
581
+ if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
582
+ const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
583
+ for (const imgUrl of imageUrls) {
584
+ forwardMessages.push(buildForwardNode(session, koishi_1.h.image(imgUrl), botName));
585
+ }
525
586
  }
526
587
  }
527
- if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
528
- const videoMsg = koishi_1.h.video(p.video);
529
- if (enableForward) {
530
- forwardMessages.push(buildForwardNode(session, videoMsg, botName));
588
+ if (forwardMessages.length) {
589
+ const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
590
+ try {
591
+ await sendWithTimeout(session, forwardMsg, config.retryTimes);
531
592
  }
532
- else {
533
- await sendWithTimeout(session, videoMsg).catch(() => { });
593
+ catch (err) {
594
+ debugLog('ERROR', '合并转发发送失败,降级为逐条发送:', err);
595
+ for (const node of forwardMessages) {
596
+ await sendWithTimeout(session, node.data.content).catch(() => { });
597
+ await delay(300);
598
+ }
599
+ }
600
+ }
601
+ for (const item of items) {
602
+ const p = item.parsed;
603
+ if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
604
+ try {
605
+ await sendVideoFile(session, p.video);
606
+ }
607
+ catch (err) {
608
+ debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
609
+ await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
610
+ }
534
611
  await delay(500);
535
612
  }
536
613
  }
537
- if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
538
- const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
539
- if (enableForward) {
540
- for (const imgUrl of imageUrls) {
541
- forwardMessages.push(buildForwardNode(session, koishi_1.h.image(imgUrl), botName));
614
+ }
615
+ else {
616
+ for (const item of items) {
617
+ const p = item.parsed;
618
+ const text = item.text;
619
+ if (text && config.showImageText) {
620
+ await sendWithTimeout(session, text);
621
+ await delay(300);
622
+ }
623
+ if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
624
+ await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
625
+ await delay(300);
626
+ }
627
+ if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
628
+ try {
629
+ await sendVideoFile(session, p.video);
630
+ }
631
+ catch (err) {
632
+ debugLog('ERROR', `视频发送失败(降级发送链接): ${getErrorMessage(err)}`);
633
+ await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
542
634
  }
635
+ await delay(500);
543
636
  }
544
- else {
637
+ if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
638
+ const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
545
639
  for (const imgUrl of imageUrls) {
546
640
  await sendWithTimeout(session, koishi_1.h.image(imgUrl)).catch(() => { });
547
641
  await delay(200);
@@ -549,15 +643,6 @@ function apply(ctx, config) {
549
643
  }
550
644
  }
551
645
  }
552
- if (enableForward && forwardMessages.length) {
553
- const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
554
- await sendWithTimeout(session, forwardMsg, config.retryTimes).catch(() => {
555
- debugLog('ERROR', '合并转发发送最终失败,降级为逐条发送');
556
- for (const node of forwardMessages) {
557
- sendWithTimeout(session, node.data.content).catch(() => { });
558
- }
559
- });
560
- }
561
646
  }
562
647
  ctx.on('message', async (session) => {
563
648
  if (!config.enable)
@@ -588,10 +673,17 @@ function apply(ctx, config) {
588
673
  urlCache.delete(key);
589
674
  }
590
675
  }, 60000);
676
+ process.on('exit', async () => {
677
+ try {
678
+ const tempDir = config.tempDir || './temp_videos';
679
+ const files = await promises_1.default.readdir(tempDir);
680
+ for (const file of files) {
681
+ if (file.startsWith('video_') && file.endsWith('.mp4')) {
682
+ await promises_1.default.unlink(path_1.default.join(tempDir, file)).catch(() => { });
683
+ }
684
+ }
685
+ }
686
+ catch { }
687
+ });
591
688
  debugLog('INFO', '插件初始化完成');
592
689
  }
593
- function getErrorMessage(error) {
594
- if (error instanceof Error)
595
- return error.message;
596
- return String(error);
597
- }
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": "0.9.8",
4
+ "version": "1.0.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -5,24 +5,28 @@
5
5
  ### 中文
6
6
  这是一个为 Koishi 机器人框架开发的**全平台视频/图集解析插件**,使用统一API接口,支持自动识别并解析抖音、快手、B站、小红书、微博、YouTube、TikTok、剪映、AcFun、知乎、虎牙等20+主流平台的短视频/图集/实况链接。核心特性:
7
7
  - 🌐 统一API解析,覆盖20+热门平台,无需繁琐配置
8
- - 🤖 自动识别链接来源,即丢即用,并支持解析 XML 卡片消息中的链接(如 QQ/OneBot 平台的分享卡片)
8
+ - 🤖 自动识别链接来源,即丢即用,并支持解析 XML/JSON 卡片消息中的链接(如 QQ/OneBot 平台的分享卡片)
9
9
  - 🎨 完全自定义的解析结果格式,支持多项变量替换,变量无值自动隐藏行
10
10
  - 🐛 内置Debug调试模式,可详细记录所有操作与API交互日志
11
11
  - 📤 支持OneBot平台消息合并转发,优化多图文展示体验
12
12
  - 💬 所有提示文案均可自定义,适配多语言场景
13
13
  - 🔁 消息发送支持自动重试,与API重试配置联动,增强稳定性
14
14
  - 🚀 内置内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
15
+ - ⚡ 优先直接发送视频URL,失败自动降级为本地文件发送,大幅提升性能
16
+ - 🛡️ 可选视频大小限制,防止超大文件占满服务器磁盘;自动清理所有临时文件
15
17
 
16
18
  ### English
17
19
  This is a **multi-platform video/image parsing plugin** developed for the Koishi bot framework, using a unified API interface to automatically recognize and parse short video/image/live photo links from 20+ mainstream platforms such as Douyin, Kuaishou, Bilibili, Xiaohongshu, Weibo, YouTube, TikTok, Jianying, AcFun, Zhihu, Huya and more. Core features:
18
20
  - 🌐 Unified API parsing, covering 20+ popular platforms without complex configuration
19
- - 🤖 Auto-detection of link sources, drop & go, and support for extracting links from XML card messages (e.g., share cards on QQ/OneBot)
21
+ - 🤖 Auto-detection of link sources, drop & go, and support for extracting links from XML/JSON card messages (e.g., share cards on QQ/OneBot)
20
22
  - 🎨 Fully customizable parsing result format with variable substitutions, empty variables hide the line automatically
21
23
  - 🐛 Built-in Debug mode, recording detailed operations and API interaction logs
22
24
  - 📤 Support OneBot message forwarding for better image/video display
23
25
  - 💬 All prompt texts are customizable for multilingual scenarios
24
26
  - 🔁 Message sending supports automatic retries, linked with API retry configuration for improved stability
25
27
  - 🚀 Built-in memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
28
+ - ⚡ Priority to send video URL directly, automatically downgrade to local file sending on failure, greatly improving performance
29
+ - 🛡️ Optional video size limit to prevent oversized files from filling up server disk; automatic cleanup of all temporary files
26
30
 
27
31
  ## 项目仓库 (Repository)
28
32
  - GitHub: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all`
@@ -55,13 +59,16 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
55
59
  | `showImageText` | boolean | true | 是否发送解析后的文字内容 |
56
60
  | `showVideoFile` | boolean | true | 是否发送视频文件(关闭则只发送视频链接) |
57
61
  | `maxDescLength` | number | 200 | 简介内容最大长度(字符),超出自动截断 |
62
+ | `videoDownloadTimeout` | number | 120000 | 视频下载超时(毫秒) |
63
+ | `tempDir` | string | `./temp_videos` | 临时视频存储目录 |
64
+ | `maxVideoSize` | number | 0 | 最大下载视频大小(MB),0 为不限制大小 |
58
65
 
59
66
  ### 网络与 API 设置
60
67
  | 配置项 | 类型 | 默认值 | 说明 |
61
68
  |--------|------|--------|------|
62
69
  | `timeout` | number | 180000 | API 请求超时时间(毫秒) |
63
70
  | `videoSendTimeout` | number | 60000 | 视频消息发送超时时间(毫秒,0 为不限制) |
64
- | `userAgent` | string | Chrome 124 UA | API 请求使用的 User-Agent |
71
+ | `userAgent` | string | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36` | API 请求使用的 User-Agent |
65
72
 
66
73
  ### 错误与重试设置
67
74
  | 配置项 | 类型 | 默认值 | 说明 |
@@ -82,7 +89,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
82
89
  | `unsupportedPlatformText` | string | 不支持该平台链接 | 不支持的平台提示 |
83
90
  | `invalidLinkText` | string | 无效的视频链接 | 无效链接提示(parse 指令) |
84
91
  | `parseErrorPrefix` | string | ❌ 解析失败: | 解析失败消息前缀 |
85
- | `parseErrorItemFormat` | string | 【${url}】: ${msg} | 每条解析失败的展示格式,可用 ${url}(链接)和 ${msg}(错误信息) |
92
+ | `parseErrorItemFormat` | string | `【${url}】: ${msg}` | 每条解析失败的展示格式,可用 ${url}(链接)和 ${msg}(错误信息) |
86
93
 
87
94
  ## 支持的变量 (Supported Variables)
88
95
  在 `unifiedMessageFormat` 中可使用以下变量进行自定义格式化,某行所有变量均为空(或为"0")时该行不显示: