koishi-plugin-video-parser-all 1.2.7 → 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' },
@@ -179,6 +221,7 @@ const LINK_RULES = [
179
221
  { pattern: /https?:\/\/(?:www\.)?doubao\.com\/video\/\d{10,}/gi, type: 'doubao' },
180
222
  { pattern: /https?:\/\/(?:www\.)?oasis\.weibo\.com\/v\/[0-9a-zA-Z_-]+/gi, type: 'oasis' },
181
223
  { pattern: /https?:\/\/channels\.weixin\.qq\.com\/[0-9a-zA-Z_-]+/gi, type: 'wechat_channel' },
224
+ { pattern: /https?:\/\/weixin\.qq\.com\/sph\/[0-9a-zA-Z_-]+/gi, type: 'wechat_channel' },
182
225
  ];
183
226
  function linkTypeParser(content) {
184
227
  content = content.replace(/\\\//g, '/');
@@ -287,22 +330,45 @@ function pickBestQuality(videoBackup) {
287
330
  bit_rate: Number(v.bit_rate || 0)
288
331
  })).sort((a, b) => b.bit_rate - a.bit_rate);
289
332
  }
290
- 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) {
291
346
  debugLog('DEBUG', 'API raw response', raw);
292
347
  const data = raw?.data || {};
293
348
  const extra = data.extra || {};
294
- let type = data.type || '';
295
- if (!type) {
296
- if (data.images?.length > 0 && !data.url)
297
- type = 'image';
298
- else if (data.live_photo?.length > 0)
299
- type = 'live_photo';
300
- else if (raw.msg === 'live' || data.live)
301
- type = 'live';
302
- else
303
- type = 'video';
304
- }
305
- 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);
306
372
  let author = '', uid = '', avatar = '';
307
373
  if (authorObj && typeof authorObj === 'object') {
308
374
  author = authorObj.name || authorObj.author || '';
@@ -310,29 +376,34 @@ function parseApiResponse(raw, maxDescLen) {
310
376
  avatar = authorObj.avatar || data.avatar || '';
311
377
  }
312
378
  else {
313
- author = data.author || data.auther || '';
314
- uid = String(data.uid || '');
315
- avatar = data.avatar || '';
379
+ author = mapField('author', () => data.author || data.auther || '');
380
+ uid = String(mapField('uid', () => data.uid || ''));
381
+ avatar = mapField('avatar', () => data.avatar || '');
316
382
  }
317
- const title = data.title || '';
318
- const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
319
- 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) : '';
320
387
  let video = '';
321
388
  let videos = [];
322
- if (Array.isArray(data.video_backup) && data.video_backup.length) {
323
- 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);
324
392
  videos = bestQ;
325
393
  video = bestQ[0]?.url || '';
326
394
  }
327
- if (!video && Array.isArray(data.videos) && data.videos.length) {
328
- const validVideos = data.videos.filter((v) => v && v.url);
329
- if (validVideos.length) {
330
- video = validVideos[0].url;
331
- 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
+ }
332
403
  }
333
404
  }
334
- if (!video && data.url)
335
- video = data.url;
405
+ if (!video)
406
+ video = mapField('video', () => data.url || '');
336
407
  if (video && !video.startsWith('http'))
337
408
  video = 'https:' + video;
338
409
  const images = Array.isArray(data.images) ? data.images.filter((img) => img && typeof img === 'string').map((img) => img.startsWith('http') ? img : 'https:' + img) : [];
@@ -341,34 +412,36 @@ function parseApiResponse(raw, maxDescLen) {
341
412
  video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
342
413
  })) : [];
343
414
  const music = {
344
- title: data.music?.title || data.music?.name || '',
345
- author: data.music?.author || data.music?.artist || '',
346
- cover: data.music?.cover || '',
347
- 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 || ''),
348
419
  };
349
- const stats = extra.statistics || {};
350
- const like = Number(data.like ?? stats.digg_count ?? 0);
351
- const comment = Number(stats.comment_count ?? 0);
352
- const collect = Number(stats.collect_count ?? 0);
353
- const share = Number(stats.share_count ?? 0);
354
- 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));
355
426
  let duration = 0;
356
- if (data.duration) {
357
- 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);
358
430
  if (duration > 1000000)
359
431
  duration = Math.floor(duration / 1000);
360
432
  }
361
433
  else if (extra.duration_ms) {
362
- duration = Math.floor(extra.duration_ms / 1000);
434
+ duration = Math.floor(Number(extra.duration_ms) / 1000);
363
435
  }
364
436
  let publishTime = 0;
365
- if (data.time) {
366
- 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);
367
440
  if (publishTime < 1000000000000)
368
441
  publishTime *= 1000;
369
442
  }
370
443
  else if (extra.create_time) {
371
- publishTime = extra.create_time * 1000;
444
+ publishTime = Number(extra.create_time) * 1000;
372
445
  }
373
446
  return { type, title, desc, author, uid, avatar, cover, video, videos, images, live_photo, music, like, comment, collect, share, play, duration, publishTime };
374
447
  }
@@ -394,24 +467,13 @@ function generateFormattedText(p, format) {
394
467
  const lines = format.split('\n');
395
468
  const resultLines = [];
396
469
  for (const line of lines) {
397
- const varMatches = line.match(formatVarRegex);
398
- if (varMatches) {
399
- let allEmpty = true;
400
- for (const match of varMatches) {
401
- const varName = match.slice(2, -1);
402
- const val = vars[varName];
403
- if (val && val !== '0') {
404
- allEmpty = false;
405
- break;
406
- }
407
- }
408
- if (allEmpty)
409
- continue;
410
- }
411
470
  let newLine = line;
412
471
  for (const [key, val] of Object.entries(vars)) {
413
472
  newLine = newLine.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), val);
414
473
  }
474
+ const stripped = newLine.replace(/[\s::,,。.、;;!!??【】\[\]「」『』()()《》""''""·—\-_/\\|@#$%^&*+=~`]/g, '').trim();
475
+ if (stripped.length === 0)
476
+ continue;
415
477
  resultLines.push(newLine);
416
478
  }
417
479
  return resultLines.join('\n').trim();
@@ -434,10 +496,25 @@ function getErrorMessage(error) {
434
496
  return String(error.message);
435
497
  return String(error);
436
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
+ }
437
512
  function apply(ctx, config) {
438
513
  debugEnabled = config.debug || false;
439
514
  debugLog('INFO', 'plugin start');
440
515
  const dedupCache = new SimpleLRUCache(1000, config.deduplicationInterval * 1000);
516
+ const cacheTTL = (config.cacheTTL || 600) * 1000;
517
+ const urlCacheLocal = new SimpleLRUCache(500, cacheTTL);
441
518
  const texts = {
442
519
  waitingTipText: config.waitingTipText || '正在解析视频,请稍候...',
443
520
  unsupportedPlatformText: config.unsupportedPlatformText || '不支持该平台链接',
@@ -445,14 +522,27 @@ function apply(ctx, config) {
445
522
  parseErrorPrefix: config.parseErrorPrefix || '❌ 解析失败:',
446
523
  parseErrorItemFormat: config.parseErrorItemFormat || '【${url}】: ${msg}',
447
524
  };
448
- const http = axios_1.default.create({
525
+ const proxyConfig = config.proxy || {};
526
+ const axiosConfig = {
449
527
  timeout: config.timeout,
450
528
  headers: {
451
529
  'User-Agent': config.userAgent,
452
530
  'Referer': 'https://www.baidu.com/',
453
531
  'Content-Type': 'application/x-www-form-urlencoded'
454
532
  }
455
- });
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);
456
546
  const defaultDedicatedApis = {
457
547
  bilibili: 'https://api.bugpk.com/api/bilibili',
458
548
  douyin: 'https://api.bugpk.com/api/douyin',
@@ -475,14 +565,19 @@ function apply(ctx, config) {
475
565
  let apiKey = '';
476
566
  let authHeaderType = 'Bearer';
477
567
  let customHeaderName = 'X-API-Key';
568
+ let fieldMapping = undefined;
478
569
  if (custom && custom.apiUrl) {
479
570
  apiUrl = custom.apiUrl;
480
571
  apiKey = custom.apiKey || '';
481
572
  authHeaderType = custom.authHeaderType || 'Bearer';
482
573
  customHeaderName = custom.customHeaderName || 'X-API-Key';
574
+ fieldMapping = parseFieldMapping(custom.fieldMapping);
483
575
  }
484
576
  const dedicatedFirst = config.platformDedicatedFirst?.[type] ?? false;
485
- return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName };
577
+ if (!fieldMapping) {
578
+ fieldMapping = parseFieldMapping(config.globalFieldMapping);
579
+ }
580
+ return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName, fieldMapping };
486
581
  }
487
582
  function buildAuthHeaders(apiKey, authHeaderType, customHeaderName) {
488
583
  if (!apiKey)
@@ -551,9 +646,9 @@ function apply(ctx, config) {
551
646
  throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
552
647
  }
553
648
  }
554
- async function fetchApi(url, type) {
649
+ async function fetchApi(url, type, fieldMapping) {
555
650
  const cacheKey = url;
556
- const cached = urlCache.get(cacheKey);
651
+ const cached = urlCacheLocal.get(cacheKey);
557
652
  if (cached && cached.expire > Date.now())
558
653
  return cached.data;
559
654
  const { apiUrl: dedicatedUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName } = getPlatformConfig(type);
@@ -562,18 +657,19 @@ function apply(ctx, config) {
562
657
  const backupAllowed = backupSupportedPlatforms.has(type);
563
658
  const apiList = [];
564
659
  if (dedicatedFirst && dedicatedUrl) {
565
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
566
- 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 });
567
662
  if (backupAllowed)
568
- apiList.push({ url: backupApi, label: '备用主API' });
663
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
569
664
  }
570
665
  else {
571
- apiList.push({ url: primaryApi, label: '默认主API' });
666
+ apiList.push({ url: primaryApi, label: '默认主API', fieldMapping });
572
667
  if (backupAllowed)
573
- apiList.push({ url: backupApi, label: '备用主API' });
668
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
574
669
  if (dedicatedUrl)
575
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
670
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
576
671
  }
672
+ const customHeaders = config.customHeaders || [];
577
673
  let lastError = null;
578
674
  for (const api of apiList) {
579
675
  for (let attempt = 0; attempt <= config.retryTimes; attempt++) {
@@ -583,14 +679,18 @@ function apply(ctx, config) {
583
679
  'Referer': 'https://www.baidu.com/',
584
680
  'Content-Type': 'application/x-www-form-urlencoded'
585
681
  };
682
+ for (const h of customHeaders) {
683
+ if (h.name && h.value)
684
+ headers[h.name] = h.value;
685
+ }
586
686
  if (api.apiKey) {
587
687
  const authHeaders = buildAuthHeaders(api.apiKey, api.authHeaderType || 'Bearer', api.customHeaderName || 'X-API-Key');
588
688
  Object.assign(headers, authHeaders);
589
689
  }
590
690
  const res = await http.get(api.url, { params: { url }, timeout: config.timeout, headers });
591
691
  if (res.data && (res.data.code === 200 || res.data.code === 0)) {
592
- const parsed = parseApiResponse(res.data, config.maxDescLength);
593
- 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 });
594
694
  return parsed;
595
695
  }
596
696
  throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
@@ -606,12 +706,12 @@ function apply(ctx, config) {
606
706
  }
607
707
  throw lastError || new Error('所有API请求全部失败');
608
708
  }
609
- async function parseUrl(url, type) {
709
+ async function parseUrl(url, type, fieldMapping) {
610
710
  const realUrl = await resolveShortUrl(url);
611
711
  const candidates = [...new Set([realUrl, url])];
612
712
  for (const candidate of candidates) {
613
713
  try {
614
- const info = await fetchApi(candidate, type);
714
+ const info = await fetchApi(candidate, type, fieldMapping);
615
715
  if (info.video || info.images.length > 0)
616
716
  return { success: true, data: info };
617
717
  debugLog('WARN', `解析成功但无内容: ${candidate}`);
@@ -622,8 +722,8 @@ function apply(ctx, config) {
622
722
  }
623
723
  return { success: false, msg: texts.unsupportedPlatformText };
624
724
  }
625
- async function processSingleUrl(url, type) {
626
- const result = await parseUrl(url, type);
725
+ async function processSingleUrl(url, type, fieldMapping) {
726
+ const result = await parseUrl(url, type, fieldMapping);
627
727
  if (!result.success)
628
728
  return { success: false, msg: result.msg, url };
629
729
  const text = generateFormattedText(result.data, config.unifiedMessageFormat);
@@ -695,31 +795,37 @@ function apply(ctx, config) {
695
795
  debugLog('INFO', `开始解析 ${matches.length} 个链接`);
696
796
  const items = [];
697
797
  const errors = [];
698
- for (let i = 0; i < matches.length; i++) {
699
- const match = matches[i];
700
- if (config.deduplicationInterval > 0) {
701
- const lastTime = dedupCache.get(match.url);
702
- if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
703
- debugLog('INFO', `跳过重复链接: ${match.url}`);
704
- const shortUrl = match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url;
705
- await sendWithTimeout(session, `链接 ${shortUrl} 在最近 ${config.deduplicationInterval} 秒内已解析过,已跳过。`).catch(() => { });
706
- 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);
707
822
  }
708
823
  }
709
- debugLog('INFO', `解析第 ${i + 1}/${matches.length} 个链接: ${match.url} (${match.type})`);
710
- const result = await processSingleUrl(match.url, match.type);
711
- if (result.success) {
712
- items.push(result.data);
713
- if (config.deduplicationInterval > 0)
714
- dedupCache.set(match.url, Date.now());
715
- }
716
- else {
717
- const item = texts.parseErrorItemFormat.replace(/\$\{url\}/g, match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url).replace(/\$\{msg\}/g, result.msg);
718
- errors.push(item);
824
+ finally {
825
+ limiter.release();
719
826
  }
720
- if (i < matches.length - 1)
721
- await delay(500);
722
- }
827
+ });
828
+ await Promise.all(promises);
723
829
  if (errors.length)
724
830
  await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
725
831
  if (!items.length)
@@ -733,7 +839,7 @@ function apply(ctx, config) {
733
839
  const text = item.text;
734
840
  if (text && config.showImageText)
735
841
  forwardMessages.push(buildForwardNode(session, text, botName));
736
- 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)))
737
843
  forwardMessages.push(buildForwardNode(session, koishi_1.h.image(p.cover), botName));
738
844
  if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
739
845
  const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
@@ -764,7 +870,7 @@ function apply(ctx, config) {
764
870
  await sendWithTimeout(session, text);
765
871
  await delay(300);
766
872
  }
767
- 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))) {
768
874
  await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
769
875
  await delay(300);
770
876
  }
@@ -852,7 +958,7 @@ function apply(ctx, config) {
852
958
  }, 3600000);
853
959
  ctx.on('dispose', () => {
854
960
  clearInterval(tempCleanupInterval);
855
- urlCache.clear();
961
+ urlCacheLocal.clear();
856
962
  dedupCache.clear();
857
963
  debugLog('INFO', '插件已卸载');
858
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.7",
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 | 正在解析视频,请稍候... | 解析等待提示文字 |
@@ -134,7 +148,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
134
148
  | 皮皮虾 | pipixia, h5.pipix.com | 短视频 |
135
149
  | 最右 | zuiyou, xiaochuankeji.cn | 短视频 |
136
150
  | 绿洲 (Oasis) | oasis.weibo.com | 视频、图文 |
137
- | 视频号 (WeChat Channels) | channels.weixin.qq.com | 短视频 |
151
+ | 视频号 (WeChat Channels) | channels.weixin.qq.com, weixin.qq.com/sph/ | 短视频 |
138
152
 
139
153
  > 注:部分平台解析能力可能因API限制有所差异,具体以实际解析结果为准。
140
154