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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -9,16 +9,32 @@ 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
+ enabled?: boolean | null | undefined;
26
+ protocol?: string | null | undefined;
27
+ host?: string | null | undefined;
28
+ port?: number | null | undefined;
29
+ auth?: ({
30
+ username?: string | null | undefined;
31
+ password?: string | null | undefined;
32
+ } & import("cosmokit").Dict) | null | undefined;
33
+ } & import("cosmokit").Dict) | null | undefined;
34
+ customHeaders?: ({
35
+ name?: string | null | undefined;
36
+ value?: string | null | undefined;
37
+ } & import("cosmokit").Dict)[] | null | undefined;
22
38
  } & {
23
39
  ignoreSendError?: boolean | null | undefined;
24
40
  retryTimes?: number | null | undefined;
@@ -27,6 +43,7 @@ export declare const Config: Schema<{
27
43
  enableForward?: boolean | null | undefined;
28
44
  } & {
29
45
  deduplicationInterval?: number | null | undefined;
46
+ cacheTTL?: number | null | undefined;
30
47
  } & {
31
48
  primaryApiUrl?: string | null | undefined;
32
49
  backupApiUrl?: string | null | undefined;
@@ -57,7 +74,9 @@ export declare const Config: Schema<{
57
74
  apiKey?: string | null | undefined;
58
75
  authHeaderType?: "Bearer" | "X-API-Key" | "Custom" | null | undefined;
59
76
  customHeaderName?: string | null | undefined;
77
+ fieldMapping?: string | null | undefined;
60
78
  } & import("cosmokit").Dict)[] | null | undefined;
79
+ globalFieldMapping?: string | null | undefined;
61
80
  } & {
62
81
  waitingTipText?: string | null | undefined;
63
82
  unsupportedPlatformText?: string | null | undefined;
@@ -73,16 +92,35 @@ export declare const Config: Schema<{
73
92
  unifiedMessageFormat: string;
74
93
  } & {
75
94
  showImageText: boolean;
95
+ showCoverImage: boolean;
76
96
  showVideoFile: boolean;
77
97
  maxDescLength: number;
78
98
  videoDownloadTimeout: number;
79
99
  tempDir: string;
80
100
  maxVideoSize: number;
81
101
  forceDownloadVideo: boolean;
102
+ maxConcurrent: number;
82
103
  } & {
83
104
  timeout: number;
84
105
  videoSendTimeout: number;
85
106
  userAgent: string;
107
+ proxy: Schemastery.ObjectT<{
108
+ enabled: Schema<boolean, boolean>;
109
+ protocol: Schema<string, string>;
110
+ host: Schema<string, string>;
111
+ port: Schema<number, number>;
112
+ auth: Schema<Schemastery.ObjectS<{
113
+ username: Schema<string, string>;
114
+ password: Schema<string, string>;
115
+ }>, Schemastery.ObjectT<{
116
+ username: Schema<string, string>;
117
+ password: Schema<string, string>;
118
+ }>>;
119
+ }>;
120
+ customHeaders: Schemastery.ObjectT<{
121
+ name: Schema<string, string>;
122
+ value: Schema<string, string>;
123
+ }>[];
86
124
  } & {
87
125
  ignoreSendError: boolean;
88
126
  retryTimes: number;
@@ -91,6 +129,7 @@ export declare const Config: Schema<{
91
129
  enableForward: boolean;
92
130
  } & {
93
131
  deduplicationInterval: number;
132
+ cacheTTL: number;
94
133
  } & {
95
134
  primaryApiUrl: string;
96
135
  backupApiUrl: string;
@@ -121,7 +160,9 @@ export declare const Config: Schema<{
121
160
  apiKey: Schema<string, string>;
122
161
  authHeaderType: Schema<"Bearer" | "X-API-Key" | "Custom", "Bearer" | "X-API-Key" | "Custom">;
123
162
  customHeaderName: Schema<string, string>;
163
+ fieldMapping: Schema<string, string>;
124
164
  }>[];
165
+ globalFieldMapping: string;
125
166
  } & {
126
167
  waitingTipText: string;
127
168
  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({
@@ -51,21 +76,37 @@ exports.Config = koishi_1.Schema.intersect([
51
76
  debug: koishi_1.Schema.boolean().default(false).description('开启调试模式,在控制台输出详细日志'),
52
77
  }).description('基础设置'),
53
78
  koishi_1.Schema.object({
54
- unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default('标题:${标题}\n作者:${作者}\n简介:${简介}\n点赞:${点赞数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n图片数量:${图片数量}').description('统一消息格式,可用变量:${标题} ${作者} ${简介} ${点赞数} ${收藏数} ${转发数} ${播放数} ${评论数} ${视频时长} ${发布时间} ${图片数量} ${作者ID} ${封面}'),
79
+ unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default('标题:${标题}\n作者:${作者}\n简介:${简介}\n点赞:${点赞数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n图片数量:${图片数量}').description('统一消息格式,可用变量:${标题} ${作者} ${简介} ${点赞数} ${收藏数} ${转发数} ${播放数} ${评论数} ${视频时长} ${发布时间} ${图片数量} ${作者ID}'),
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
+ enabled: koishi_1.Schema.boolean().default(false).description('是否启用 HTTP/HTTPS 代理'),
98
+ protocol: koishi_1.Schema.string().default('http').description('代理协议 (http 或 https)'),
99
+ host: koishi_1.Schema.string().default('127.0.0.1').description('代理地址'),
100
+ port: koishi_1.Schema.number().default(7890).description('代理端口'),
101
+ auth: koishi_1.Schema.object({
102
+ username: koishi_1.Schema.string().default('').description('代理用户名'),
103
+ password: koishi_1.Schema.string().default('').description('代理密码'),
104
+ }).description('代理认证'),
105
+ }).description('HTTP/HTTPS 代理设置(需开启 enabled)'),
106
+ customHeaders: koishi_1.Schema.array(koishi_1.Schema.object({
107
+ name: koishi_1.Schema.string().required().description('请求头名称'),
108
+ value: koishi_1.Schema.string().required().description('请求头值'),
109
+ })).default([]).description('自定义请求头,会附加到所有 API 请求中'),
69
110
  }).description('网络与 API 设置'),
70
111
  koishi_1.Schema.object({
71
112
  ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败,避免插件崩溃'),
@@ -77,7 +118,8 @@ exports.Config = koishi_1.Schema.intersect([
77
118
  }).description('发送方式设置'),
78
119
  koishi_1.Schema.object({
79
120
  deduplicationInterval: koishi_1.Schema.number().min(0).step(1).default(180).description('禁止重复解析时间间隔(秒),0 为不限制'),
80
- }).description('去重设置'),
121
+ cacheTTL: koishi_1.Schema.number().min(0).step(1).default(600).description('解析结果缓存时间(秒),0 为不缓存'),
122
+ }).description('缓存与去重设置'),
81
123
  koishi_1.Schema.object({
82
124
  primaryApiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos').description('主 API 地址'),
83
125
  backupApiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/svparse').description('备用主 API 地址'),
@@ -132,7 +174,31 @@ exports.Config = koishi_1.Schema.intersect([
132
174
  koishi_1.Schema.const('Custom').description('自定义 Header 名称'),
133
175
  ]).default('Bearer').description('认证头类型'),
134
176
  customHeaderName: koishi_1.Schema.string().description('自定义 Header 名称(仅当选择 Custom 时有效)').default('X-API-Key'),
177
+ fieldMapping: koishi_1.Schema.string().role('textarea').default('{}').description('字段映射 JSON,例如 {"title":"data.info.name"},支持点号路径'),
135
178
  })).default([]).description('自定义平台专属 API 地址,留空则使用内置默认专属 API'),
179
+ globalFieldMapping: koishi_1.Schema.string().role('textarea').default('{\n' +
180
+ ' "title": "data.title",\n' +
181
+ ' "desc": "data.description",\n' +
182
+ ' "author": "data.author.name",\n' +
183
+ ' "uid": "data.author.id",\n' +
184
+ ' "avatar": "data.author.avatar",\n' +
185
+ ' "cover": "data.cover_url",\n' +
186
+ ' "video": "data.video_url",\n' +
187
+ ' "video_backup": "data.video_qualities",\n' +
188
+ ' "videos": "data.videos",\n' +
189
+ ' "type": "data.type",\n' +
190
+ ' "like": "data.statistics.likes",\n' +
191
+ ' "comment": "data.statistics.comments",\n' +
192
+ ' "collect": "data.statistics.favorites",\n' +
193
+ ' "share": "data.statistics.shares",\n' +
194
+ ' "play": "data.statistics.plays",\n' +
195
+ ' "duration": "data.duration",\n' +
196
+ ' "publishTime": "data.create_time",\n' +
197
+ ' "music_title": "data.music.title",\n' +
198
+ ' "music_author": "data.music.author",\n' +
199
+ ' "music_cover": "data.music.cover",\n' +
200
+ ' "music_url": "data.music.url"\n' +
201
+ '}').description('全局字段映射 JSON,优先级低于专属 API 映射'),
136
202
  }).description('API 选择设置'),
137
203
  koishi_1.Schema.object({
138
204
  waitingTipText: koishi_1.Schema.string().default('正在解析视频,请稍候...').description('解析等待提示文字'),
@@ -149,7 +215,6 @@ function debugLog(level, ...args) {
149
215
  return;
150
216
  logger.info(`[${new Date().toISOString()}] [${level}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`);
151
217
  }
152
- const urlCache = new SimpleLRUCache(500, 10 * 60 * 1000);
153
218
  const LINK_RULES = [
154
219
  { pattern: /https?:\/\/(?:www\.)?bilibili\.com\/video\/([ab]v[0-9a-zA-Z_-]+)/gi, type: 'bilibili' },
155
220
  { pattern: /https?:\/\/b23\.tv\/[0-9a-zA-Z_-]{5,}/gi, type: 'bilibili' },
@@ -288,22 +353,45 @@ function pickBestQuality(videoBackup) {
288
353
  bit_rate: Number(v.bit_rate || 0)
289
354
  })).sort((a, b) => b.bit_rate - a.bit_rate);
290
355
  }
291
- function parseApiResponse(raw, maxDescLen) {
356
+ function getNestedValue(obj, path) {
357
+ if (!path)
358
+ return obj;
359
+ const keys = path.split('.');
360
+ let current = obj;
361
+ for (const key of keys) {
362
+ if (current === null || current === undefined)
363
+ return undefined;
364
+ current = current[key];
365
+ }
366
+ return current;
367
+ }
368
+ function parseApiResponse(raw, maxDescLen, fieldMapping) {
292
369
  debugLog('DEBUG', 'API raw response', raw);
293
370
  const data = raw?.data || {};
294
371
  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;
372
+ const mapField = (name, fallback) => {
373
+ if (fieldMapping && fieldMapping[name]) {
374
+ const value = getNestedValue(raw, fieldMapping[name]);
375
+ if (value !== undefined)
376
+ return value;
377
+ }
378
+ return fallback();
379
+ };
380
+ let type = mapField('type', () => {
381
+ let t = data.type || '';
382
+ if (!t) {
383
+ if (data.images?.length > 0 && !data.url)
384
+ t = 'image';
385
+ else if (data.live_photo?.length > 0)
386
+ t = 'live_photo';
387
+ else if (raw.msg === 'live' || data.live)
388
+ t = 'live';
389
+ else
390
+ t = 'video';
391
+ }
392
+ return t;
393
+ });
394
+ const authorObj = mapField('author', () => data.author);
307
395
  let author = '', uid = '', avatar = '';
308
396
  if (authorObj && typeof authorObj === 'object') {
309
397
  author = authorObj.name || authorObj.author || '';
@@ -311,29 +399,34 @@ function parseApiResponse(raw, maxDescLen) {
311
399
  avatar = authorObj.avatar || data.avatar || '';
312
400
  }
313
401
  else {
314
- author = data.author || data.auther || '';
315
- uid = String(data.uid || '');
316
- avatar = data.avatar || '';
402
+ author = mapField('author', () => data.author || data.auther || '');
403
+ uid = String(mapField('uid', () => data.uid || ''));
404
+ avatar = mapField('avatar', () => data.avatar || '');
317
405
  }
318
- const title = data.title || '';
319
- const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
320
- const cover = data.cover || '';
406
+ const title = mapField('title', () => data.title || '');
407
+ const desc = mapField('desc', () => data.desc || data.description || '').slice(0, maxDescLen).trim();
408
+ const coverRaw = mapField('cover', () => data.cover || '');
409
+ const cover = coverRaw ? (String(coverRaw).startsWith('http') ? String(coverRaw) : 'https:' + coverRaw) : '';
321
410
  let video = '';
322
411
  let videos = [];
323
- if (Array.isArray(data.video_backup) && data.video_backup.length) {
324
- const bestQ = pickBestQuality(data.video_backup);
412
+ const videoBackup = mapField('video_backup', () => data.video_backup);
413
+ if (Array.isArray(videoBackup) && videoBackup.length) {
414
+ const bestQ = pickBestQuality(videoBackup);
325
415
  videos = bestQ;
326
416
  video = bestQ[0]?.url || '';
327
417
  }
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 }));
418
+ if (!video) {
419
+ const rawVideos = mapField('videos', () => data.videos);
420
+ if (Array.isArray(rawVideos) && rawVideos.length) {
421
+ const validVideos = rawVideos.filter((v) => v && v.url);
422
+ if (validVideos.length) {
423
+ video = validVideos[0].url;
424
+ videos = validVideos.map((v) => ({ quality: v.accept?.[0] || 'unknown', url: v.url }));
425
+ }
333
426
  }
334
427
  }
335
- if (!video && data.url)
336
- video = data.url;
428
+ if (!video)
429
+ video = mapField('video', () => data.url || '');
337
430
  if (video && !video.startsWith('http'))
338
431
  video = 'https:' + video;
339
432
  const images = Array.isArray(data.images) ? data.images.filter((img) => img && typeof img === 'string').map((img) => img.startsWith('http') ? img : 'https:' + img) : [];
@@ -342,34 +435,36 @@ function parseApiResponse(raw, maxDescLen) {
342
435
  video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
343
436
  })) : [];
344
437
  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 || ''
438
+ title: mapField('music_title', () => data.music?.title || data.music?.name || ''),
439
+ author: mapField('music_author', () => data.music?.author || data.music?.artist || ''),
440
+ cover: mapField('music_cover', () => data.music?.cover || ''),
441
+ url: mapField('music_url', () => data.music?.url || ''),
349
442
  };
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);
443
+ const stats = { ...(data.statistics || {}), ...(extra.statistics || {}) };
444
+ const like = Number(mapField('like', () => data.like ?? stats.digg_count ?? stats.like_count ?? stats.likes ?? 0));
445
+ const comment = Number(mapField('comment', () => data.comment ?? stats.comment_count ?? stats.comments ?? stats.comment ?? 0));
446
+ const collect = Number(mapField('collect', () => data.collect ?? stats.collect_count ?? stats.favorite_count ?? stats.favorites ?? 0));
447
+ const share = Number(mapField('share', () => data.share ?? stats.share_count ?? stats.forward_count ?? stats.shares ?? 0));
448
+ const play = Number(mapField('play', () => data.play ?? stats.play_count ?? stats.view_count ?? stats.plays ?? 0));
356
449
  let duration = 0;
357
- if (data.duration) {
358
- duration = typeof data.duration === 'string' ? parseInt(data.duration, 10) : data.duration;
450
+ const durRaw = mapField('duration', () => data.duration);
451
+ if (durRaw) {
452
+ duration = typeof durRaw === 'string' ? parseInt(durRaw, 10) : Number(durRaw);
359
453
  if (duration > 1000000)
360
454
  duration = Math.floor(duration / 1000);
361
455
  }
362
456
  else if (extra.duration_ms) {
363
- duration = Math.floor(extra.duration_ms / 1000);
457
+ duration = Math.floor(Number(extra.duration_ms) / 1000);
364
458
  }
365
459
  let publishTime = 0;
366
- if (data.time) {
367
- publishTime = typeof data.time === 'number' ? data.time : parseInt(data.time, 10);
460
+ const timeRaw = mapField('publishTime', () => data.time);
461
+ if (timeRaw) {
462
+ publishTime = typeof timeRaw === 'number' ? timeRaw : parseInt(timeRaw, 10);
368
463
  if (publishTime < 1000000000000)
369
464
  publishTime *= 1000;
370
465
  }
371
466
  else if (extra.create_time) {
372
- publishTime = extra.create_time * 1000;
467
+ publishTime = Number(extra.create_time) * 1000;
373
468
  }
374
469
  return { type, title, desc, author, uid, avatar, cover, video, videos, images, live_photo, music, like, comment, collect, share, play, duration, publishTime };
375
470
  }
@@ -395,24 +490,13 @@ function generateFormattedText(p, format) {
395
490
  const lines = format.split('\n');
396
491
  const resultLines = [];
397
492
  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
493
  let newLine = line;
413
494
  for (const [key, val] of Object.entries(vars)) {
414
495
  newLine = newLine.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), val);
415
496
  }
497
+ const stripped = newLine.replace(/[\s::,,。.、;;!!??【】\[\]「」『』()()《》""''""·—\-_/\\|@#$%^&*+=~`]/g, '').trim();
498
+ if (stripped.length === 0)
499
+ continue;
416
500
  resultLines.push(newLine);
417
501
  }
418
502
  return resultLines.join('\n').trim();
@@ -435,10 +519,25 @@ function getErrorMessage(error) {
435
519
  return String(error.message);
436
520
  return String(error);
437
521
  }
522
+ function parseFieldMapping(mappingStr) {
523
+ if (!mappingStr || mappingStr.trim() === '{}' || mappingStr.trim() === '')
524
+ return undefined;
525
+ try {
526
+ const obj = JSON.parse(mappingStr);
527
+ if (typeof obj === 'object' && !Array.isArray(obj))
528
+ return obj;
529
+ return undefined;
530
+ }
531
+ catch {
532
+ return undefined;
533
+ }
534
+ }
438
535
  function apply(ctx, config) {
439
536
  debugEnabled = config.debug || false;
440
537
  debugLog('INFO', 'plugin start');
441
538
  const dedupCache = new SimpleLRUCache(1000, config.deduplicationInterval * 1000);
539
+ const cacheTTL = (config.cacheTTL || 600) * 1000;
540
+ const urlCacheLocal = new SimpleLRUCache(500, cacheTTL);
442
541
  const texts = {
443
542
  waitingTipText: config.waitingTipText || '正在解析视频,请稍候...',
444
543
  unsupportedPlatformText: config.unsupportedPlatformText || '不支持该平台链接',
@@ -446,14 +545,27 @@ function apply(ctx, config) {
446
545
  parseErrorPrefix: config.parseErrorPrefix || '❌ 解析失败:',
447
546
  parseErrorItemFormat: config.parseErrorItemFormat || '【${url}】: ${msg}',
448
547
  };
449
- const http = axios_1.default.create({
548
+ const proxyConfig = config.proxy || {};
549
+ const axiosConfig = {
450
550
  timeout: config.timeout,
451
551
  headers: {
452
552
  'User-Agent': config.userAgent,
453
553
  'Referer': 'https://www.baidu.com/',
454
554
  'Content-Type': 'application/x-www-form-urlencoded'
455
555
  }
456
- });
556
+ };
557
+ if (proxyConfig.enabled && proxyConfig.host) {
558
+ axiosConfig.proxy = {
559
+ protocol: proxyConfig.protocol || 'http',
560
+ host: proxyConfig.host,
561
+ port: proxyConfig.port || 7890,
562
+ auth: proxyConfig.auth?.username ? {
563
+ username: proxyConfig.auth.username,
564
+ password: proxyConfig.auth.password || ''
565
+ } : undefined
566
+ };
567
+ }
568
+ const http = axios_1.default.create(axiosConfig);
457
569
  const defaultDedicatedApis = {
458
570
  bilibili: 'https://api.bugpk.com/api/bilibili',
459
571
  douyin: 'https://api.bugpk.com/api/douyin',
@@ -476,14 +588,19 @@ function apply(ctx, config) {
476
588
  let apiKey = '';
477
589
  let authHeaderType = 'Bearer';
478
590
  let customHeaderName = 'X-API-Key';
591
+ let fieldMapping = undefined;
479
592
  if (custom && custom.apiUrl) {
480
593
  apiUrl = custom.apiUrl;
481
594
  apiKey = custom.apiKey || '';
482
595
  authHeaderType = custom.authHeaderType || 'Bearer';
483
596
  customHeaderName = custom.customHeaderName || 'X-API-Key';
597
+ fieldMapping = parseFieldMapping(custom.fieldMapping);
484
598
  }
485
599
  const dedicatedFirst = config.platformDedicatedFirst?.[type] ?? false;
486
- return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName };
600
+ if (!fieldMapping) {
601
+ fieldMapping = parseFieldMapping(config.globalFieldMapping);
602
+ }
603
+ return { apiUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName, fieldMapping };
487
604
  }
488
605
  function buildAuthHeaders(apiKey, authHeaderType, customHeaderName) {
489
606
  if (!apiKey)
@@ -552,9 +669,9 @@ function apply(ctx, config) {
552
669
  throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
553
670
  }
554
671
  }
555
- async function fetchApi(url, type) {
672
+ async function fetchApi(url, type, fieldMapping) {
556
673
  const cacheKey = url;
557
- const cached = urlCache.get(cacheKey);
674
+ const cached = urlCacheLocal.get(cacheKey);
558
675
  if (cached && cached.expire > Date.now())
559
676
  return cached.data;
560
677
  const { apiUrl: dedicatedUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName } = getPlatformConfig(type);
@@ -563,18 +680,19 @@ function apply(ctx, config) {
563
680
  const backupAllowed = backupSupportedPlatforms.has(type);
564
681
  const apiList = [];
565
682
  if (dedicatedFirst && dedicatedUrl) {
566
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
567
- apiList.push({ url: primaryApi, label: '默认主API' });
683
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
684
+ apiList.push({ url: primaryApi, label: '默认主API', fieldMapping });
568
685
  if (backupAllowed)
569
- apiList.push({ url: backupApi, label: '备用主API' });
686
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
570
687
  }
571
688
  else {
572
- apiList.push({ url: primaryApi, label: '默认主API' });
689
+ apiList.push({ url: primaryApi, label: '默认主API', fieldMapping });
573
690
  if (backupAllowed)
574
- apiList.push({ url: backupApi, label: '备用主API' });
691
+ apiList.push({ url: backupApi, label: '备用主API', fieldMapping });
575
692
  if (dedicatedUrl)
576
- apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName });
693
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
577
694
  }
695
+ const customHeaders = config.customHeaders || [];
578
696
  let lastError = null;
579
697
  for (const api of apiList) {
580
698
  for (let attempt = 0; attempt <= config.retryTimes; attempt++) {
@@ -584,14 +702,18 @@ function apply(ctx, config) {
584
702
  'Referer': 'https://www.baidu.com/',
585
703
  'Content-Type': 'application/x-www-form-urlencoded'
586
704
  };
705
+ for (const h of customHeaders) {
706
+ if (h.name && h.value)
707
+ headers[h.name] = h.value;
708
+ }
587
709
  if (api.apiKey) {
588
710
  const authHeaders = buildAuthHeaders(api.apiKey, api.authHeaderType || 'Bearer', api.customHeaderName || 'X-API-Key');
589
711
  Object.assign(headers, authHeaders);
590
712
  }
591
713
  const res = await http.get(api.url, { params: { url }, timeout: config.timeout, headers });
592
714
  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 });
715
+ const parsed = parseApiResponse(res.data, config.maxDescLength, api.fieldMapping);
716
+ urlCacheLocal.set(cacheKey, { data: parsed, expire: Date.now() + cacheTTL });
595
717
  return parsed;
596
718
  }
597
719
  throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
@@ -607,12 +729,12 @@ function apply(ctx, config) {
607
729
  }
608
730
  throw lastError || new Error('所有API请求全部失败');
609
731
  }
610
- async function parseUrl(url, type) {
732
+ async function parseUrl(url, type, fieldMapping) {
611
733
  const realUrl = await resolveShortUrl(url);
612
734
  const candidates = [...new Set([realUrl, url])];
613
735
  for (const candidate of candidates) {
614
736
  try {
615
- const info = await fetchApi(candidate, type);
737
+ const info = await fetchApi(candidate, type, fieldMapping);
616
738
  if (info.video || info.images.length > 0)
617
739
  return { success: true, data: info };
618
740
  debugLog('WARN', `解析成功但无内容: ${candidate}`);
@@ -623,8 +745,8 @@ function apply(ctx, config) {
623
745
  }
624
746
  return { success: false, msg: texts.unsupportedPlatformText };
625
747
  }
626
- async function processSingleUrl(url, type) {
627
- const result = await parseUrl(url, type);
748
+ async function processSingleUrl(url, type, fieldMapping) {
749
+ const result = await parseUrl(url, type, fieldMapping);
628
750
  if (!result.success)
629
751
  return { success: false, msg: result.msg, url };
630
752
  const text = generateFormattedText(result.data, config.unifiedMessageFormat);
@@ -696,31 +818,37 @@ function apply(ctx, config) {
696
818
  debugLog('INFO', `开始解析 ${matches.length} 个链接`);
697
819
  const items = [];
698
820
  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;
821
+ const limiter = new ConcurrencyLimiter(config.maxConcurrent || 3);
822
+ const promises = matches.map(async (match) => {
823
+ await limiter.acquire();
824
+ try {
825
+ if (config.deduplicationInterval > 0) {
826
+ const lastTime = dedupCache.get(match.url);
827
+ if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
828
+ debugLog('INFO', `跳过重复链接: ${match.url}`);
829
+ const shortUrl = match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url;
830
+ await sendWithTimeout(session, `链接 ${shortUrl} 在最近 ${config.deduplicationInterval} 秒内已解析过,已跳过。`).catch(() => { });
831
+ return;
832
+ }
833
+ }
834
+ debugLog('INFO', `解析链接: ${match.url} (${match.type})`);
835
+ const fieldMapping = getPlatformConfig(match.type).fieldMapping;
836
+ const result = await processSingleUrl(match.url, match.type, fieldMapping);
837
+ if (result.success) {
838
+ items.push(result.data);
839
+ if (config.deduplicationInterval > 0)
840
+ dedupCache.set(match.url, Date.now());
841
+ }
842
+ else {
843
+ const item = texts.parseErrorItemFormat.replace(/\$\{url\}/g, match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url).replace(/\$\{msg\}/g, result.msg);
844
+ errors.push(item);
708
845
  }
709
846
  }
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);
847
+ finally {
848
+ limiter.release();
720
849
  }
721
- if (i < matches.length - 1)
722
- await delay(500);
723
- }
850
+ });
851
+ await Promise.all(promises);
724
852
  if (errors.length)
725
853
  await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
726
854
  if (!items.length)
@@ -734,7 +862,7 @@ function apply(ctx, config) {
734
862
  const text = item.text;
735
863
  if (text && config.showImageText)
736
864
  forwardMessages.push(buildForwardNode(session, text, botName));
737
- if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length)))
865
+ if (config.showCoverImage && p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length)))
738
866
  forwardMessages.push(buildForwardNode(session, koishi_1.h.image(p.cover), botName));
739
867
  if (p.type === 'image' || p.type === 'live_photo' || (p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
740
868
  const imageUrls = p.images?.length ? p.images : (p.live_photo?.map(lp => lp.image) ?? []);
@@ -765,7 +893,7 @@ function apply(ctx, config) {
765
893
  await sendWithTimeout(session, text);
766
894
  await delay(300);
767
895
  }
768
- if (p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
896
+ if (config.showCoverImage && p.cover && p.type !== 'live_photo' && !(p.type === 'live' && (p.live_photo?.length || p.images?.length))) {
769
897
  await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
770
898
  await delay(300);
771
899
  }
@@ -853,7 +981,7 @@ function apply(ctx, config) {
853
981
  }, 3600000);
854
982
  ctx.on('dispose', () => {
855
983
  clearInterval(tempCleanupInterval);
856
- urlCache.clear();
984
+ urlCacheLocal.clear();
857
985
  dedupCache.clear();
858
986
  debugLog('INFO', '插件已卸载');
859
987
  });
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.3.0",
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 | `{ enabled: false, protocol: "http", host: "127.0.0.1", port: 7890, auth: { username: "", password: "" } }` | HTTP/HTTPS 代理设置。`enabled` 开关(默认关闭),`protocol` 支持 `http` 或 `https`。需开启 `enabled` 并填写 `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(见下方示例) | 全局字段映射 JSON,优先级低于专属 API 映射。用于统一适配所有平台的 API 响应格式,默认已包含常用路径示例 |
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 | 正在解析视频,请稍候... | 解析等待提示文字 |
@@ -102,7 +116,6 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
102
116
  | `${发布时间}` | 发布时间(格式化) | 所有平台 |
103
117
  | `${图片数量}` | 图集/实况图片数量 | 图集/实况 |
104
118
  | `${作者ID}` | 作者唯一标识ID | 部分平台 |
105
- | `${封面}` | 封面图片地址 | 所有平台 |
106
119
  | `${视频链接}` | 视频原始链接 | 视频 |
107
120
 
108
121
  > 注:部分变量可能因平台API返回数据不同而显示为空,某行所有变量为空(或为"0")时该行会自动隐藏。
@@ -144,6 +157,9 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
144
157
  |----------------------|-------------------------|
145
158
  | Minecraft-1314 | 插件完整开发 (Complete plugin development) |
146
159
  | ShiraiKuroko003 | 修复消息格式设置问题并且PR-1.2.5版本已修复 |
160
+ | cyavb | 提交功能建议-给自定义API添加KEY认证-已修复 |
161
+ | Keep785 | 提交Bug-无法正常关闭发送封面-已修复 |
162
+ | dzt2008 + Apricityx | 提交Bug-会对非支持视频平台URL进行误解析-已修复 |
147
163
  | JH-Ahua | BugPk-Api 支持 |
148
164
  | shangxue | 灵感来源 |
149
165