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 +1 -0
- package/lib/index.js +147 -66
- package/package.json +2 -2
package/lib/index.d.ts
CHANGED
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
imageParseFormat: koishi_1.Schema.string().role('textarea').default('${标题}
|
|
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
|
|
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
|
|
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
|
|
96
|
+
if (PLATFORM_KEYWORDS.douyin.some(k => lower.includes(k)))
|
|
85
97
|
return 'douyin';
|
|
86
|
-
if (PLATFORM_KEYWORDS.kuaishou.some(k => lower.includes(k
|
|
98
|
+
if (PLATFORM_KEYWORDS.kuaishou.some(k => lower.includes(k)))
|
|
87
99
|
return 'kuaishou';
|
|
88
|
-
if (PLATFORM_KEYWORDS.bilibili.some(k => lower.includes(k
|
|
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('
|
|
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
|
-
|
|
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(`🔗
|
|
248
|
+
contentParts.push(`🔗 无水印视频:${parseResult.data.video}`);
|
|
229
249
|
}
|
|
230
|
-
return {
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
285
|
-
if (r.videoContent)
|
|
286
|
-
await
|
|
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.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
|
4
|
-
"version": "0.2.
|
|
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": [
|