koishi-plugin-video-parser-all 0.9.6 → 0.9.7

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.
Files changed (3) hide show
  1. package/lib/index.js +168 -51
  2. package/package.json +5 -4
  3. package/readme.md +4 -2
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ 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 fast_xml_parser_1 = require("fast-xml-parser");
10
11
  exports.name = 'video-parser-all';
11
12
  exports.Config = koishi_1.Schema.intersect([
12
13
  koishi_1.Schema.object({
@@ -16,7 +17,7 @@ exports.Config = koishi_1.Schema.intersect([
16
17
  debug: koishi_1.Schema.boolean().default(false).description('开启调试模式,在控制台输出详细日志'),
17
18
  }).description('基础设置'),
18
19
  koishi_1.Schema.object({
19
- unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default(`标题:\${标题}\n作者:\${作者}\n简介:\${简介}\n点赞:\${点赞数}\n收藏:\${收藏数}\n转发:\${转发数}\n播放:\${播放数}\n评论:\${评论数}\n图片数量:\${图片数量}`).description('统一消息格式,可用变量:${标题} ${作者} ${简介} ${点赞数} ${收藏数} ${转发数} ${播放数} ${评论数} ${视频时长} ${发布时间} ${图片数量} ${作者ID} ${封面}'),
20
+ unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default(`标题:${'标题'}\n作者:${'作者'}\n简介:${'简介'}\n点赞:${'点赞数'}\n收藏:${'收藏数'}\n转发:${'转发数'}\n播放:${'播放数'}\n评论:${'评论数'}\n图片数量:${'图片数量'}`).description('统一消息格式,可用变量:${标题} ${作者} ${简介} ${点赞数} ${收藏数} ${转发数} ${播放数} ${评论数} ${视频时长} ${发布时间} ${图片数量} ${作者ID} ${封面}'),
20
21
  }).description('消息格式设置'),
21
22
  koishi_1.Schema.object({
22
23
  showImageText: koishi_1.Schema.boolean().default(true).description('是否发送解析后的文字内容'),
@@ -85,6 +86,87 @@ function getErrorMessage(error) {
85
86
  return error.message;
86
87
  return String(error);
87
88
  }
89
+ const xmlParser = new fast_xml_parser_1.XMLParser({
90
+ ignoreAttributes: false,
91
+ attributeNamePrefix: '@_',
92
+ allowBooleanAttributes: true,
93
+ trimValues: true,
94
+ parseTagValue: false,
95
+ isArray: (name) => name === 'item' || name === 'picture',
96
+ });
97
+ function extractUrlsFromXml(xml) {
98
+ const urls = [];
99
+ try {
100
+ const parsed = xmlParser.parse(xml);
101
+ const msg = parsed?.msg;
102
+ if (!msg)
103
+ return urls;
104
+ if (msg.source && typeof msg.source === 'object') {
105
+ const sourceUrl = msg.source['@_url'];
106
+ if (sourceUrl && typeof sourceUrl === 'string')
107
+ urls.push(sourceUrl);
108
+ }
109
+ const items = Array.isArray(msg.item) ? msg.item : (msg.item ? [msg.item] : []);
110
+ for (const item of items) {
111
+ const pictures = Array.isArray(item.picture) ? item.picture : (item.picture ? [item.picture] : []);
112
+ for (const pic of pictures) {
113
+ if (pic['@_cover'] && typeof pic['@_cover'] === 'string')
114
+ urls.push(pic['@_cover']);
115
+ }
116
+ if (item.title && typeof item.title === 'string') {
117
+ const match = item.title.match(/https?:\/\/[^\s<>"']+/i);
118
+ if (match)
119
+ urls.push(match[0]);
120
+ }
121
+ if (item.summary && typeof item.summary === 'string') {
122
+ const matches = item.summary.match(/https?:\/\/[^\s<>"']+/gi);
123
+ if (matches)
124
+ urls.push(...matches);
125
+ }
126
+ }
127
+ }
128
+ catch (err) {
129
+ debugLog('WARN', `解析 XML 卡片失败: ${getErrorMessage(err)}`);
130
+ }
131
+ return urls;
132
+ }
133
+ function extractAllUrlsFromMessage(session) {
134
+ const urls = [];
135
+ const content = session.content?.trim() || '';
136
+ if (content) {
137
+ const textUrls = extractUrl(content);
138
+ urls.push(...textUrls);
139
+ }
140
+ if (session.elements) {
141
+ for (const elem of session.elements) {
142
+ if (elem.type === 'xml' && elem.data) {
143
+ const xmlUrls = extractUrlsFromXml(elem.data);
144
+ urls.push(...xmlUrls);
145
+ }
146
+ else if (elem.type === 'json' && elem.data) {
147
+ try {
148
+ const json = JSON.parse(elem.data);
149
+ const extractFromObject = (obj) => {
150
+ if (!obj || typeof obj !== 'object')
151
+ return;
152
+ for (const val of Object.values(obj)) {
153
+ if (typeof val === 'string') {
154
+ const match = val.match(/https?:\/\/[^\s<>"']+/gi);
155
+ if (match)
156
+ urls.push(...match);
157
+ }
158
+ else if (typeof val === 'object')
159
+ extractFromObject(val);
160
+ }
161
+ };
162
+ extractFromObject(json);
163
+ }
164
+ catch { }
165
+ }
166
+ }
167
+ }
168
+ return [...new Set(urls)];
169
+ }
88
170
  function extractUrl(content) {
89
171
  const urlMatches = content.match(/https?:\/\/[^\s\"\'\>]+/gi) || [];
90
172
  return urlMatches.filter(url => {
@@ -137,9 +219,9 @@ async function resolveShortUrl(url) {
137
219
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
138
220
  'Referer': 'https://www.baidu.com/',
139
221
  },
140
- validateStatus: status => true
222
+ validateStatus: status => status >= 200 && status < 400,
141
223
  });
142
- const finalUrl = res.request.res?.responseUrl || url;
224
+ const finalUrl = res.request?.res?.responseUrl || url;
143
225
  return cleanUrl(finalUrl);
144
226
  }
145
227
  catch (e) {
@@ -152,7 +234,9 @@ function formatDuration(seconds) {
152
234
  const h = Math.floor(seconds / 3600);
153
235
  const m = Math.floor((seconds % 3600) / 60);
154
236
  const s = Math.floor(seconds % 60);
155
- return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
237
+ if (h > 0)
238
+ return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
239
+ return `${m}:${s.toString().padStart(2, '0')}`;
156
240
  }
157
241
  function formatPublishTime(ms) {
158
242
  if (!ms)
@@ -185,7 +269,7 @@ function parseApiResponse(raw, maxDescLen) {
185
269
  }
186
270
  const authorObj = data.author;
187
271
  let author = '', uid = '', avatar = '';
188
- if (typeof authorObj === 'object' && authorObj) {
272
+ if (authorObj && typeof authorObj === 'object') {
189
273
  author = authorObj.name || authorObj.author || '';
190
274
  uid = String(authorObj.id || data.uid || '');
191
275
  avatar = authorObj.avatar || data.avatar || '';
@@ -200,12 +284,12 @@ function parseApiResponse(raw, maxDescLen) {
200
284
  const cover = data.cover || '';
201
285
  let video = '';
202
286
  let videos = [];
203
- if (data.video_backup?.length) {
287
+ if (Array.isArray(data.video_backup) && data.video_backup.length) {
204
288
  const bestQ = pickBestQuality(data.video_backup);
205
289
  videos = bestQ;
206
290
  video = bestQ[0]?.url || data.url || '';
207
291
  }
208
- else if (data.videos?.length) {
292
+ else if (Array.isArray(data.videos) && data.videos.length) {
209
293
  video = data.videos[0]?.url || '';
210
294
  videos = data.videos.map((v) => ({ quality: v.accept?.[0] || 'unknown', url: v.url }));
211
295
  }
@@ -221,14 +305,14 @@ function parseApiResponse(raw, maxDescLen) {
221
305
  url: data.music?.url || ''
222
306
  };
223
307
  const stats = extra.statistics || {};
224
- const like = Number(data.like || stats.digg_count || 0);
225
- const comment = Number(stats.comment_count || 0);
226
- const collect = Number(stats.collect_count || 0);
227
- const share = Number(stats.share_count || 0);
228
- const play = Number(stats.play_count || 0);
308
+ const like = Number(data.like ?? stats.digg_count ?? 0);
309
+ const comment = Number(stats.comment_count ?? 0);
310
+ const collect = Number(stats.collect_count ?? 0);
311
+ const share = Number(stats.share_count ?? 0);
312
+ const play = Number(stats.play_count ?? 0);
229
313
  let duration = 0;
230
314
  if (data.duration) {
231
- duration = typeof data.duration === 'string' ? parseInt(data.duration) : data.duration;
315
+ duration = typeof data.duration === 'string' ? parseInt(data.duration, 10) : data.duration;
232
316
  if (duration > 1000000)
233
317
  duration = Math.floor(duration / 1000);
234
318
  }
@@ -237,7 +321,7 @@ function parseApiResponse(raw, maxDescLen) {
237
321
  }
238
322
  let publishTime = 0;
239
323
  if (data.time) {
240
- publishTime = typeof data.time === 'number' ? data.time : parseInt(data.time);
324
+ publishTime = typeof data.time === 'number' ? data.time : parseInt(data.time, 10);
241
325
  if (publishTime < 1000000000000)
242
326
  publishTime *= 1000;
243
327
  }
@@ -277,7 +361,7 @@ function generateFormattedText(p, format) {
277
361
  for (const match of varMatches) {
278
362
  const varName = match.replace(/\$\{|\}/g, '');
279
363
  const val = vars[varName];
280
- if (val !== undefined && val !== '' && val !== '0') {
364
+ if (val && val !== '0') {
281
365
  allEmpty = false;
282
366
  break;
283
367
  }
@@ -304,6 +388,8 @@ function buildForwardNode(session, content, botName) {
304
388
  messageContent = [koishi_1.h.text(String(content))];
305
389
  return (0, koishi_1.h)('node', { user: { nickname: botName.substring(0, 15), user_id: session.selfId } }, messageContent);
306
390
  }
391
+ const urlCache = new Map();
392
+ const CACHE_TTL = 10 * 60 * 1000;
307
393
  function apply(ctx, config) {
308
394
  debugEnabled = config.debug || false;
309
395
  debugLog('INFO', '插件初始化开始');
@@ -323,7 +409,14 @@ function apply(ctx, config) {
323
409
  }
324
410
  });
325
411
  async function fetchApi(url) {
412
+ const cacheKey = url;
413
+ const cached = urlCache.get(cacheKey);
414
+ if (cached && cached.expire > Date.now()) {
415
+ debugLog('DEBUG', `使用缓存: ${url}`);
416
+ return cached.data;
417
+ }
326
418
  debugLog('INFO', `调用API解析: ${url}`);
419
+ let lastError = null;
327
420
  for (let i = 0; i <= config.retryTimes; i++) {
328
421
  try {
329
422
  const res = await http.get('https://api.bugpk.com/api/short_videos', {
@@ -332,17 +425,21 @@ function apply(ctx, config) {
332
425
  });
333
426
  debugLog('DEBUG', `API响应: ${JSON.stringify(res.data)}`);
334
427
  if (res.data && (res.data.code === 200 || res.data.code === 0)) {
335
- return parseApiResponse(res.data, config.maxDescLength);
428
+ const parsed = parseApiResponse(res.data, config.maxDescLength);
429
+ urlCache.set(cacheKey, { data: parsed, expire: Date.now() + CACHE_TTL });
430
+ return parsed;
336
431
  }
337
432
  throw new Error(res.data?.msg || '解析失败');
338
433
  }
339
434
  catch (error) {
340
- debugLog('ERROR', `第${i + 1}次请求失败: ${getErrorMessage(error)}`);
341
- if (i < config.retryTimes)
342
- await delay(config.retryInterval * (i + 1));
435
+ lastError = error instanceof Error ? error : new Error(String(error));
436
+ debugLog('ERROR', `第${i + 1}次请求失败: ${lastError.message}`);
437
+ if (i < config.retryTimes) {
438
+ await delay(config.retryInterval);
439
+ }
343
440
  }
344
441
  }
345
- throw new Error('API请求全部失败');
442
+ throw lastError || new Error('API请求全部失败');
346
443
  }
347
444
  async function parseUrl(url) {
348
445
  const realUrl = await resolveShortUrl(url);
@@ -350,13 +447,14 @@ function apply(ctx, config) {
350
447
  if (!platform) {
351
448
  return { success: false, msg: texts.unsupportedPlatformText };
352
449
  }
353
- for (const candidate of [url, realUrl]) {
450
+ const candidates = [realUrl, url];
451
+ for (const candidate of [...new Set(candidates)]) {
354
452
  try {
355
453
  const info = await fetchApi(candidate);
356
454
  return { success: true, data: info };
357
455
  }
358
456
  catch (error) {
359
- debugLog('ERROR', `候选链接解析失败: ${candidate}`);
457
+ debugLog('ERROR', `候选链接解析失败: ${candidate}`, getErrorMessage(error));
360
458
  }
361
459
  }
362
460
  return { success: false, msg: '解析失败' };
@@ -371,19 +469,26 @@ function apply(ctx, config) {
371
469
  async function sendWithTimeout(session, content, customRetries) {
372
470
  const maxRetries = customRetries ?? config.retryTimes ?? 3;
373
471
  const retryDelay = config.retryInterval || 1000;
472
+ let timeoutId = null;
374
473
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
375
474
  try {
376
- if (config.videoSendTimeout <= 0) {
377
- return await session.send(content);
475
+ let sendPromise = session.send(content);
476
+ if (config.videoSendTimeout > 0) {
477
+ const timeoutPromise = new Promise((_, reject) => {
478
+ timeoutId = setTimeout(() => reject(new Error('发送超时')), config.videoSendTimeout);
479
+ });
480
+ const result = await Promise.race([sendPromise, timeoutPromise]);
481
+ if (timeoutId)
482
+ clearTimeout(timeoutId);
483
+ return result;
378
484
  }
379
485
  else {
380
- return await Promise.race([
381
- session.send(content),
382
- new Promise((_, reject) => setTimeout(() => reject(new Error('发送超时')), config.videoSendTimeout))
383
- ]);
486
+ return await sendPromise;
384
487
  }
385
488
  }
386
489
  catch (err) {
490
+ if (timeoutId)
491
+ clearTimeout(timeoutId);
387
492
  const errMsg = getErrorMessage(err);
388
493
  debugLog('ERROR', `第${attempt + 1}次发送失败: ${errMsg}`);
389
494
  if (attempt < maxRetries) {
@@ -400,18 +505,28 @@ function apply(ctx, config) {
400
505
  return null;
401
506
  }
402
507
  async function flush(session, urls) {
508
+ const uniqueUrls = [...new Set(urls)];
403
509
  const items = [];
404
510
  const errors = [];
405
- for (const url of urls) {
406
- const res = await processSingleUrl(url);
407
- if (res.success) {
408
- items.push(res.data);
409
- }
410
- else {
411
- const item = texts.parseErrorItemFormat
412
- .replace(/\$\{url\}/g, url.length > 50 ? url.slice(0, 50) + '...' : url)
413
- .replace(/\$\{msg\}/g, res.msg);
414
- errors.push(item);
511
+ const concurrency = 3;
512
+ const chunks = [];
513
+ for (let i = 0; i < uniqueUrls.length; i += concurrency) {
514
+ chunks.push(uniqueUrls.slice(i, i + concurrency));
515
+ }
516
+ for (const chunk of chunks) {
517
+ const results = await Promise.all(chunk.map(url => processSingleUrl(url)));
518
+ for (let idx = 0; idx < results.length; idx++) {
519
+ const res = results[idx];
520
+ if (res.success) {
521
+ items.push(res.data);
522
+ }
523
+ else {
524
+ const url = chunk[idx];
525
+ const item = texts.parseErrorItemFormat
526
+ .replace(/\$\{url\}/g, url.length > 50 ? url.slice(0, 50) + '...' : url)
527
+ .replace(/\$\{msg\}/g, res.msg);
528
+ errors.push(item);
529
+ }
415
530
  }
416
531
  }
417
532
  if (errors.length) {
@@ -455,13 +570,13 @@ function apply(ctx, config) {
455
570
  if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
456
571
  const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
457
572
  if (enableForward) {
458
- for (const url of imageUrls) {
459
- forwardMessages.push(buildForwardNode(session, koishi_1.h.image(url), botName));
573
+ for (const imgUrl of imageUrls) {
574
+ forwardMessages.push(buildForwardNode(session, koishi_1.h.image(imgUrl), botName));
460
575
  }
461
576
  }
462
577
  else {
463
- for (const url of imageUrls) {
464
- await sendWithTimeout(session, koishi_1.h.image(url)).catch(() => { });
578
+ for (const imgUrl of imageUrls) {
579
+ await sendWithTimeout(session, koishi_1.h.image(imgUrl)).catch(() => { });
465
580
  await delay(200);
466
581
  }
467
582
  }
@@ -471,21 +586,16 @@ function apply(ctx, config) {
471
586
  const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
472
587
  await sendWithTimeout(session, forwardMsg, config.retryTimes).catch(() => {
473
588
  debugLog('ERROR', '合并转发发送最终失败,降级为逐条发送');
474
- forwardMessages.forEach(async (node) => {
475
- try {
476
- await sendWithTimeout(session, node.data.content);
477
- await delay(300);
478
- }
479
- catch { }
480
- });
589
+ for (const node of forwardMessages) {
590
+ sendWithTimeout(session, node.data.content).catch(() => { });
591
+ }
481
592
  });
482
593
  }
483
594
  }
484
595
  ctx.on('message', async (session) => {
485
596
  if (!config.enable)
486
597
  return;
487
- const content = session.content?.trim() || '';
488
- const urls = extractUrl(content);
598
+ const urls = extractAllUrlsFromMessage(session);
489
599
  if (!urls.length)
490
600
  return;
491
601
  if (config.showWaitingTip) {
@@ -504,5 +614,12 @@ function apply(ctx, config) {
504
614
  }
505
615
  await flush(session, us);
506
616
  });
617
+ setInterval(() => {
618
+ const now = Date.now();
619
+ for (const [key, { expire }] of urlCache.entries()) {
620
+ if (expire <= now)
621
+ urlCache.delete(key);
622
+ }
623
+ }, 60000);
507
624
  debugLog('INFO', '插件初始化完成');
508
625
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-video-parser-all",
3
- "description": "Koishi 全平台视频解析插件,支持抖音/快手/B站/微博/小红书/剪映/YouTube/TikTok等20+平台",
4
- "version": "0.9.6",
3
+ "description": "Koishi 全平台视频解析插件,支持抖音/快手/B站/微博/小红书/剪映/YouTube/TikTok等20+平台,新增XML卡片链接提取",
4
+ "version": "0.9.7",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -57,7 +57,8 @@
57
57
  "typescript": "^5.3.3"
58
58
  },
59
59
  "dependencies": {
60
- "axios": "^1.6.8",
60
+ "axios": "^1.16.1",
61
+ "fast-xml-parser": "^4.5.6",
61
62
  "stream": "^0.0.3"
62
63
  },
63
64
  "peerDependencies": {
@@ -75,4 +76,4 @@
75
76
  "engines": {
76
77
  "node": ">=16.0.0"
77
78
  }
78
- }
79
+ }
package/readme.md CHANGED
@@ -5,22 +5,24 @@
5
5
  ### 中文
6
6
  这是一个为 Koishi 机器人框架开发的**全平台视频/图集解析插件**,使用统一API接口,支持自动识别并解析抖音、快手、B站、小红书、微博、YouTube、TikTok、剪映、AcFun、知乎、虎牙等20+主流平台的短视频/图集/实况链接。核心特性:
7
7
  - 🌐 统一API解析,覆盖20+热门平台,无需繁琐配置
8
- - 🤖 自动识别链接来源,即丢即用
8
+ - 🤖 自动识别链接来源,即丢即用,并支持解析 XML 卡片消息中的链接(如 QQ/OneBot 平台的分享卡片)
9
9
  - 🎨 完全自定义的解析结果格式,支持多项变量替换,变量无值自动隐藏行
10
10
  - 🐛 内置Debug调试模式,可详细记录所有操作与API交互日志
11
11
  - 📤 支持OneBot平台消息合并转发,优化多图文展示体验
12
12
  - 💬 所有提示文案均可自定义,适配多语言场景
13
13
  - 🔁 消息发送支持自动重试,与API重试配置联动,增强稳定性
14
+ - 🚀 内置内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
14
15
 
15
16
  ### English
16
17
  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:
17
18
  - 🌐 Unified API parsing, covering 20+ popular platforms without complex configuration
18
- - 🤖 Auto-detection of link sources, just drop & go
19
+ - 🤖 Auto-detection of link sources, drop & go, and support for extracting links from XML card messages (e.g., share cards on QQ/OneBot)
19
20
  - 🎨 Fully customizable parsing result format with variable substitutions, empty variables hide the line automatically
20
21
  - 🐛 Built-in Debug mode, recording detailed operations and API interaction logs
21
22
  - 📤 Support OneBot message forwarding for better image/video display
22
23
  - 💬 All prompt texts are customizable for multilingual scenarios
23
24
  - 🔁 Message sending supports automatic retries, linked with API retry configuration for improved stability
25
+ - 🚀 Built-in memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
24
26
 
25
27
  ## 项目仓库 (Repository)
26
28
  - GitHub: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all`