koishi-plugin-video-parser-all 0.2.0 → 0.2.2

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
@@ -21,6 +21,7 @@ export interface Config {
21
21
  retryTimes: number;
22
22
  retryInterval: number;
23
23
  apiUrl: string;
24
+ videoSendTimeout: number;
24
25
  }
25
26
  export declare const Config: Schema<Config>;
26
27
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -14,27 +14,39 @@ const promises_1 = require("stream/promises");
14
14
  const worker_threads_1 = require("worker_threads");
15
15
  exports.name = 'video-parser-all';
16
16
  exports.Config = koishi_1.Schema.object({
17
- enable: koishi_1.Schema.boolean().default(true),
18
- showWaitingTip: koishi_1.Schema.boolean().default(true),
19
- // 修复:boolean.default → Schema.boolean().default
20
- revokeWaitingTip: koishi_1.Schema.boolean().default(true),
21
- waitingTipText: koishi_1.Schema.string().default('正在解析视频…'),
22
- sameLinkInterval: koishi_1.Schema.number().default(180),
23
- imageParseFormat: koishi_1.Schema.string().role('textarea').default('${标题} ${tab} ${UP主}\n${简介}\n${~~~}\n${封面}'),
17
+ enable: koishi_1.Schema.boolean().default(true).description('启用插件'),
18
+ showWaitingTip: koishi_1.Schema.boolean().default(true).description('解析时显示等待提示'),
19
+ revokeWaitingTip: koishi_1.Schema.boolean().default(true).description('解析完成后撤回等待提示'),
20
+ waitingTipText: koishi_1.Schema.string().default('正在解析视频,请稍候...').description('等待提示文本'),
21
+ sameLinkInterval: koishi_1.Schema.number().default(180).description('相同链接解析间隔(秒)'),
22
+ // 修复:给${~~~}加转义符 \${~~~} 避免TS解析错误
23
+ imageParseFormat: koishi_1.Schema.string().role('textarea').default('${标题}\n${~~~}\n${UP主}\n${~~~}\n${封面}').description(`解析结果格式
24
+ 支持变量:\${标题} \${UP主} \${简介} \${点赞} \${投币} \${收藏} \${转发} \${观看} \${弹幕} \${tab} \${~~~} \${封面}`),
24
25
  returnContent: koishi_1.Schema.object({
25
- showImageText: koishi_1.Schema.boolean().default(true),
26
- showVideoUrl: koishi_1.Schema.boolean().default(false),
27
- showVideoFile: koishi_1.Schema.boolean().default(true),
28
- }),
29
- maxDescLength: koishi_1.Schema.number().default(200),
30
- timeout: koishi_1.Schema.number().default(15000),
31
- ignoreSendError: koishi_1.Schema.boolean().default(true),
32
- enableForward: koishi_1.Schema.boolean().default(false),
33
- downloadVideoBeforeSend: koishi_1.Schema.boolean().default(false),
34
- messageBufferDelay: koishi_1.Schema.number().default(1).min(0),
35
- retryTimes: koishi_1.Schema.number().default(3).min(0),
36
- retryInterval: koishi_1.Schema.number().default(2000).min(500),
37
- apiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos'),
26
+ showImageText: koishi_1.Schema.boolean().default(true).description('显示文本与封面'),
27
+ showVideoUrl: koishi_1.Schema.boolean().default(false).description('显示无水印链接'),
28
+ showVideoFile: koishi_1.Schema.boolean().default(true).description('发送视频消息'),
29
+ }).description('返回内容设置'),
30
+ maxDescLength: koishi_1.Schema.number().default(200).description('简介最大长度'),
31
+ timeout: koishi_1.Schema.number().default(15000).description('API请求超时(毫秒)'),
32
+ ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送错误'),
33
+ enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅OneBot)'),
34
+ downloadVideoBeforeSend: koishi_1.Schema.boolean().default(false).description('发送前先下载视频(仅OneBot)'),
35
+ messageBufferDelay: koishi_1.Schema.number().default(1).min(0).description('消息缓冲延迟(秒)'),
36
+ retryTimes: koishi_1.Schema.number().default(3).min(0).description('接口重试次数'),
37
+ retryInterval: koishi_1.Schema.number().default(2000).min(500).description('重试间隔(毫秒)'),
38
+ apiUrl: koishi_1.Schema.string().default('https://api.bugpk.com/api/short_videos').description(`本插件使用 BugPk-Api 公共接口
39
+ 解析失败可能原因:
40
+ 1. 视频为私密/付费/下架/限制
41
+ 2. 接口限流或维护
42
+ 3. 网络波动请求超时
43
+ 4. 平台链接格式更新
44
+ 注意事项:
45
+ 1. 勿频繁解析相同链接
46
+ 2. 仅支持抖音/快手/B站公开视频
47
+ 3. 公共接口不保证100%成功
48
+ 4. 失败可检查链接或稍后重试`),
49
+ videoSendTimeout: koishi_1.Schema.number().default(60000).description('视频发送超时(毫秒)'),
38
50
  });
39
51
  if (!worker_threads_1.isMainThread) {
40
52
  const { url, filePath } = worker_threads_1.workerData;
@@ -64,28 +76,28 @@ if (!worker_threads_1.isMainThread) {
64
76
  const processed = new Map();
65
77
  const linkBuffer = new Map();
66
78
  const PLATFORM_KEYWORDS = {
67
- bilibili: ['bilibili', 'b23', 'B站'],
68
- kuaishou: ['kuaishou', '快手', 'v.kuaishou.com'],
69
- douyin: ['douyin', '抖音', 'v.douyin.com']
79
+ bilibili: ['bilibili', 'b23', 'B站', 'www.bilibili.com', 'm.bilibili.com'],
80
+ kuaishou: ['kuaishou', '快手', 'v.kuaishou.com', 'www.kuaishou.com', 'kwimgs.com'],
81
+ douyin: ['douyin', '抖音', 'v.douyin.com', 'www.douyin.com', '365yg.com', 'douyinpic.com']
70
82
  };
71
83
  function extractUrl(content) {
72
84
  const urlMatches = content.match(/https?:\/\/[^\s]+/gi) || [];
73
85
  return urlMatches.filter(url => {
74
86
  const lower = url.toLowerCase();
75
- return Object.values(PLATFORM_KEYWORDS).some(g => g.some(k => lower.includes(k.toLowerCase())));
87
+ return Object.values(PLATFORM_KEYWORDS).some(g => g.some(k => lower.includes(k)));
76
88
  });
77
89
  }
78
90
  function hasPlatformKeyword(content) {
79
91
  const lower = content.toLowerCase();
80
- return Object.values(PLATFORM_KEYWORDS).some(g => g.some(k => lower.includes(k.toLowerCase())));
92
+ return Object.values(PLATFORM_KEYWORDS).some(g => g.some(k => lower.includes(k)));
81
93
  }
82
94
  function getPlatformType(url) {
83
95
  const lower = url.toLowerCase();
84
- if (PLATFORM_KEYWORDS.douyin.some(k => lower.includes(k.toLowerCase())))
96
+ if (PLATFORM_KEYWORDS.douyin.some(k => lower.includes(k)))
85
97
  return 'douyin';
86
- if (PLATFORM_KEYWORDS.kuaishou.some(k => lower.includes(k.toLowerCase())))
98
+ if (PLATFORM_KEYWORDS.kuaishou.some(k => lower.includes(k)))
87
99
  return 'kuaishou';
88
- if (PLATFORM_KEYWORDS.bilibili.some(k => lower.includes(k.toLowerCase())))
100
+ if (PLATFORM_KEYWORDS.bilibili.some(k => lower.includes(k)))
89
101
  return 'bilibili';
90
102
  return null;
91
103
  }
@@ -105,7 +117,7 @@ async function downloadVideoWithThreads(url, filename) {
105
117
  worker.on('error', reject);
106
118
  worker.on('exit', (code) => {
107
119
  if (code !== 0)
108
- reject(new Error('worker exit'));
120
+ reject(new Error('视频下载线程异常'));
109
121
  });
110
122
  });
111
123
  }
@@ -148,6 +160,7 @@ function clearAllCache() {
148
160
  catch { }
149
161
  });
150
162
  }
163
+ return true;
151
164
  }
152
165
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
153
166
  function apply(ctx, config) {
@@ -163,30 +176,37 @@ function apply(ctx, config) {
163
176
  async function parse(url) {
164
177
  const platform = getPlatformType(url);
165
178
  if (!platform)
166
- return { data: null };
179
+ return { data: null, msg: '不支持该平台链接' };
167
180
  for (let i = 0; i <= config.retryTimes; i++) {
168
181
  try {
169
182
  const res = await http.get(config.apiUrl, { params: { url } });
170
183
  const isSuccess = (res.data.code === 200 || res.data.code === 0) && res.data.data;
171
184
  if (isSuccess) {
172
- return { data: parseData(res.data.data, config.maxDescLength) };
185
+ return { data: parseData(res.data.data, config.maxDescLength), msg: '解析成功' };
186
+ }
187
+ else {
188
+ return { data: null, msg: res.data.msg || '解析失败' };
173
189
  }
174
190
  }
175
- catch (e) { }
176
- if (i < config.retryTimes)
191
+ catch (e) {
192
+ if (i === config.retryTimes)
193
+ return { data: null, msg: '接口请求超时' };
177
194
  await delay(config.retryInterval);
195
+ }
178
196
  }
179
- return { data: null };
197
+ return { data: null, msg: '解析失败' };
180
198
  }
181
199
  async function processSingleUrl(session, url) {
182
200
  const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
183
201
  const now = Date.now();
184
- if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000)
185
- return null;
202
+ if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000) {
203
+ return { data: null, msg: '请勿重复解析' };
204
+ }
186
205
  processed.set(hash, now);
187
206
  const parseResult = await parse(url);
188
- if (!parseResult.data || !parseResult.data.video)
189
- return null;
207
+ if (!parseResult.data || !parseResult.data.video) {
208
+ return { data: null, msg: parseResult.msg };
209
+ }
190
210
  let text = config.imageParseFormat
191
211
  .replace(/\${标题}/g, parseResult.data.title)
192
212
  .replace(/\${UP主}/g, parseResult.data.author)
@@ -225,9 +245,15 @@ function apply(ctx, config) {
225
245
  }
226
246
  }
227
247
  if (config.returnContent.showVideoUrl && parseResult.data.video) {
228
- contentParts.push(`🔗 无水印链接:${parseResult.data.video}`);
248
+ contentParts.push(`🔗 无水印视频:${parseResult.data.video}`);
229
249
  }
230
- return { textContent: contentParts.join('\n'), videoContent };
250
+ return {
251
+ data: {
252
+ textContent: contentParts.join('\n'),
253
+ videoContent
254
+ },
255
+ msg: '解析成功'
256
+ };
231
257
  }
232
258
  async function revokeTip(session, key) {
233
259
  if (!config.revokeWaitingTip || session.platform !== 'onebot')
@@ -236,28 +262,44 @@ function apply(ctx, config) {
236
262
  if (!buf?.tipMsgId)
237
263
  return;
238
264
  try {
239
- await session.bot.deleteMessage(session.channelId, buf.tipMsgId);
265
+ await session.bot.deleteMessage(session.channelId, buf.tipMsgId.toString());
240
266
  }
241
267
  catch { }
242
268
  }
243
- async function flush(session) {
269
+ async function sendWithTimeout(session, content, timeout) {
270
+ return Promise.race([
271
+ session.send(content),
272
+ new Promise((_, reject) => setTimeout(() => reject(new Error('发送超时')), timeout))
273
+ ]).catch(() => null);
274
+ }
275
+ async function flush(session, manualUrls) {
244
276
  const key = `${session.platform}:${session.userId}:${session.channelId}`;
245
- const buf = linkBuffer.get(key);
246
- if (!buf)
247
- return;
248
- clearTimeout(buf.timer);
249
- linkBuffer.delete(key);
250
- await revokeTip(session, key);
277
+ let buf = linkBuffer.get(key);
278
+ let urls = manualUrls || buf?.urls || [];
279
+ if (buf) {
280
+ clearTimeout(buf.timer);
281
+ linkBuffer.delete(key);
282
+ await revokeTip(session, key);
283
+ }
251
284
  const results = [];
252
- for (const u of buf.urls) {
285
+ const errorMsgs = [];
286
+ for (const u of urls) {
253
287
  const r = await processSingleUrl(session, u);
254
- if (r)
255
- results.push(r);
288
+ if (r.data) {
289
+ results.push(r.data);
290
+ }
291
+ else {
292
+ errorMsgs.push(`【${u.slice(0, 22)}...】:${r.msg}`);
293
+ }
256
294
  }
257
295
  if (results.length === 0) {
258
- await session.send('视频解析失败');
296
+ await sendWithTimeout(session, `❌ 全部解析失败\n${errorMsgs.join('\n')}`, config.videoSendTimeout);
259
297
  return;
260
298
  }
299
+ if (errorMsgs.length > 0) {
300
+ await sendWithTimeout(session, `⚠️ 部分解析失败\n${errorMsgs.join('\n')}`, config.videoSendTimeout);
301
+ await delay(600);
302
+ }
261
303
  if (config.enableForward && session.platform === 'onebot') {
262
304
  try {
263
305
  const forwardMessages = results.map(result => {
@@ -267,25 +309,31 @@ function apply(ctx, config) {
267
309
  if (result.videoContent)
268
310
  content.push(result.videoContent);
269
311
  return (0, koishi_1.h)('message', [
270
- (0, koishi_1.h)('author', { id: session.selfId, name: '解析机器人' }),
312
+ (0, koishi_1.h)('author', { id: session.selfId, name: '视频解析' }),
271
313
  ...content
272
314
  ]);
273
315
  });
274
- await session.send((0, koishi_1.h)('message', { forward: true }, forwardMessages));
316
+ await sendWithTimeout(session, (0, koishi_1.h)('message', { forward: true }, forwardMessages), config.videoSendTimeout);
275
317
  return;
276
318
  }
277
319
  catch (e) {
278
- await session.send('合并转发失败,将分开发送');
320
+ await sendWithTimeout(session, '合并转发失败,将分开发送', config.videoSendTimeout);
279
321
  }
280
322
  }
281
323
  for (const r of results) {
282
324
  try {
283
325
  if (r.textContent)
284
- await session.send(r.textContent);
285
- if (r.videoContent)
286
- await session.send(r.videoContent);
326
+ await sendWithTimeout(session, r.textContent, config.videoSendTimeout);
327
+ if (r.videoContent) {
328
+ await delay(600);
329
+ await sendWithTimeout(session, r.videoContent, config.videoSendTimeout);
330
+ }
331
+ await delay(1000);
332
+ }
333
+ catch (e) {
334
+ if (!config.ignoreSendError)
335
+ ctx.logger.error('发送失败');
287
336
  }
288
- catch { }
289
337
  }
290
338
  }
291
339
  ctx.on('message', async (session) => {
@@ -300,26 +348,59 @@ function apply(ctx, config) {
300
348
  const key = `${session.platform}:${session.userId}:${session.channelId}`;
301
349
  if (linkBuffer.has(key)) {
302
350
  const b = linkBuffer.get(key);
303
- b.urls.push(...urls);
304
- clearTimeout(b.timer);
305
- b.timer = setTimeout(() => flush(session), Math.max(0, config.messageBufferDelay * 1000));
306
- linkBuffer.set(key, b);
351
+ const newUrls = urls.filter(u => !b.urls.includes(u));
352
+ if (newUrls.length > 0) {
353
+ b.urls.push(...newUrls);
354
+ clearTimeout(b.timer);
355
+ b.timer = setTimeout(() => flush(session), config.messageBufferDelay * 1000);
356
+ linkBuffer.set(key, b);
357
+ }
307
358
  return;
308
359
  }
309
360
  let tipId;
310
361
  if (config.showWaitingTip) {
311
- const m = await session.send(config.waitingTipText).catch(() => null);
362
+ const m = await sendWithTimeout(session, config.waitingTipText, config.videoSendTimeout);
312
363
  tipId = m?.messageId || m?.id || m;
313
364
  }
314
365
  linkBuffer.set(key, {
315
366
  urls,
316
- timer: setTimeout(() => flush(session), Math.max(0, config.messageBufferDelay * 1000)),
367
+ timer: setTimeout(() => flush(session), config.messageBufferDelay * 1000),
317
368
  tipMsgId: tipId
318
369
  });
319
370
  });
371
+ ctx.command('解析视频 <url>', '手动解析视频链接')
372
+ .action(async ({ session }, url) => {
373
+ if (!url)
374
+ return '请输入视频链接';
375
+ const urls = extractUrl(url);
376
+ if (urls.length === 0)
377
+ return '不支持该链接';
378
+ await flush(session, urls);
379
+ });
380
+ ctx.command('清除解析缓存', '清空解析缓存与临时文件')
381
+ .action(() => {
382
+ clearAllCache();
383
+ return '✅ 解析缓存已清空';
384
+ });
320
385
  setInterval(() => {
321
386
  const now = Date.now();
322
387
  processed.forEach((t, h) => now - t > 86400000 && processed.delete(h));
323
388
  }, 3600000);
324
- ctx.logger.info('视频解析已加载');
389
+ setInterval(() => {
390
+ const tempDir = path_1.default.join(process.cwd(), 'temp_videos');
391
+ if (!fs_1.default.existsSync(tempDir))
392
+ return;
393
+ const files = fs_1.default.readdirSync(tempDir);
394
+ const now = Date.now();
395
+ files.forEach(file => {
396
+ try {
397
+ const st = fs_1.default.statSync(path_1.default.join(tempDir, file));
398
+ if (now - st.mtimeMs > 3600000)
399
+ fs_1.default.unlinkSync(path_1.default.join(tempDir, file));
400
+ }
401
+ catch { }
402
+ });
403
+ }, 1800000);
404
+ process.on('exit', clearAllCache);
405
+ ctx.logger.info('视频解析插件已加载');
325
406
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-video-parser-all",
3
- "description": "Koishi 视频解析插件,支持抖音/快手/B站链接解析,可自定义API和解析规则",
4
- "version": "0.2.0",
3
+ "description": "Koishi 视频解析插件,支持抖音/快手/B站链接解析",
4
+ "version": "0.2.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [