koishi-plugin-video-parser-all 0.1.2 → 0.1.3

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
@@ -15,6 +15,9 @@ export interface Config {
15
15
  bugpkBilibiliApi: string;
16
16
  timeout: number;
17
17
  ignoreSendError: boolean;
18
+ enableForward: boolean;
19
+ downloadVideoBeforeSend: boolean;
20
+ messageBufferDelay: number;
18
21
  }
19
22
  export declare const Config: Schema<Config>;
20
23
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -8,37 +8,48 @@ exports.apply = apply;
8
8
  const koishi_1 = require("koishi");
9
9
  const axios_1 = __importDefault(require("axios"));
10
10
  const crypto_1 = __importDefault(require("crypto"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const promises_1 = require("stream/promises");
11
14
  exports.name = 'video-parser-all';
12
15
  exports.Config = koishi_1.Schema.object({
13
- enable: koishi_1.Schema.boolean().default(true),
14
- showWaitingTip: koishi_1.Schema.boolean().default(true),
15
- waitingTipText: koishi_1.Schema.string().default('正在解析视频…'),
16
- sameLinkInterval: koishi_1.Schema.number().default(180),
16
+ enable: koishi_1.Schema.boolean().default(true).description('是否启用插件'),
17
+ showWaitingTip: koishi_1.Schema.boolean().default(true).description('是否显示解析等待提示'),
18
+ waitingTipText: koishi_1.Schema.string().default('正在解析视频…').description('解析等待提示的文本内容'),
19
+ sameLinkInterval: koishi_1.Schema.number().default(180).description('相同链接重复解析间隔(秒),避免频繁解析同一链接'),
17
20
  imageParseFormat: koishi_1.Schema.string()
18
21
  .role('textarea')
19
22
  .default(`\${标题} \${tab} \${UP主}
20
23
  \${简介}
21
24
  \${~~~}
22
- \${封面}`),
23
- showVideoUrl: koishi_1.Schema.boolean().default(false),
24
- maxDescLength: koishi_1.Schema.number().default(200),
25
- bugpkUniversalApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos'),
26
- bugpkDouyinMainApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/douyin'),
27
- bugpkDouyinBackupApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/dyjx'),
28
- bugpkKuaishouApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/ksjx'),
29
- bugpkBilibiliApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/bilibili'),
30
- timeout: koishi_1.Schema.number().default(15000),
31
- ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败的错误(避免日志刷屏)'),
25
+ \${封面}`)
26
+ .description('解析结果的输出格式,支持占位符:${标题}、${UP主}、${简介}、${点赞}、${投币}、${收藏}、${转发}、${观看}、${弹幕}、${tab}、${~~~}、${封面}'),
27
+ showVideoUrl: koishi_1.Schema.boolean().default(false).description('是否在消息中显示无水印视频链接(false则直接发送视频文件)'),
28
+ maxDescLength: koishi_1.Schema.number().default(200).description('简介内容的最大长度,超出部分会被截断'),
29
+ bugpkUniversalApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos').description('通用视频解析API地址(优先调用)'),
30
+ bugpkDouyinMainApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/douyin').description('抖音主解析API地址(备用)'),
31
+ bugpkDouyinBackupApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/dyjx').description('抖音备用解析API地址(备用)'),
32
+ bugpkKuaishouApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/ksjx').description('快手解析API地址(备用)'),
33
+ bugpkBilibiliApi: koishi_1.Schema.string().default('https://api.bugpk.com/api/bilibili').description('B站解析API地址(备用)'),
34
+ timeout: koishi_1.Schema.number().default(15000).description('API请求超时时间(毫秒)'),
35
+ ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败的错误,避免日志刷屏'),
36
+ enableForward: koishi_1.Schema.boolean().default(false).description('是否开启合并转发 仅支持 onebot 适配器 其他平台开启 无效'),
37
+ downloadVideoBeforeSend: koishi_1.Schema.boolean().default(false).description('是否将视频链接下载后再发送 (以解决部分onebot协议端的问题)否则使用视频直链发送'),
38
+ messageBufferDelay: koishi_1.Schema.number().default(1).description('消息接收缓冲延迟(秒)收到链接后等待指定时间,收集同时发送的多个链接后再逐个处理'),
32
39
  });
33
40
  const processed = new Map();
41
+ const linkBuffer = new Map();
34
42
  const PLATFORM_KEYWORDS = {
35
43
  bilibili: ['bilibili', 'b23', 'B站'],
36
44
  kuaishou: ['kuaishou', '快手'],
37
45
  douyin: ['douyin', '抖音']
38
46
  };
39
47
  function extractUrl(content) {
40
- const urlMatch = content.match(/https?:\/\/[^\s]+/i);
41
- return urlMatch ? urlMatch[0] : null;
48
+ const urlMatches = content.match(/https?:\/\/[^\s]+/gi) || [];
49
+ return urlMatches.filter(url => {
50
+ const lowerUrl = url.toLowerCase();
51
+ return Object.values(PLATFORM_KEYWORDS).some(keywords => keywords.some(keyword => lowerUrl.includes(keyword.toLowerCase())));
52
+ });
42
53
  }
43
54
  function hasPlatformKeyword(content) {
44
55
  const lowerContent = content.toLowerCase();
@@ -57,6 +68,21 @@ function getPlatformType(url) {
57
68
  return 'bilibili';
58
69
  return null;
59
70
  }
71
+ async function downloadVideo(url, filename) {
72
+ const downloadPath = path_1.default.join(process.cwd(), 'temp_videos');
73
+ if (!fs_1.default.existsSync(downloadPath)) {
74
+ fs_1.default.mkdirSync(downloadPath, { recursive: true });
75
+ }
76
+ const filePath = path_1.default.join(downloadPath, `${filename}.mp4`);
77
+ const response = await (0, axios_1.default)({
78
+ url,
79
+ method: 'GET',
80
+ responseType: 'stream',
81
+ timeout: 30000
82
+ });
83
+ await (0, promises_1.pipeline)(response.data, fs_1.default.createWriteStream(filePath));
84
+ return filePath;
85
+ }
60
86
  function parseUniversalApiData(data) {
61
87
  return {
62
88
  title: data.title || '无标题',
@@ -112,6 +138,10 @@ function parseBilibili(data) {
112
138
  videoUrl = data.play;
113
139
  else if (data.durl && Array.isArray(data.durl))
114
140
  videoUrl = data.durl[0]?.url || '';
141
+ else if (data.hd_url)
142
+ videoUrl = data.hd_url;
143
+ else if (data.sd_url)
144
+ videoUrl = data.sd_url;
115
145
  const digg = data.like || data.digg || 0;
116
146
  const coin = data.coin || 0;
117
147
  const collect = data.favorite || data.collect || 0;
@@ -200,50 +230,112 @@ function apply(ctx, config) {
200
230
  cover: '', video: ''
201
231
  };
202
232
  }
203
- async function sendResult(session, data) {
204
- try {
205
- let text = config.imageParseFormat
206
- .replace(/\${标题}/g, data.title)
207
- .replace(/\${UP主}/g, data.author)
208
- .replace(/\${简介}/g, data.desc.slice(0, config.maxDescLength))
209
- .replace(/\${点赞}/g, data.digg.toString())
210
- .replace(/\${投币}/g, data.coin.toString())
211
- .replace(/\${收藏}/g, data.collect.toString())
212
- .replace(/\${转发}/g, data.share.toString())
213
- .replace(/\${观看}/g, data.play.toString())
214
- .replace(/\${弹幕}/g, data.danmaku.toString())
215
- .replace(/\${tab}/g, '\t')
216
- .replace(/\${~~~}/g, '——————————————');
217
- const [beforeCover, afterCover] = text.split('\${封面}');
218
- const msgParts = [];
219
- if (beforeCover && beforeCover.trim())
220
- msgParts.push(beforeCover.trim());
221
- if (data.cover)
222
- msgParts.push(koishi_1.h.image(data.cover));
223
- if (afterCover && afterCover.trim())
224
- msgParts.push(afterCover.trim());
225
- if (data.video && config.showVideoUrl)
226
- msgParts.push(`🔗 无水印链接:${data.video}`);
227
- if (msgParts.length > 0) {
228
- await session.send(msgParts.join('\n')).catch(e => {
229
- if (!config.ignoreSendError)
230
- ctx.logger.warn(`发送合并消息失败: ${e.message}`);
231
- });
232
- }
233
- if (data.video && !config.showVideoUrl) {
233
+ async function processSingleUrl(session, url) {
234
+ const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
235
+ const now = Date.now();
236
+ if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000)
237
+ return;
238
+ processed.set(hash, now);
239
+ const data = await parseVideo(url);
240
+ let text = config.imageParseFormat
241
+ .replace(/\${标题}/g, data.title)
242
+ .replace(/\${UP主}/g, data.author)
243
+ .replace(/\${简介}/g, data.desc.slice(0, config.maxDescLength))
244
+ .replace(/\${点赞}/g, data.digg.toString())
245
+ .replace(/\${投币}/g, data.coin.toString())
246
+ .replace(/\${收藏}/g, data.collect.toString())
247
+ .replace(/\${转发}/g, data.share.toString())
248
+ .replace(/\${观看}/g, data.play.toString())
249
+ .replace(/\${弹幕}/g, data.danmaku.toString())
250
+ .replace(/\${tab}/g, '\t')
251
+ .replace(/\${~~~}/g, '——————————————');
252
+ const [beforeCover, afterCover] = text.split('\${封面}');
253
+ const msgParts = [];
254
+ if (beforeCover && beforeCover.trim())
255
+ msgParts.push(beforeCover.trim());
256
+ if (data.cover)
257
+ msgParts.push(koishi_1.h.image(data.cover));
258
+ if (afterCover && afterCover.trim())
259
+ msgParts.push(afterCover.trim());
260
+ if (data.video && config.showVideoUrl)
261
+ msgParts.push(`🔗 无水印链接:${data.video}`);
262
+ let videoMsg = '';
263
+ if (data.video && !config.showVideoUrl) {
264
+ if (config.downloadVideoBeforeSend && data.video) {
234
265
  try {
235
- await session.send(koishi_1.h.video(data.video)).catch(e => {
236
- session.send(`📥 无水印视频:${data.video}`).catch(() => { });
237
- });
266
+ const filename = crypto_1.default.createHash('md5').update(data.video).digest('hex');
267
+ const filePath = await downloadVideo(data.video, filename);
268
+ videoMsg = koishi_1.h.video(`file://${filePath}`);
238
269
  }
239
270
  catch (e) {
240
- await session.send(`📥 无水印视频:${data.video}`).catch(() => { });
271
+ ctx.logger.error(`视频下载失败: ${e.message}`);
272
+ videoMsg = `📥 无水印视频:${data.video}`;
241
273
  }
242
274
  }
275
+ else {
276
+ videoMsg = koishi_1.h.video(data.video);
277
+ }
243
278
  }
244
- catch (e) {
245
- if (!config.ignoreSendError)
246
- ctx.logger.error(`发送结果异常: ${e.message}`);
279
+ return {
280
+ content: msgParts.join('\n'),
281
+ video: videoMsg,
282
+ data
283
+ };
284
+ }
285
+ async function processBufferedUrls(session) {
286
+ const sessionKey = `${session.platform}:${session.userId}:${session.channelId}`;
287
+ const bufferData = linkBuffer.get(sessionKey);
288
+ if (!bufferData)
289
+ return;
290
+ clearTimeout(bufferData.timer);
291
+ linkBuffer.delete(sessionKey);
292
+ const results = [];
293
+ for (const url of bufferData.urls) {
294
+ const result = await processSingleUrl(session, url);
295
+ if (result)
296
+ results.push(result);
297
+ }
298
+ if (results.length === 0)
299
+ return;
300
+ // 修复:通过平台判断是否为onebot,而非直接访问session.adapter
301
+ if (config.enableForward && session.platform === 'onebot') {
302
+ const forwardMessages = results.map(result => {
303
+ const messages = [
304
+ (0, koishi_1.h)('message', { user_id: session.selfId }, [result.content]),
305
+ ];
306
+ if (result.video)
307
+ messages.push((0, koishi_1.h)('message', { user_id: session.selfId }, [result.video]));
308
+ return messages;
309
+ }).flat();
310
+ await session.send((0, koishi_1.h)('forward', {
311
+ messages: forwardMessages,
312
+ title: '视频解析结果',
313
+ brief: `共解析${results.length}个视频链接`,
314
+ source: '视频解析插件',
315
+ preview: [results[0].content.substring(0, 10) + '...'],
316
+ summary: `查看${results.length}条解析结果`
317
+ })).catch(e => {
318
+ if (!config.ignoreSendError)
319
+ ctx.logger.warn(`合并转发发送失败: ${e.message}`);
320
+ sendIndividualMessages(session, results);
321
+ });
322
+ }
323
+ else {
324
+ sendIndividualMessages(session, results);
325
+ }
326
+ }
327
+ async function sendIndividualMessages(session, results) {
328
+ for (const result of results) {
329
+ try {
330
+ if (result.content)
331
+ await session.send(result.content);
332
+ if (result.video)
333
+ await session.send(result.video);
334
+ }
335
+ catch (e) {
336
+ if (!config.ignoreSendError)
337
+ ctx.logger.warn(`单条消息发送失败: ${e.message}`);
338
+ }
247
339
  }
248
340
  }
249
341
  ctx.on('message', async (session) => {
@@ -253,22 +345,27 @@ function apply(ctx, config) {
253
345
  const content = session.content.trim();
254
346
  if (!hasPlatformKeyword(content))
255
347
  return;
256
- const url = extractUrl(content);
257
- if (!url)
258
- return;
259
- const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
260
- const now = Date.now();
261
- if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000)
348
+ const urls = extractUrl(content);
349
+ if (urls.length === 0)
262
350
  return;
263
- processed.set(hash, now);
264
- if (config.showWaitingTip) {
351
+ if (config.showWaitingTip && !linkBuffer.has(`${session.platform}:${session.userId}:${session.channelId}`)) {
265
352
  await session.send(config.waitingTipText).catch(e => {
266
353
  if (!config.ignoreSendError)
267
354
  ctx.logger.warn(`发送等待提示失败: ${e.message}`);
268
355
  });
269
356
  }
270
- const data = await parseVideo(url);
271
- await sendResult(session, data);
357
+ const sessionKey = `${session.platform}:${session.userId}:${session.channelId}`;
358
+ if (linkBuffer.has(sessionKey)) {
359
+ const bufferData = linkBuffer.get(sessionKey);
360
+ bufferData.urls.push(...urls);
361
+ clearTimeout(bufferData.timer);
362
+ bufferData.timer = setTimeout(() => processBufferedUrls(session), config.messageBufferDelay * 1000);
363
+ linkBuffer.set(sessionKey, bufferData);
364
+ }
365
+ else {
366
+ const timer = setTimeout(() => processBufferedUrls(session), config.messageBufferDelay * 1000);
367
+ linkBuffer.set(sessionKey, { urls, timer });
368
+ }
272
369
  }
273
370
  catch (e) {
274
371
  ctx.logger.error(`消息处理异常: ${e.message}`);
@@ -277,6 +374,16 @@ function apply(ctx, config) {
277
374
  setInterval(() => {
278
375
  const now = Date.now();
279
376
  processed.forEach((t, k) => now - t > 86400000 && processed.delete(k));
377
+ const tempPath = path_1.default.join(process.cwd(), 'temp_videos');
378
+ if (fs_1.default.existsSync(tempPath)) {
379
+ fs_1.default.readdirSync(tempPath).forEach(file => {
380
+ const filePath = path_1.default.join(tempPath, file);
381
+ const stat = fs_1.default.statSync(filePath);
382
+ if (now - stat.ctimeMs > 3600000) {
383
+ fs_1.default.unlinkSync(filePath);
384
+ }
385
+ });
386
+ }
280
387
  }, 3600000);
281
388
  ctx.logger.info('视频解析插件加载完成');
282
389
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-video-parser-all",
3
3
  "description": "Koishi 视频解析插件,支持抖音/快手/B站链接解析,可自定义API和解析规则",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [