koishi-plugin-video-parser-all 1.2.8 → 1.2.9

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
@@ -9,16 +9,31 @@ export declare const Config: Schema<{
9
9
  unifiedMessageFormat?: string | null | undefined;
10
10
  } & {
11
11
  showImageText?: boolean | null | undefined;
12
+ showCoverImage?: boolean | null | undefined;
12
13
  showVideoFile?: boolean | null | undefined;
13
14
  maxDescLength?: number | null | undefined;
14
15
  videoDownloadTimeout?: number | null | undefined;
15
16
  tempDir?: string | null | undefined;
16
17
  maxVideoSize?: number | null | undefined;
17
18
  forceDownloadVideo?: boolean | null | undefined;
19
+ maxConcurrent?: number | null | undefined;
18
20
  } & {
19
21
  timeout?: number | null | undefined;
20
22
  videoSendTimeout?: number | null | undefined;
21
23
  userAgent?: string | null | undefined;
24
+ proxy?: ({
25
+ protocol?: string | null | undefined;
26
+ host?: string | null | undefined;
27
+ port?: number | null | undefined;
28
+ auth?: ({
29
+ username?: string | null | undefined;
30
+ password?: string | null | undefined;
31
+ } & import("cosmokit").Dict) | null | undefined;
32
+ } & import("cosmokit").Dict) | null | undefined;
33
+ customHeaders?: ({
34
+ name?: string | null | undefined;
35
+ value?: string | null | undefined;
36
+ } & import("cosmokit").Dict)[] | null | undefined;
22
37
  } & {
23
38
  ignoreSendError?: boolean | null | undefined;
24
39
  retryTimes?: number | null | undefined;
@@ -27,6 +42,7 @@ export declare const Config: Schema<{
27
42
  enableForward?: boolean | null | undefined;
28
43
  } & {
29
44
  deduplicationInterval?: number | null | undefined;
45
+ cacheTTL?: number | null | undefined;
30
46
  } & {
31
47
  primaryApiUrl?: string | null | undefined;
32
48
  backupApiUrl?: string | null | undefined;
@@ -57,7 +73,9 @@ export declare const Config: Schema<{
57
73
  apiKey?: string | null | undefined;
58
74
  authHeaderType?: "Bearer" | "X-API-Key" | "Custom" | null | undefined;
59
75
  customHeaderName?: string | null | undefined;
76
+ fieldMapping?: string | null | undefined;
60
77
  } & import("cosmokit").Dict)[] | null | undefined;
78
+ globalFieldMapping?: string | null | undefined;
61
79
  } & {
62
80
  waitingTipText?: string | null | undefined;
63
81
  unsupportedPlatformText?: string | null | undefined;
@@ -73,16 +91,34 @@ export declare const Config: Schema<{
73
91
  unifiedMessageFormat: string;
74
92
  } & {
75
93
  showImageText: boolean;
94
+ showCoverImage: boolean;
76
95
  showVideoFile: boolean;
77
96
  maxDescLength: number;
78
97
  videoDownloadTimeout: number;
79
98
  tempDir: string;
80
99
  maxVideoSize: number;
81
100
  forceDownloadVideo: boolean;
101
+ maxConcurrent: number;
82
102
  } & {
83
103
  timeout: number;
84
104
  videoSendTimeout: number;
85
105
  userAgent: string;
106
+ proxy: Schemastery.ObjectT<{
107
+ protocol: Schema<string, string>;
108
+ host: Schema<string, string>;
109
+ port: Schema<number, number>;
110
+ auth: Schema<Schemastery.ObjectS<{
111
+ username: Schema<string, string>;
112
+ password: Schema<string, string>;
113
+ }>, Schemastery.ObjectT<{
114
+ username: Schema<string, string>;
115
+ password: Schema<string, string>;
116
+ }>>;
117
+ }>;
118
+ customHeaders: Schemastery.ObjectT<{
119
+ name: Schema<string, string>;
120
+ value: Schema<string, string>;
121
+ }>[];
86
122
  } & {
87
123
  ignoreSendError: boolean;
88
124
  retryTimes: number;
@@ -91,6 +127,7 @@ export declare const Config: Schema<{
91
127
  enableForward: boolean;
92
128
  } & {
93
129
  deduplicationInterval: number;
130
+ cacheTTL: number;
94
131
  } & {
95
132
  primaryApiUrl: string;
96
133
  backupApiUrl: string;
@@ -121,7 +158,9 @@ export declare const Config: Schema<{
121
158
  apiKey: Schema<string, string>;
122
159
  authHeaderType: Schema<"Bearer" | "X-API-Key" | "Custom", "Bearer" | "X-API-Key" | "Custom">;
123
160
  customHeaderName: Schema<string, string>;
161
+ fieldMapping: Schema<string, string>;
124
162
  }>[];
163
+ globalFieldMapping: string;
125
164
  } & {
126
165
  waitingTipText: string;
127
166
  unsupportedPlatformText: string;
package/lib/index.js CHANGED
@@ -42,6 +42,31 @@ class SimpleLRUCache {
42
42
  this.map.clear();
43
43
  }
44
44
  }
45
+ class ConcurrencyLimiter {
46
+ constructor(max) {
47
+ this.max = max;
48
+ this.running = 0;
49
+ this.queue = [];
50
+ }
51
+ async acquire() {
52
+ if (this.running < this.max) {
53
+ this.running++;
54
+ return;
55
+ }
56
+ return new Promise(resolve => {
57
+ this.queue.push(() => {
58
+ this.running++;
59
+ resolve();
60
+ });
61
+ });
62
+ }
63
+ release() {
64
+ this.running--;
65
+ const next = this.queue.shift();
66
+ if (next)
67
+ next();
68
+ }
69
+ }
45
70
  exports.name = 'video-parser-all';
46
71
  exports.Config = koishi_1.Schema.intersect([
47
72
  koishi_1.Schema.object({
@@ -55,17 +80,32 @@ exports.Config = koishi_1.Schema.intersect([
55
80
  }).description('消息格式设置'),
56
81
  koishi_1.Schema.object({
57
82
  showImageText: koishi_1.Schema.boolean().default(true).description('是否发送解析后的文字内容'),
83
+ showCoverImage: koishi_1.Schema.boolean().default(true).description('是否发送封面图片'),
58
84
  showVideoFile: koishi_1.Schema.boolean().default(true).description('是否发送视频文件(关闭则只发送视频链接)'),
59
85
  maxDescLength: koishi_1.Schema.number().min(0).step(1).default(200).description('简介内容最大长度(字符),超出自动截断'),
60
86
  videoDownloadTimeout: koishi_1.Schema.number().min(0).step(1).default(120000).description('视频下载超时(毫秒)'),
61
87
  tempDir: koishi_1.Schema.string().default('./temp_videos').description('临时视频存储目录'),
62
88
  maxVideoSize: koishi_1.Schema.number().min(0).step(1).default(0).description('最大下载视频大小(MB),0 为不限制大小'),
63
89
  forceDownloadVideo: koishi_1.Schema.boolean().default(false).description('强制下载视频后发送'),
90
+ maxConcurrent: koishi_1.Schema.number().min(1).step(1).default(3).description('批量解析时最大并发数'),
64
91
  }).description('内容显示设置'),
65
92
  koishi_1.Schema.object({
66
93
  timeout: koishi_1.Schema.number().min(0).step(1).default(180000).description('API 请求超时(毫秒)'),
67
94
  videoSendTimeout: koishi_1.Schema.number().min(0).step(1).default(60000).description('视频消息发送超时(毫秒,0 为不限制)'),
68
95
  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'),
96
+ proxy: koishi_1.Schema.object({
97
+ protocol: koishi_1.Schema.string().default('http').description('代理协议'),
98
+ host: koishi_1.Schema.string().default('127.0.0.1').description('代理地址'),
99
+ port: koishi_1.Schema.number().default(7890).description('代理端口'),
100
+ auth: koishi_1.Schema.object({
101
+ username: koishi_1.Schema.string().default('').description('代理用户名'),
102
+ password: koishi_1.Schema.string().default('').description('代理密码'),
103
+ }).description('代理认证'),
104
+ }).description('HTTP 代理设置'),
105
+ customHeaders: koishi_1.Schema.array(koishi_1.Schema.object({
106
+ name: koishi_1.Schema.string().required().description('请求头名称'),
107
+ value: koishi_1.Schema.string().required().description('请求头值'),
108
+ })).default([]).description('自定义请求头,会附加到所有 API 请求中'),
69
109
  }).description('网络与 API 设置'),
70
110
  koishi_1.Schema.object({
71
111
  ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败,避免插件崩溃'),
@@ -77,7 +117,8 @@ exports.Config = koishi_1.Schema.intersect([
77
117
  }).description('发送方式设置'),
78
118
  koishi_1.Schema.object({
79
119
  deduplicationInterval: koishi_1.Schema.number().min(0).step(1).default(180).description('禁止重复解析时间间隔(秒),0 为不限制'),
80
- }).description('去重设置'),
120
+ cacheTTL: koishi_1.Schema.number().min(0).step(1).default(600).description('解析结果缓存时间(秒),0 为不缓存'),
121
+ }).description('缓存与去重设置'),
81
122
  koishi_1.Schema.object({
82
123
  primaryApiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos').description('主 API 地址'),
83
124
  backupApiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/svparse').description('备用主 API 地址'),
@@ -132,7 +173,9 @@ exports.Config = koishi_1.Schema.intersect([
132
173
  koishi_1.Schema.const('Custom').description('自定义 Header 名称'),
133
174
  ]).default('Bearer').description('认证头类型'),
134
175
  customHeaderName: koishi_1.Schema.string().description('自定义 Header 名称(仅当选择 Custom 时有效)').default('X-API-Key'),
176
+ fieldMapping: koishi_1.Schema.string().role('textarea').default('{}').description('字段映射 JSON,例如 {"title":"data.info.name"},支持点号路径'),
135
177
  })).default([]).description('自定义平台专属 API 地址,留空则使用内置默认专属 API'),
178
+ globalFieldMapping: koishi_1.Schema.string().role('textarea').default('{}').description('全局字段映射 JSON,优先级低于专属 API 映射'),
136
179
  }).description('API 选择设置'),
137
180
  koishi_1.Schema.object({
138
181
  waitingTipText: koishi_1.Schema.string().default('正在解析视频,请稍候...').description('解析等待提示文字'),
@@ -149,7 +192,6 @@ function debugLog(level, ...args) {
149
192
  return;
150
193
  logger.info(`[${new Date().toISOString()}] [${level}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`);
151
194
  }
152
- const urlCache = new SimpleLRUCache(500, 10 * 60 * 1000);
153
195
  const LINK_RULES = [
154
196
  { pattern: /https?:\/\/(?:www\.)?bilibili\.com\/video\/([ab]v[0-9a-zA-Z_-]+)/gi, type: 'bilibili' },
155
197
  { pattern: /https?:\/\/b23\.tv\/[0-9a-zA-Z_-]{5,}/gi, type: 'bilibili' },
@@ -288,22 +330,45 @@ function pickBestQuality(videoBackup) {
288
330
  bit_rate: Number(v.bit_rate || 0)
289
331
  })).sort((a, b) => b.bit_rate - a.bit_rate);
290
332
  }
291
- function parseApiResponse(raw, maxDescLen) {
333
+ function getNestedValue(obj, path) {
334
+ if (!path)
335
+ return obj;
336
+ const keys = path.split('.');
337
+ let current = obj;
338
+ for (const key of keys) {
339
+ if (current === null || current === undefined)
340
+ return undefined;
341
+ current = current[key];
342
+ }
343
+ return current;
344
+ }
345
+ function parseApiResponse(raw, maxDescLen, fieldMapping) {
292
346
  debugLog('DEBUG', 'API raw response', raw);
293
347
  const data = raw?.data || {};
294
348
  const extra = data.extra || {};
295
- let type = data.type || '';
296
- if (!type) {
297
- if (data.images?.length > 0 && !data.url)
298
- type = 'image';
299
- else if (data.live_photo?.length > 0)
300
- type = 'live_photo';
301
- else if (raw.msg === 'live' || data.live)
302
- type = 'live';
303
- else
304
- type = 'video';
305
- }
306
- const authorObj = data.author;
349
+ const mapField = (name, fallback) => {
350
+ if (fieldMapping && fieldMapping[name]) {
351
+ const value = getNestedValue(raw, fieldMapping[name]);
352
+ if (value !== undefined)
353
+ return value;
354
+ }
355
+ return fallback();
356
+ };
357
+ let type = mapField('type', () => {
358
+ let t = data.type || '';
359
+ if (!t) {
360
+ if (data.images?.length > 0 && !data.url)
361
+ t = 'image';
362
+ else if (data.live_photo?.length > 0)
363
+ t = 'live_photo';
364
+ else if (raw.msg === 'live' || data.live)
365
+ t = 'live';
366
+ else
367
+ t = 'video';
368
+ }
369
+ return t;
370
+ });
371
+ const authorObj = mapField('author', () => data.author);
307
372
  let author = '', uid = '', avatar = '';
308
373
  if (authorObj && typeof authorObj === 'object') {
309
374
  author = authorObj.name || authorObj.author || '';
@@ -311,29 +376,34 @@ function parseApiResponse(raw, maxDescLen) {
311
376
  avatar = authorObj.avatar || data.avatar || '';
312
377
  }
313
378
  else {
314
- author = data.author || data.auther || '';
315
- uid = String(data.uid || '');
316
- avatar = data.avatar || '';
379
+ author = mapField('author', () => data.author || data.auther || '');
380
+ uid = String(mapField('uid', () => data.uid || ''));
381
+ avatar = mapField('avatar', () => data.avatar || '');
317
382
  }
318
- const title = data.title || '';
319
- const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
320
- const cover = data.cover || '';
383
+ const title = mapField('title', () => data.title || '');
384
+ const desc = mapField('desc', () => data.desc || data.description || '').slice(0, maxDescLen).trim();
385
+ const coverRaw = mapField('cover', () => data.cover || '');
386
+ const cover = coverRaw ? (String(coverRaw).startsWith('http') ? String(coverRaw) : 'https:' + coverRaw) : '';
321
387
  let video = '';
322
388
  let videos = [];
323
- if (Array.isArray(data.video_backup) && data.video_backup.length) {
324
- const bestQ = pickBestQuality(data.video_backup);
389
+ const videoBackup = mapField('video_backup', () => data.video_backup);
390
+ if (Array.isArray(videoBackup) && videoBackup.length) {
391
+ const bestQ = pickBestQuality(videoBackup);
325
392
  videos = bestQ;
326
393
  video = bestQ[0]?.url || '';
327
394
  }
328
- if (!video && Array.isArray(data.videos) && data.videos.length) {
329
- const validVideos = data.videos.filter((v) => v && v.url);
330
- if (validVideos.length) {
331
- video = validVideos[0].url;
332
- videos = validVideos.map((v) => ({ quality: v.accept?.[0] || 'unknown', url: v.url }));
395
+ if (!video) {
396
+ const rawVideos = mapField('videos', () => data.videos);
397
+ if (Array.isArray(rawVideos) && rawVideos.length) {
398
+ const validVideos = rawVideos.filter((v) => v && v.url);
399
+ if (validVideos.length) {
400
+ video = validVideos[0].url;
401
+ videos = validVideos.map((v) => ({ quality: v.accept?.[0] || 'unknown', url: v.url }));
402
+ }
333
403
  }
334
404
  }
335
- if (!video && data.url)
336
- video = data.url;
405
+ if (!video)
406
+ video = mapField('video', () => data.url || '');
337
407
  if (video && !video.startsWith('http'))
338
408
  video = 'https:' + video;
339
409
  const images = Array.isArray(data.images) ? data.images.filter((img) => img && typeof img === 'string').map((img) => img.startsWith('http') ? img : 'https:' + img) : [];
@@ -342,34 +412,36 @@ function parseApiResponse(raw, maxDescLen) {
342
412
  video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
343
413
  })) : [];
344
414
  const music = {
345
- title: data.music?.title || data.music?.name || '',
346
- author: data.music?.author || data.music?.artist || '',
347
- cover: data.music?.cover || '',
348
- url: data.music?.url || ''
415
+ title: mapField('music_title', () => data.music?.title || data.music?.name || ''),
416
+ author: mapField('music_author', () => data.music?.author || data.music?.artist || ''),
417
+ cover: mapField('music_cover', () => data.music?.cover || ''),
418
+ url: mapField('music_url', () => data.music?.url || ''),
349
419
  };
350
- const stats = extra.statistics || {};
351
- const like = Number(data.like ?? stats.digg_count ?? 0);
352
- const comment = Number(stats.comment_count ?? 0);
353
- const collect = Number(stats.collect_count ?? 0);
354
- const share = Number(stats.share_count ?? 0);
355
- const play = Number(stats.play_count ?? 0);
420
+ const stats = { ...(data.statistics || {}), ...(extra.statistics || {}) };
421
+ const like = Number(mapField('like', () => data.like ?? stats.digg_count ?? stats.like_count ?? stats.likes ?? 0));
422
+ const comment = Number(mapField('comment', () => data.comment ?? stats.comment_count ?? stats.comments ?? stats.comment ?? 0));
423
+ const collect = Number(mapField('collect', () => data.collect ?? stats.collect_count ?? stats.favorite_count ?? stats.favorites ?? 0));
424
+ const share = Number(mapField('share', () => data.share ?? stats.share_count ?? stats.forward_count ?? stats.shares ?? 0));
425
+ const play = Number(mapField('play', () => data.play ?? stats.play_count ?? stats.view_count ?? stats.plays ?? 0));
356
426
  let duration = 0;
357
- if (data.duration) {
358
- duration = typeof data.duration === 'string' ? parseInt(data.duration, 10) : data.duration;
427
+ const durRaw = mapField('duration', () => data.duration);
428
+ if (durRaw) {
429
+ duration = typeof durRaw === 'string' ? parseInt(durRaw, 10) : Number(durRaw);
359
430
  if (duration > 1000000)
360
431
  duration = Math.floor(duration / 1000);
361
432
  }
362
433
  else if (extra.duration_ms) {
363
- duration = Math.floor(extra.duration_ms / 1000);
434
+ duration = Math.floor(Number(extra.duration_ms) / 1000);
364
435
  }
365
436
  let publishTime = 0;
366
- if (data.time) {
367
- publishTime = typeof data.time === 'number' ? data.time : parseInt(data.time, 10);
437
+ const timeRaw = mapField('publishTime', () => data.time);
438
+ if (timeRaw) {
439
+ publishTime = typeof timeRaw === 'number' ? timeRaw : parseInt(timeRaw, 10);
368
440
  if (publishTime < 1000000000000)
369
441
  publishTime *= 1000;
370
442
  }
371
443
  else if (extra.create_time) {
372
- publishTime = extra.create_time * 1000;
444
+ publishTime = Number(extra.create_time) * 1000;
373
445
  }
374
446
  return { type, title, desc, author, uid, avatar, cover, video, videos, images, live_photo, music, like, comment, collect, share, play, duration, publishTime };
375
447
  }
@@ -395,24 +467,13 @@ function generateFormattedText(p, format) {
395
467
  const lines = format.split('\n');
396
468
  const resultLines = [];
397
469
  for (const line of lines) {
398
- const varMatches = line.match(formatVarRegex);
399
- if (varMatches) {
400
- let allEmpty = true;
401
- for (const match of varMatches) {
402
- const varName = match.slice(2, -1);
403
- const val = vars[varName];
404
- if (val && val !== '0') {
405
- allEmpty = false;
406
- break;
407
- }
408
- }
409
- if (allEmpty)
410
- continue;
411
- }
412
470
  let newLine = line;
413
471
  for (const [key, val] of Object.entries(vars)) {
414
472
  newLine = newLine.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), val);
415
473
  }
474
+ const stripped = newLine.replace(/[\s::,,。.、;;!!??【】\[\]「」『』()()《》""''""·—\-_/\\|@#$%^&*+=~`]/g, '').trim();
475
+ if (stripped.length === 0)
476
+ continue;
416
477
  resultLines.push(newLine);
417
478
  }
418
479
  return resultLines.join('\n').trim();
@@ -435,10 +496,25 @@ function getErrorMessage(error) {
435
496
  return String(error.message);
436
497
  return String(error);
437
498
  }
499
+ function parseFieldMapping(mappingStr) {
500
+ if (!mappingStr || mappingStr.trim() === '{}' || mappingStr.trim() === '')
501
+ return undefined;
502
+ try {
503
+ const obj = JSON.parse(mappingStr);
504
+ if (typeof obj === 'object' && !Array.isArray(obj))
505
+ return obj;
506
+ return undefined;
507
+ }
508
+ catch {
509
+ return undefined;
510
+ }
511
+ }
438
512
  function apply(ctx, config) {
439
513
  debugEnabled = config.debug || false;
440
514
  debugLog('INFO', 'plugin start');
441
515
  const dedupCache = new SimpleLRUCache(1000, config.deduplicationInterval * 1000);
516
+ const cacheTTL = (config.cacheTTL || 600) * 1000;
517
+ const urlCacheLocal = new SimpleLRUCache(500, cacheTTL);
442
518
  const texts = {
443
519
  waitingTipText: config.waitingTipText || '正在解析视频,请稍候...',
444
520
  unsupportedPlatformText: config.unsupportedPlatformText || '不支持该平台链接',
@@ -446,14 +522,27 @@ function apply(ctx, config) {
446
522
  parseErrorPrefix: config.parseErrorPrefix || '❌ 解析失败:',
447
523
  parseErrorItemFormat: config.parseErrorItemFormat || '【${url}】: ${msg}',
448
524
  };
449
- const http = axios_1.default.create({
525
+ const proxyConfig = config.proxy || {};
526
+ const axiosConfig = {
450
527
  timeout: config.timeout,
451
528
  headers: {
452
529
  'User-Agent': config.userAgent,
453
530
  'Referer': 'https://www.baidu.com/',
454
531
  'Content-Type': 'application/x-www-form-urlencoded'
455
532
  }
456
- });
533
+ };
534
+ if (proxyConfig.host) {
535
+ axiosConfig.proxy = {
536
+ protocol: proxyConfig.protocol || 'http',
537
+ host: proxyConfig.host,
538
+ port: proxyConfig.port || 7890,
539
+ auth: proxyConfig.auth?.username ? {
540
+ username: proxyConfig.auth.username,
541
+ password: proxyConfig.auth.password || ''
542
+ } : undefined
543
+ };
544
+ }
545
+ const http = axios_1.default.create(axiosConfig);
457
546
  const defaultDedicatedApis = {
458
547
  bilibili: 'https://api.bugpk.com/api/bilibili',
459
548
  douyin: 'https://api.bugpk.com/api/douyin',
@@ -476,14 +565,19 @@ function apply(ctx, config) {
476
565
  let apiKey = '';
477
566
  let authHeaderType = 'Bearer';
478
567
  let customHeaderName = 'X-API-Key';
568
+ let fieldMapping = undefined;
479
569
  if (custom && custom.apiUrl) {
480
570
  apiUrl = custom.apiUrl;
481
571
  apiKey = custom.apiKey || '';
482
572
  authHeaderType = custom.authHeaderType || 'Bearer';
483
573
  customHeaderName = custom.customHeaderName || 'X-API-Key';
574
+ fieldMapping = parseFieldMapping(custom.fieldMapping);
484
575
  }
485
576
  const dedicatedFirst = config.platformDedicatedFirst?.[type] ?? false;
486
- return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName };
577
+ if (!fieldMapping) {
578
+ fieldMapping = parseFieldMapping(config.globalFieldMapping);
579
+ }
580
+ return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName, fieldMapping };
487
581
  }
488
582
  function buildAuthHeaders(apiKey, authHeaderType, customHeaderName) {
489
583
  if (!apiKey)
@@ -552,9 +646,9 @@ function apply(ctx, config) {
552
646
  throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
553
647
  }
554
648
  }
555
- async function fetchApi(url, type) {
649
+ async function fetchApi(url, type, fieldMapping) {
556
650
  const cacheKey = url;
557
- const cached = urlCache.get(cacheKey);
651
+ const cached = urlCacheLocal.get(cacheKey);
558
652
  if (cached && cached.expire > Date.now())
559
653
  return cached.data;
560
654
  const { apiUrl: dedicatedUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName } = getPlatformConfig(type);
@@ -563,18 +657,19 @@ function apply(ctx, config) {
563
657
  const backupAllowed = backupSupportedPlatforms.has(type);
564
658
  const apiList = [];
565
659
  if (dedicatedFirst && dedicatedUrl) {
566
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
567
- apiList.push({ url: primaryApi, label: '默认主API' });
660
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
661
+ apiList.push({ url: primaryApi, label: '默认主API', fieldMapping });
568
662
  if (backupAllowed)
569
- apiList.push({ url: backupApi, label: '备用主API' });
663
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
570
664
  }
571
665
  else {
572
- apiList.push({ url: primaryApi, label: '默认主API' });
666
+ apiList.push({ url: primaryApi, label: '默认主API', fieldMapping });
573
667
  if (backupAllowed)
574
- apiList.push({ url: backupApi, label: '备用主API' });
668
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
575
669
  if (dedicatedUrl)
576
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
670
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
577
671
  }
672
+ const customHeaders = config.customHeaders || [];
578
673
  let lastError = null;
579
674
  for (const api of apiList) {
580
675
  for (let attempt = 0; attempt <= config.retryTimes; attempt++) {
@@ -584,14 +679,18 @@ function apply(ctx, config) {
584
679
  'Referer': 'https://www.baidu.com/',
585
680
  'Content-Type': 'application/x-www-form-urlencoded'
586
681
  };
682
+ for (const h of customHeaders) {
683
+ if (h.name && h.value)
684
+ headers[h.name] = h.value;
685
+ }
587
686
  if (api.apiKey) {
588
687
  const authHeaders = buildAuthHeaders(api.apiKey, api.authHeaderType || 'Bearer', api.customHeaderName || 'X-API-Key');
589
688
  Object.assign(headers, authHeaders);
590
689
  }
591
690
  const res = await http.get(api.url, { params: { url }, timeout: config.timeout, headers });
592
691
  if (res.data && (res.data.code === 200 || res.data.code === 0)) {
593
- const parsed = parseApiResponse(res.data, config.maxDescLength);
594
- urlCache.set(cacheKey, { data: parsed, expire: Date.now() + 10 * 60 * 1000 });
692
+ const parsed = parseApiResponse(res.data, config.maxDescLength, api.fieldMapping);
693
+ urlCacheLocal.set(cacheKey, { data: parsed, expire: Date.now() + cacheTTL });
595
694
  return parsed;
596
695
  }
597
696
  throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
@@ -607,12 +706,12 @@ function apply(ctx, config) {
607
706
  }
608
707
  throw lastError || new Error('所有API请求全部失败');
609
708
  }
610
- async function parseUrl(url, type) {
709
+ async function parseUrl(url, type, fieldMapping) {
611
710
  const realUrl = await resolveShortUrl(url);
612
711
  const candidates = [...new Set([realUrl, url])];
613
712
  for (const candidate of candidates) {
614
713
  try {
615
- const info = await fetchApi(candidate, type);
714
+ const info = await fetchApi(candidate, type, fieldMapping);
616
715
  if (info.video || info.images.length > 0)
617
716
  return { success: true, data: info };
618
717
  debugLog('WARN', `解析成功但无内容: ${candidate}`);
@@ -623,8 +722,8 @@ function apply(ctx, config) {
623
722
  }
624
723
  return { success: false, msg: texts.unsupportedPlatformText };
625
724
  }
626
- async function processSingleUrl(url, type) {
627
- const result = await parseUrl(url, type);
725
+ async function processSingleUrl(url, type, fieldMapping) {
726
+ const result = await parseUrl(url, type, fieldMapping);
628
727
  if (!result.success)
629
728
  return { success: false, msg: result.msg, url };
630
729
  const text = generateFormattedText(result.data, config.unifiedMessageFormat);
@@ -696,31 +795,37 @@ function apply(ctx, config) {
696
795
  debugLog('INFO', `开始解析 ${matches.length} 个链接`);
697
796
  const items = [];
698
797
  const errors = [];
699
- for (let i = 0; i < matches.length; i++) {
700
- const match = matches[i];
701
- if (config.deduplicationInterval > 0) {
702
- const lastTime = dedupCache.get(match.url);
703
- if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
704
- debugLog('INFO', `跳过重复链接: ${match.url}`);
705
- const shortUrl = match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url;
706
- await sendWithTimeout(session, `链接 ${shortUrl} 在最近 ${config.deduplicationInterval} 秒内已解析过,已跳过。`).catch(() => { });
707
- continue;
798
+ const limiter = new ConcurrencyLimiter(config.maxConcurrent || 3);
799
+ const promises = matches.map(async (match) => {
800
+ await limiter.acquire();
801
+ try {
802
+ if (config.deduplicationInterval > 0) {
803
+ const lastTime = dedupCache.get(match.url);
804
+ if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
805
+ debugLog('INFO', `跳过重复链接: ${match.url}`);
806
+ const shortUrl = match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url;
807
+ await sendWithTimeout(session, `链接 ${shortUrl} 在最近 ${config.deduplicationInterval} 秒内已解析过,已跳过。`).catch(() => { });
808
+ return;
809
+ }
810
+ }
811
+ debugLog('INFO', `解析链接: ${match.url} (${match.type})`);
812
+ const fieldMapping = getPlatformConfig(match.type).fieldMapping;
813
+ const result = await processSingleUrl(match.url, match.type, fieldMapping);
814
+ if (result.success) {
815
+ items.push(result.data);
816
+ if (config.deduplicationInterval > 0)
817
+ dedupCache.set(match.url, Date.now());
818
+ }
819
+ else {
820
+ const item = texts.parseErrorItemFormat.replace(/\$\{url\}/g, match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url).replace(/\$\{msg\}/g, result.msg);
821
+ errors.push(item);
708
822
  }
709
823
  }
710
- debugLog('INFO', `解析第 ${i + 1}/${matches.length} 个链接: ${match.url} (${match.type})`);
711
- const result = await processSingleUrl(match.url, match.type);
712
- if (result.success) {
713
- items.push(result.data);
714
- if (config.deduplicationInterval > 0)
715
- dedupCache.set(match.url, Date.now());
716
- }
717
- else {
718
- const item = texts.parseErrorItemFormat.replace(/\$\{url\}/g, match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url).replace(/\$\{msg\}/g, result.msg);
719
- errors.push(item);
824
+ finally {
825
+ limiter.release();
720
826
  }
721
- if (i < matches.length - 1)
722
- await delay(500);
723
- }
827
+ });
828
+ await Promise.all(promises);
724
829
  if (errors.length)
725
830
  await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
726
831
  if (!items.length)
@@ -734,7 +839,7 @@ function apply(ctx, config) {
734
839
  const text = item.text;
735
840
  if (text && config.showImageText)
736
841
  forwardMessages.push(buildForwardNode(session, text, botName));
737
- if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length)))
842
+ if (config.showCoverImage && p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length)))
738
843
  forwardMessages.push(buildForwardNode(session, koishi_1.h.image(p.cover), botName));
739
844
  if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
740
845
  const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
@@ -765,7 +870,7 @@ function apply(ctx, config) {
765
870
  await sendWithTimeout(session, text);
766
871
  await delay(300);
767
872
  }
768
- if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
873
+ if (config.showCoverImage && p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
769
874
  await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
770
875
  await delay(300);
771
876
  }
@@ -853,7 +958,7 @@ function apply(ctx, config) {
853
958
  }, 3600000);
854
959
  ctx.on('dispose', () => {
855
960
  clearInterval(tempCleanupInterval);
856
- urlCache.clear();
961
+ urlCacheLocal.clear();
857
962
  dedupCache.clear();
858
963
  debugLog('INFO', '插件已卸载');
859
964
  });
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.2.8",
4
+ "version": "1.2.9",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -6,7 +6,7 @@
6
6
  这是一个为 Koishi 机器人框架开发的**全平台视频/图集解析插件**,使用统一API接口,支持自动识别并解析抖音、快手、B站、小红书、微博、YouTube、TikTok、剪映、AcFun、知乎、虎牙、绿洲、视频号等20+主流平台的短视频/图集/实况链接。
7
7
 
8
8
  ### English
9
- 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, Oasis, WeChat Channels and more.
9
+ 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, Oasis, WeChat Channels and more.
10
10
 
11
11
  ## 项目仓库 (Repository)
12
12
  - GitHub: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all`
@@ -20,7 +20,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
20
20
 
21
21
  ## 配置项说明 (Configuration)
22
22
 
23
- ### 基础设置
23
+ ### 基础设置 (Basic Settings)
24
24
  | 配置项 | 类型 | 默认值 | 说明 |
25
25
  |--------|------|--------|------|
26
26
  | `enable` | boolean | true | 是否启用视频解析插件 |
@@ -28,55 +28,69 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
28
28
  | `showWaitingTip` | boolean | true | 解析时是否显示等待提示 |
29
29
  | `debug` | boolean | false | 是否开启 Debug 模式,在控制台输出详细日志 |
30
30
 
31
- ### 统一消息格式
31
+ ### 统一消息格式 (Unified Message Format)
32
32
  | 配置项 | 类型 | 默认值 | 说明 |
33
33
  |--------|------|--------|------|
34
- | `unifiedMessageFormat` | string | `标题:${标题}\n作者:${作者}\n简介:${简介}\n点赞:${点赞数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n图片数量:${图片数量}` | 自定义解析结果的输出格式,支持变量替换。某行所有变量为空(或为"0")时自动隐藏该行 |
35
-
36
- ### 内容显示设置
34
+ | `unifiedMessageFormat` | string | `标题:${标题}
35
+ 作者:${作者}
36
+ 简介:${简介}
37
+ 点赞:${点赞数}
38
+ 收藏:${收藏数}
39
+ 转发:${转发数}
40
+ 播放:${播放数}
41
+ 评论:${评论数}
42
+ 图片数量:${图片数量}` | 自定义解析结果的输出格式,支持变量替换。某行所有变量均为空(或为"0")时自动隐藏该行 |
43
+
44
+ ### 内容显示设置 (Content Display Settings)
37
45
  | 配置项 | 类型 | 默认值 | 说明 |
38
46
  |--------|------|--------|------|
39
47
  | `showImageText` | boolean | true | 是否发送解析后的文字内容 |
48
+ | `showCoverImage` | boolean | true | 是否发送封面图片(关闭后不再自动发送封面) |
40
49
  | `showVideoFile` | boolean | true | 是否发送视频文件(关闭则只发送视频链接) |
41
50
  | `maxDescLength` | number | 200 | 简介内容最大长度(字符),超出自动截断 |
42
51
  | `videoDownloadTimeout` | number | 120000 | 视频下载超时(毫秒) |
43
52
  | `tempDir` | string | `./temp_videos` | 临时视频存储目录 |
44
53
  | `maxVideoSize` | number | 0 | 最大下载视频大小(MB),0 为不限制大小 |
45
54
  | `forceDownloadVideo` | boolean | false | 强制下载视频后发送 |
55
+ | `maxConcurrent` | number | 3 | 批量解析时最大并发数,避免同时下载过多 |
46
56
 
47
- ### 网络与 API 设置
57
+ ### 网络与 API 设置 (Network & API Settings)
48
58
  | 配置项 | 类型 | 默认值 | 说明 |
49
59
  |--------|------|--------|------|
50
60
  | `timeout` | number | 180000 | API 请求超时时间(毫秒) |
51
61
  | `videoSendTimeout` | number | 60000 | 视频消息发送超时时间(毫秒,0 为不限制) |
52
62
  | `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 |
63
+ | `proxy` | object | `{ protocol: "http", host: "127.0.0.1", port: 7890, auth: { username: "", password: "" } }` | HTTP 代理设置,支持认证。不填 `host` 则不启用代理 |
64
+ | `customHeaders` | array | [] | 自定义请求头,会附加到所有 API 请求中。每项包含 `name`(头名称)和 `value`(头值) |
53
65
 
54
- ### API 选择与回退设置
66
+ ### API 选择与回退设置 (API Selection & Fallback)
55
67
  | 配置项 | 类型 | 默认值 | 说明 |
56
68
  |--------|------|--------|------|
57
69
  | `primaryApiUrl` | string | `https://api.bugpk.com/api/short_videos` | 主 API 地址,解析时优先使用 |
58
70
  | `backupApiUrl` | string | `https://api.bugpk.com/api/svparse` | 备用主 API 地址,仅支持抖音、小红书、Instagram、即梦平台解析 |
59
71
  | `platformDedicatedFirst` | object | 各平台均为 `false` | 各平台独立开关:是否优先使用平台专属 API。对象键为平台标识(英文),值为布尔值。支持的键:`bilibili`、`douyin`、`kuaishou`、`xiaohongshu`、`weibo`、`xigua`、`youtube`、`tiktok`、`acfun`、`zhihu`、`weishi`、`huya`、`haokan`、`meipai`、`twitter`、`instagram`、`doubao`、`oasis`、`wechat_channel` |
60
- | `customApis` | array | [] | 自定义平台专属 API 列表。每项包含:`platform`(平台类型)、`apiUrl`(API 地址)、`apiKey`(API Key,可选)、`authHeaderType`(认证头类型,可选:`Bearer` / `X-API-Key` / `Custom`)、`customHeaderName`(自定义 Header 名称,仅当 `authHeaderType` 为 `Custom` 时有效)。可覆盖内置默认专属 API |
72
+ | `customApis` | array | [] | 自定义平台专属 API 列表。每项包含:`platform`(平台类型)、`apiUrl`(API 地址)、`apiKey`(API Key,可选)、`authHeaderType`(认证头类型,可选:`Bearer` / `X-API-Key` / `Custom`)、`customHeaderName`(自定义 Header 名称,仅当 `authHeaderType` 为 `Custom` 时有效)、`fieldMapping`(字段映射 JSON 字符串,用于适配非标准 API 响应,支持点号路径) |
73
+ | `globalFieldMapping` | string | `{}` | 全局字段映射 JSON 字符串,优先级低于专属 API 映射。用于统一适配所有平台的 API 响应格式,例如 `{"title":"data.info.title"}` |
61
74
 
62
- ### 错误与重试设置
75
+ ### 错误与重试设置 (Error & Retry Settings)
63
76
  | 配置项 | 类型 | 默认值 | 说明 |
64
77
  |--------|------|--------|------|
65
78
  | `ignoreSendError` | boolean | true | 是否忽略消息发送失败,避免插件崩溃 |
66
79
  | `retryTimes` | number | 3 | API 请求及消息发送失败时的重试次数 |
67
80
  | `retryInterval` | number | 1000 | API 请求及消息发送重试的间隔时间(毫秒) |
68
81
 
69
- ### 发送方式设置
82
+ ### 发送方式设置 (Send Mode Settings)
70
83
  | 配置项 | 类型 | 默认值 | 说明 |
71
84
  |--------|------|--------|------|
72
85
  | `enableForward` | boolean | false | 是否启用合并转发(仅 OneBot 平台),启用后视频与图文将整合进同一条合并消息 |
73
86
 
74
- ### 去重设置
87
+ ### 缓存与去重设置 (Cache & Deduplication Settings)
75
88
  | 配置项 | 类型 | 默认值 | 说明 |
76
89
  |--------|------|--------|------|
77
90
  | `deduplicationInterval` | number | 180 | 禁止重复解析时间间隔(秒),0 为不限制。同一个链接在间隔内不会重复解析。 |
91
+ | `cacheTTL` | number | 600 | 解析结果缓存时间(秒),0 为不缓存。缓存可减少重复 API 请求。 |
78
92
 
79
- ### 界面文字设置
93
+ ### 界面文字设置 (UI Text Settings)
80
94
  | 配置项 | 类型 | 默认值 | 说明 |
81
95
  |--------|------|--------|------|
82
96
  | `waitingTipText` | string | 正在解析视频,请稍候... | 解析等待提示文字 |