koishi-plugin-video-parser-all 1.0.1 → 1.0.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 +2 -0
- package/lib/index.js +279 -80
- package/package.json +1 -1
- package/readme.md +5 -3
package/lib/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare const Config: Schema<{
|
|
|
15
15
|
tempDir?: string | null | undefined;
|
|
16
16
|
maxVideoSize?: number | null | undefined;
|
|
17
17
|
forceDownloadVideo?: boolean | null | undefined;
|
|
18
|
+
videoLoadWaitTime?: number | null | undefined;
|
|
18
19
|
} & {
|
|
19
20
|
timeout?: number | null | undefined;
|
|
20
21
|
videoSendTimeout?: number | null | undefined;
|
|
@@ -46,6 +47,7 @@ export declare const Config: Schema<{
|
|
|
46
47
|
tempDir: string;
|
|
47
48
|
maxVideoSize: number;
|
|
48
49
|
forceDownloadVideo: boolean;
|
|
50
|
+
videoLoadWaitTime: number;
|
|
49
51
|
} & {
|
|
50
52
|
timeout: number;
|
|
51
53
|
videoSendTimeout: number;
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const promises_1 = __importDefault(require("fs/promises"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const fs_1 = require("fs");
|
|
13
13
|
const promises_2 = require("stream/promises");
|
|
14
|
+
const lru_cache_1 = require("lru-cache");
|
|
14
15
|
exports.name = 'video-parser-all';
|
|
15
16
|
exports.Config = koishi_1.Schema.intersect([
|
|
16
17
|
koishi_1.Schema.object({
|
|
@@ -25,24 +26,25 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
25
26
|
koishi_1.Schema.object({
|
|
26
27
|
showImageText: koishi_1.Schema.boolean().default(true).description('是否发送解析后的文字内容'),
|
|
27
28
|
showVideoFile: koishi_1.Schema.boolean().default(true).description('是否发送视频文件(关闭则只发送视频链接)'),
|
|
28
|
-
maxDescLength: koishi_1.Schema.number().default(200).description('简介内容最大长度(字符),超出自动截断'),
|
|
29
|
-
videoDownloadTimeout: koishi_1.Schema.number().default(120000).description('视频下载超时(毫秒)'),
|
|
29
|
+
maxDescLength: koishi_1.Schema.number().min(0).step(1).default(200).description('简介内容最大长度(字符),超出自动截断'),
|
|
30
|
+
videoDownloadTimeout: koishi_1.Schema.number().min(0).step(1).default(120000).description('视频下载超时(毫秒)'),
|
|
30
31
|
tempDir: koishi_1.Schema.string().default('./temp_videos').description('临时视频存储目录'),
|
|
31
32
|
maxVideoSize: koishi_1.Schema.number().min(0).step(1).default(0).description('最大下载视频大小(MB),0 为不限制大小'),
|
|
32
|
-
forceDownloadVideo: koishi_1.Schema.boolean().default(true).description('
|
|
33
|
+
forceDownloadVideo: koishi_1.Schema.boolean().default(true).description('强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题)'),
|
|
34
|
+
videoLoadWaitTime: koishi_1.Schema.number().min(0).step(1).default(180000).description('视频链接加载等待时间(毫秒),获取到视频链接后等待指定时间再发送,0为不等待'),
|
|
33
35
|
}).description('内容显示设置'),
|
|
34
36
|
koishi_1.Schema.object({
|
|
35
|
-
timeout: koishi_1.Schema.number().min(0).default(180000).description('API 请求超时(毫秒)'),
|
|
36
|
-
videoSendTimeout: koishi_1.Schema.number().min(0).default(60000).description('视频消息发送超时(毫秒,0 为不限制)'),
|
|
37
|
-
userAgent: koishi_1.Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36').description('API 请求 UA'),
|
|
37
|
+
timeout: koishi_1.Schema.number().min(0).step(1).default(180000).description('API 请求超时(毫秒)'),
|
|
38
|
+
videoSendTimeout: koishi_1.Schema.number().min(0).step(1).default(60000).description('视频消息发送超时(毫秒,0 为不限制)'),
|
|
39
|
+
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'),
|
|
38
40
|
}).description('网络与 API 设置'),
|
|
39
41
|
koishi_1.Schema.object({
|
|
40
42
|
ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略消息发送失败,避免插件崩溃'),
|
|
41
|
-
retryTimes: koishi_1.Schema.number().min(0).default(3).description('API 请求及消息发送失败时的重试次数'),
|
|
42
|
-
retryInterval: koishi_1.Schema.number().min(0).default(1000).description('重试间隔(毫秒,同时用于消息发送重试)'),
|
|
43
|
+
retryTimes: koishi_1.Schema.number().min(0).step(1).default(3).description('API 请求及消息发送失败时的重试次数'),
|
|
44
|
+
retryInterval: koishi_1.Schema.number().min(0).step(1).default(1000).description('重试间隔(毫秒,同时用于消息发送重试)'),
|
|
43
45
|
}).description('错误与重试设置'),
|
|
44
46
|
koishi_1.Schema.object({
|
|
45
|
-
enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅 OneBot
|
|
47
|
+
enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅 OneBot 平台)'),
|
|
46
48
|
}).description('发送方式设置'),
|
|
47
49
|
koishi_1.Schema.object({
|
|
48
50
|
waitingTipText: koishi_1.Schema.string().default('正在解析视频,请稍候...').description('解析等待提示'),
|
|
@@ -58,14 +60,24 @@ function debugLog(level, ...args) {
|
|
|
58
60
|
if (!debugEnabled)
|
|
59
61
|
return;
|
|
60
62
|
const timestamp = new Date().toISOString();
|
|
61
|
-
const message = `[${timestamp}] [${level}] ${args.map(a =>
|
|
63
|
+
const message = `[${timestamp}] [${level}] ${args.map(a => {
|
|
64
|
+
if (typeof a === 'object') {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(a, null, 2);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return String(a);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return String(a);
|
|
73
|
+
}).join(' ')}`;
|
|
62
74
|
logger.info(message);
|
|
63
75
|
}
|
|
64
76
|
function linkTypeParser(content) {
|
|
65
77
|
content = content.replace(/\\\//g, '/');
|
|
66
78
|
const rules = [
|
|
67
79
|
{ pattern: /bilibili\.com\/video\/([ab]v[0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://www.bilibili.com/video/${id}` },
|
|
68
|
-
{ pattern: /b23\.tv(
|
|
80
|
+
{ pattern: /b23\.tv\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://b23.tv/${id}` },
|
|
69
81
|
{ pattern: /bili(?:22|23|33)\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili23.cn/${id}` },
|
|
70
82
|
{ pattern: /bili2233\.cn\/([0-9a-zA-Z]+)/gi, type: 'bilibili', buildUrl: (id) => `https://bili2233.cn/${id}` },
|
|
71
83
|
{ pattern: /douyin\.com\/video\/(\d+)/gi, type: 'douyin', buildUrl: (id) => `https://www.douyin.com/video/${id}` },
|
|
@@ -108,17 +120,26 @@ function linkTypeParser(content) {
|
|
|
108
120
|
return matches;
|
|
109
121
|
}
|
|
110
122
|
function extractUrl(content) {
|
|
111
|
-
|
|
123
|
+
if (!content)
|
|
124
|
+
return [];
|
|
125
|
+
const urlMatches = content.match(/https?:\/\/[^\s<>"'(){}[\]]+/gi) || [];
|
|
112
126
|
return urlMatches.filter(url => {
|
|
113
127
|
try {
|
|
114
|
-
const
|
|
115
|
-
|
|
128
|
+
const urlObj = new URL(url);
|
|
129
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
130
|
+
if (hostname.includes('multimedia.nt.qq.com.cn') ||
|
|
131
|
+
hostname.includes('grouptalk.qq.com') ||
|
|
132
|
+
hostname.includes('qpic.cn') ||
|
|
133
|
+
hostname.includes('qlogo.cn')) {
|
|
116
134
|
return false;
|
|
135
|
+
}
|
|
117
136
|
return true;
|
|
118
137
|
}
|
|
119
138
|
catch {
|
|
120
139
|
return false;
|
|
121
140
|
}
|
|
141
|
+
}).map(url => {
|
|
142
|
+
return url.replace(/[.,;:!?)]+$/, '');
|
|
122
143
|
});
|
|
123
144
|
}
|
|
124
145
|
function extractAllUrlsFromMessage(session) {
|
|
@@ -138,10 +159,11 @@ function extractAllUrlsFromMessage(session) {
|
|
|
138
159
|
if (session.elements) {
|
|
139
160
|
for (const elem of session.elements) {
|
|
140
161
|
if (elem.type === 'xml' && elem.data) {
|
|
141
|
-
const urlRegex = /https?:\/\/[^\s<>"']+/gi;
|
|
162
|
+
const urlRegex = /https?:\/\/[^\s<>"'(){}[\]]+/gi;
|
|
142
163
|
let match;
|
|
143
164
|
while ((match = urlRegex.exec(elem.data)) !== null) {
|
|
144
|
-
|
|
165
|
+
const cleanUrl = match[0].replace(/[.,;:!?)]+$/, '');
|
|
166
|
+
urls.push(cleanUrl);
|
|
145
167
|
}
|
|
146
168
|
}
|
|
147
169
|
else if (elem.type === 'json' && elem.data) {
|
|
@@ -152,9 +174,13 @@ function extractAllUrlsFromMessage(session) {
|
|
|
152
174
|
return;
|
|
153
175
|
for (const val of Object.values(obj)) {
|
|
154
176
|
if (typeof val === 'string') {
|
|
155
|
-
const match = val.match(/https?:\/\/[^\s<>"']+/gi);
|
|
156
|
-
if (match)
|
|
157
|
-
|
|
177
|
+
const match = val.match(/https?:\/\/[^\s<>"'(){}[\]]+/gi);
|
|
178
|
+
if (match) {
|
|
179
|
+
match.forEach(url => {
|
|
180
|
+
const cleanUrl = url.replace(/[.,;:!?)]+$/, '');
|
|
181
|
+
urls.push(cleanUrl);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
158
184
|
}
|
|
159
185
|
else if (typeof val === 'object')
|
|
160
186
|
extractFromObject(val);
|
|
@@ -162,7 +188,9 @@ function extractAllUrlsFromMessage(session) {
|
|
|
162
188
|
};
|
|
163
189
|
extractFromObject(json);
|
|
164
190
|
}
|
|
165
|
-
catch {
|
|
191
|
+
catch (e) {
|
|
192
|
+
debugLog('WARN', '解析JSON卡片失败:', e);
|
|
193
|
+
}
|
|
166
194
|
}
|
|
167
195
|
}
|
|
168
196
|
}
|
|
@@ -172,14 +200,25 @@ function cleanUrl(url) {
|
|
|
172
200
|
try {
|
|
173
201
|
url = url.replace(/&/g, '&');
|
|
174
202
|
const urlObj = new URL(url);
|
|
203
|
+
if (urlObj.protocol === 'http:') {
|
|
204
|
+
urlObj.protocol = 'https:';
|
|
205
|
+
}
|
|
175
206
|
if (urlObj.hostname.includes('douyin.com') || urlObj.hostname.includes('v.douyin.com')) {
|
|
176
|
-
|
|
177
|
-
|
|
207
|
+
['source', 'share_type', 'share_token', 'timestamp', 'from', 'isappinstalled'].forEach(p => {
|
|
208
|
+
urlObj.searchParams.delete(p);
|
|
209
|
+
});
|
|
210
|
+
return urlObj.origin + urlObj.pathname;
|
|
211
|
+
}
|
|
212
|
+
if (urlObj.hostname.includes('bilibili.com') || urlObj.hostname.includes('b23.tv')) {
|
|
213
|
+
['share_source', 'share_medium', 'share_plat', 'share_session_id', 'share_tag', 'timestamp'].forEach(p => {
|
|
214
|
+
urlObj.searchParams.delete(p);
|
|
215
|
+
});
|
|
178
216
|
return urlObj.origin + urlObj.pathname;
|
|
179
217
|
}
|
|
180
|
-
return
|
|
218
|
+
return urlObj.toString();
|
|
181
219
|
}
|
|
182
220
|
catch (e) {
|
|
221
|
+
debugLog('WARN', '清理URL失败:', e, '原始URL:', url);
|
|
183
222
|
return url.replace(/&/g, '&').replace(/\?.*/, '');
|
|
184
223
|
}
|
|
185
224
|
}
|
|
@@ -198,6 +237,7 @@ async function resolveShortUrl(url) {
|
|
|
198
237
|
return cleanUrl(finalUrl);
|
|
199
238
|
}
|
|
200
239
|
catch (e) {
|
|
240
|
+
debugLog('WARN', '解析短链接失败:', e, '原始URL:', url);
|
|
201
241
|
return cleanUrl(url);
|
|
202
242
|
}
|
|
203
243
|
}
|
|
@@ -209,7 +249,7 @@ function formatDuration(seconds) {
|
|
|
209
249
|
const s = Math.floor(seconds % 60);
|
|
210
250
|
if (h > 0)
|
|
211
251
|
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
212
|
-
return `${m
|
|
252
|
+
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
213
253
|
}
|
|
214
254
|
function formatPublishTime(ms) {
|
|
215
255
|
if (!ms)
|
|
@@ -222,8 +262,13 @@ function pickBestQuality(videoBackup) {
|
|
|
222
262
|
if (!Array.isArray(videoBackup))
|
|
223
263
|
return [];
|
|
224
264
|
return videoBackup
|
|
225
|
-
.
|
|
226
|
-
.
|
|
265
|
+
.filter(v => v && v.url)
|
|
266
|
+
.map(v => ({
|
|
267
|
+
quality: v.quality || v.label || 'unknown',
|
|
268
|
+
url: v.url,
|
|
269
|
+
bit_rate: Number(v.bit_rate || 0)
|
|
270
|
+
}))
|
|
271
|
+
.sort((a, b) => b.bit_rate - a.bit_rate);
|
|
227
272
|
}
|
|
228
273
|
function parseApiResponse(raw, maxDescLen) {
|
|
229
274
|
debugLog('DEBUG', '原始API返回数据:', raw);
|
|
@@ -253,24 +298,44 @@ function parseApiResponse(raw, maxDescLen) {
|
|
|
253
298
|
avatar = data.avatar || '';
|
|
254
299
|
}
|
|
255
300
|
const title = data.title || '';
|
|
256
|
-
const desc = (data.desc || data.description || '').slice(0, maxDescLen);
|
|
301
|
+
const desc = (data.desc || data.description || '').slice(0, maxDescLen).trim();
|
|
257
302
|
const cover = data.cover || '';
|
|
258
303
|
let video = '';
|
|
259
304
|
let videos = [];
|
|
260
305
|
if (Array.isArray(data.video_backup) && data.video_backup.length) {
|
|
261
306
|
const bestQ = pickBestQuality(data.video_backup);
|
|
262
307
|
videos = bestQ;
|
|
263
|
-
video = bestQ[0]?.url ||
|
|
308
|
+
video = bestQ[0]?.url || '';
|
|
264
309
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
if (!video && Array.isArray(data.videos) && data.videos.length) {
|
|
311
|
+
const validVideos = data.videos.filter((v) => v && v.url);
|
|
312
|
+
if (validVideos.length) {
|
|
313
|
+
video = validVideos[0].url;
|
|
314
|
+
videos = validVideos.map((v) => ({
|
|
315
|
+
quality: v.accept?.[0] || 'unknown',
|
|
316
|
+
url: v.url
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
268
319
|
}
|
|
269
|
-
|
|
270
|
-
video = data.url
|
|
320
|
+
if (!video && data.url) {
|
|
321
|
+
video = data.url;
|
|
322
|
+
}
|
|
323
|
+
if (video && !video.startsWith('http')) {
|
|
324
|
+
video = 'https:' + video;
|
|
271
325
|
}
|
|
272
|
-
const images = Array.isArray(data.images)
|
|
273
|
-
|
|
326
|
+
const images = Array.isArray(data.images)
|
|
327
|
+
? data.images.filter((img) => img && typeof img === 'string').map((img) => {
|
|
328
|
+
if (!img.startsWith('http'))
|
|
329
|
+
return 'https:' + img;
|
|
330
|
+
return img;
|
|
331
|
+
})
|
|
332
|
+
: [];
|
|
333
|
+
const live_photo = Array.isArray(data.live_photo)
|
|
334
|
+
? data.live_photo.filter((lp) => lp && lp.image).map((lp) => ({
|
|
335
|
+
image: lp.image.startsWith('http') ? lp.image : 'https:' + lp.image,
|
|
336
|
+
video: lp.video ? (lp.video.startsWith('http') ? lp.video : 'https:' + lp.video) : ''
|
|
337
|
+
}))
|
|
338
|
+
: [];
|
|
274
339
|
const music = {
|
|
275
340
|
title: data.music?.title || data.music?.name || '',
|
|
276
341
|
author: data.music?.author || data.music?.artist || '',
|
|
@@ -301,6 +366,10 @@ function parseApiResponse(raw, maxDescLen) {
|
|
|
301
366
|
else if (extra.create_time) {
|
|
302
367
|
publishTime = extra.create_time * 1000;
|
|
303
368
|
}
|
|
369
|
+
debugLog('DEBUG', '解析后的数据:', {
|
|
370
|
+
type, title, author, video: video.substring(0, 100) + '...',
|
|
371
|
+
images: images.length, live_photo: live_photo.length
|
|
372
|
+
});
|
|
304
373
|
return {
|
|
305
374
|
type, title, desc, author, uid, avatar, cover,
|
|
306
375
|
video, videos, images, live_photo, music,
|
|
@@ -324,6 +393,7 @@ function generateFormattedText(p, format) {
|
|
|
324
393
|
'图片数量': String(imageCount),
|
|
325
394
|
'作者ID': p.uid,
|
|
326
395
|
'封面': p.cover,
|
|
396
|
+
'视频链接': p.video,
|
|
327
397
|
};
|
|
328
398
|
const lines = format.split('\n');
|
|
329
399
|
const resultLines = [];
|
|
@@ -359,24 +429,46 @@ function buildForwardNode(session, content, botName) {
|
|
|
359
429
|
messageContent = [content];
|
|
360
430
|
else
|
|
361
431
|
messageContent = [koishi_1.h.text(String(content))];
|
|
362
|
-
return (0, koishi_1.h)('node', {
|
|
432
|
+
return (0, koishi_1.h)('node', {
|
|
433
|
+
user: {
|
|
434
|
+
nickname: botName.substring(0, 15),
|
|
435
|
+
user_id: session.selfId
|
|
436
|
+
}
|
|
437
|
+
}, messageContent);
|
|
363
438
|
}
|
|
364
|
-
const urlCache = new
|
|
365
|
-
|
|
439
|
+
const urlCache = new lru_cache_1.LRUCache({
|
|
440
|
+
max: 500,
|
|
441
|
+
ttl: 10 * 60 * 1000,
|
|
442
|
+
updateAgeOnGet: false,
|
|
443
|
+
});
|
|
366
444
|
async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
|
|
445
|
+
if (!videoUrl)
|
|
446
|
+
throw new Error('视频链接为空');
|
|
367
447
|
await promises_1.default.mkdir(tempDir, { recursive: true });
|
|
368
448
|
const fileName = `video_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.mp4`;
|
|
369
|
-
const filePath = path_1.default.
|
|
449
|
+
const filePath = path_1.default.resolve(tempDir, fileName);
|
|
450
|
+
debugLog('INFO', `开始下载视频: ${videoUrl.substring(0, 100)}...`);
|
|
451
|
+
debugLog('INFO', `临时文件路径: ${filePath}`);
|
|
370
452
|
const writer = (0, fs_1.createWriteStream)(filePath);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
453
|
+
let response;
|
|
454
|
+
try {
|
|
455
|
+
response = await (0, axios_1.default)({
|
|
456
|
+
method: 'GET',
|
|
457
|
+
url: videoUrl,
|
|
458
|
+
responseType: 'stream',
|
|
459
|
+
timeout: timeout,
|
|
460
|
+
headers: {
|
|
461
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
462
|
+
'Referer': 'https://www.baidu.com/',
|
|
463
|
+
},
|
|
464
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
writer.destroy();
|
|
469
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
470
|
+
throw new Error(`下载视频失败: ${getErrorMessage(e)}`);
|
|
471
|
+
}
|
|
380
472
|
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
|
381
473
|
const contentLength = Number(response.headers['content-length'] || 0);
|
|
382
474
|
if (maxSizeMB > 0 && contentLength > maxSizeBytes) {
|
|
@@ -394,8 +486,15 @@ async function downloadVideoFile(videoUrl, tempDir, timeout, maxSizeMB) {
|
|
|
394
486
|
throw new Error(`视频文件过大,超过限制(${maxSizeMB}MB)`);
|
|
395
487
|
}
|
|
396
488
|
});
|
|
397
|
-
|
|
398
|
-
|
|
489
|
+
try {
|
|
490
|
+
await (0, promises_2.pipeline)(response.data, writer);
|
|
491
|
+
debugLog('INFO', `视频下载完成,大小: ${Math.round(downloadedSize / 1024 / 1024)}MB`);
|
|
492
|
+
return filePath;
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
496
|
+
throw new Error(`写入视频文件失败: ${getErrorMessage(e)}`);
|
|
497
|
+
}
|
|
399
498
|
}
|
|
400
499
|
function getErrorMessage(error) {
|
|
401
500
|
if (error instanceof Error)
|
|
@@ -411,10 +510,36 @@ function isSpecialPlatformVideo(url) {
|
|
|
411
510
|
'xhslink.com',
|
|
412
511
|
'zhihu.com',
|
|
413
512
|
'weibo.com',
|
|
414
|
-
'sinaimg.cn'
|
|
513
|
+
'sinaimg.cn',
|
|
514
|
+
'ixigua.com',
|
|
515
|
+
'toutiao.com',
|
|
415
516
|
];
|
|
416
517
|
return specialHosts.some(host => url.includes(host));
|
|
417
518
|
}
|
|
519
|
+
async function getShortUrl(url) {
|
|
520
|
+
if (!url)
|
|
521
|
+
return '';
|
|
522
|
+
try {
|
|
523
|
+
const urlObj = new URL(url);
|
|
524
|
+
if (urlObj.hostname.includes('bilibili.com')) {
|
|
525
|
+
const bvMatch = url.match(/\/video\/(bv[0-9a-zA-Z]+)/i);
|
|
526
|
+
if (bvMatch)
|
|
527
|
+
return `https://b23.tv/${bvMatch[1]}`;
|
|
528
|
+
}
|
|
529
|
+
if (urlObj.hostname.includes('douyin.com')) {
|
|
530
|
+
const idMatch = url.match(/\/video\/(\d+)/);
|
|
531
|
+
if (idMatch)
|
|
532
|
+
return `https://v.douyin.com/${idMatch[1].substring(0, 8)}/`;
|
|
533
|
+
}
|
|
534
|
+
if (urlObj.hostname.includes('xiaohongshu.com')) {
|
|
535
|
+
const idMatch = url.match(/\/item\/([0-9a-zA-Z]+)/);
|
|
536
|
+
if (idMatch)
|
|
537
|
+
return `https://xhslink.com/${idMatch[1].substring(0, 8)}`;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch { }
|
|
541
|
+
return url;
|
|
542
|
+
}
|
|
418
543
|
function apply(ctx, config) {
|
|
419
544
|
debugEnabled = config.debug || false;
|
|
420
545
|
debugLog('INFO', '插件初始化开始');
|
|
@@ -428,7 +553,7 @@ function apply(ctx, config) {
|
|
|
428
553
|
const http = axios_1.default.create({
|
|
429
554
|
timeout: config.timeout,
|
|
430
555
|
headers: {
|
|
431
|
-
'User-Agent': config.userAgent
|
|
556
|
+
'User-Agent': config.userAgent,
|
|
432
557
|
'Referer': 'https://www.baidu.com/',
|
|
433
558
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
434
559
|
}
|
|
@@ -448,13 +573,16 @@ function apply(ctx, config) {
|
|
|
448
573
|
params: { url },
|
|
449
574
|
timeout: config.timeout
|
|
450
575
|
});
|
|
451
|
-
debugLog('DEBUG', `API
|
|
576
|
+
debugLog('DEBUG', `API响应状态: ${res.status}`);
|
|
452
577
|
if (res.data && (res.data.code === 200 || res.data.code === 0)) {
|
|
453
578
|
const parsed = parseApiResponse(res.data, config.maxDescLength);
|
|
454
|
-
urlCache.set(cacheKey, {
|
|
579
|
+
urlCache.set(cacheKey, {
|
|
580
|
+
data: parsed,
|
|
581
|
+
expire: Date.now() + 10 * 60 * 1000
|
|
582
|
+
});
|
|
455
583
|
return parsed;
|
|
456
584
|
}
|
|
457
|
-
throw new Error(res.data?.msg ||
|
|
585
|
+
throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
|
|
458
586
|
}
|
|
459
587
|
catch (error) {
|
|
460
588
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -472,7 +600,10 @@ function apply(ctx, config) {
|
|
|
472
600
|
for (const candidate of [...new Set(candidates)]) {
|
|
473
601
|
try {
|
|
474
602
|
const info = await fetchApi(candidate);
|
|
475
|
-
|
|
603
|
+
if (info.video || info.images.length > 0) {
|
|
604
|
+
return { success: true, data: info };
|
|
605
|
+
}
|
|
606
|
+
debugLog('WARN', `解析成功但无有效内容: ${candidate}`);
|
|
476
607
|
}
|
|
477
608
|
catch (error) {
|
|
478
609
|
debugLog('ERROR', `候选链接解析失败: ${candidate}`, getErrorMessage(error));
|
|
@@ -482,10 +613,19 @@ function apply(ctx, config) {
|
|
|
482
613
|
}
|
|
483
614
|
async function processSingleUrl(url) {
|
|
484
615
|
const result = await parseUrl(url);
|
|
485
|
-
if (!result.success)
|
|
486
|
-
return result;
|
|
616
|
+
if (!result.success) {
|
|
617
|
+
return { success: false, msg: result.msg, url };
|
|
618
|
+
}
|
|
487
619
|
const text = generateFormattedText(result.data, config.unifiedMessageFormat);
|
|
488
|
-
|
|
620
|
+
const shortUrl = await getShortUrl(result.data.video);
|
|
621
|
+
return {
|
|
622
|
+
success: true,
|
|
623
|
+
data: {
|
|
624
|
+
text,
|
|
625
|
+
parsed: result.data,
|
|
626
|
+
shortUrl
|
|
627
|
+
}
|
|
628
|
+
};
|
|
489
629
|
}
|
|
490
630
|
async function sendWithTimeout(session, content, customRetries) {
|
|
491
631
|
const maxRetries = customRetries ?? config.retryTimes ?? 3;
|
|
@@ -525,9 +665,18 @@ function apply(ctx, config) {
|
|
|
525
665
|
}
|
|
526
666
|
return null;
|
|
527
667
|
}
|
|
528
|
-
async function sendVideoFile(session, videoUrl) {
|
|
668
|
+
async function sendVideoFile(session, videoUrl, shortUrl) {
|
|
529
669
|
if (!videoUrl)
|
|
530
670
|
throw new Error('视频链接为空');
|
|
671
|
+
if (config.videoLoadWaitTime > 0) {
|
|
672
|
+
await delay(config.videoLoadWaitTime);
|
|
673
|
+
}
|
|
674
|
+
if (shortUrl) {
|
|
675
|
+
await sendWithTimeout(session, `视频链接:${shortUrl}`).catch(() => { });
|
|
676
|
+
}
|
|
677
|
+
if (!config.showVideoFile) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
531
680
|
const shouldForceDownload = config.forceDownloadVideo || isSpecialPlatformVideo(videoUrl);
|
|
532
681
|
if (!shouldForceDownload) {
|
|
533
682
|
try {
|
|
@@ -544,8 +693,8 @@ function apply(ctx, config) {
|
|
|
544
693
|
let tempFilePath = null;
|
|
545
694
|
try {
|
|
546
695
|
tempFilePath = await downloadVideoFile(videoUrl, config.tempDir || './temp_videos', config.videoDownloadTimeout || 120000, config.maxVideoSize || 0);
|
|
547
|
-
const localFile = `file://${
|
|
548
|
-
debugLog('INFO',
|
|
696
|
+
const localFile = `file://${tempFilePath}`;
|
|
697
|
+
debugLog('INFO', `发送本地视频文件: ${localFile}`);
|
|
549
698
|
return await sendWithTimeout(session, koishi_1.h.video(localFile));
|
|
550
699
|
}
|
|
551
700
|
finally {
|
|
@@ -556,6 +705,7 @@ function apply(ctx, config) {
|
|
|
556
705
|
}
|
|
557
706
|
async function flush(session, urls) {
|
|
558
707
|
const uniqueUrls = [...new Set(urls)];
|
|
708
|
+
debugLog('INFO', `开始解析 ${uniqueUrls.length} 个链接`);
|
|
559
709
|
const items = [];
|
|
560
710
|
const errors = [];
|
|
561
711
|
const concurrency = 3;
|
|
@@ -583,16 +733,19 @@ function apply(ctx, config) {
|
|
|
583
733
|
await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
|
|
584
734
|
await delay(500);
|
|
585
735
|
}
|
|
586
|
-
if (!items.length)
|
|
736
|
+
if (!items.length) {
|
|
737
|
+
debugLog('INFO', '没有成功解析的内容');
|
|
587
738
|
return;
|
|
739
|
+
}
|
|
588
740
|
const enableForward = config.enableForward && session.platform === 'onebot';
|
|
589
741
|
const botName = config.botName || '视频解析机器人';
|
|
590
|
-
const videoItems = [];
|
|
591
742
|
if (enableForward) {
|
|
592
743
|
const forwardMessages = [];
|
|
744
|
+
const videoItems = [];
|
|
593
745
|
for (const item of items) {
|
|
594
746
|
const p = item.parsed;
|
|
595
747
|
const text = item.text;
|
|
748
|
+
const shortUrl = item.shortUrl;
|
|
596
749
|
if (text && config.showImageText) {
|
|
597
750
|
forwardMessages.push(buildForwardNode(session, text, botName));
|
|
598
751
|
}
|
|
@@ -606,12 +759,16 @@ function apply(ctx, config) {
|
|
|
606
759
|
}
|
|
607
760
|
}
|
|
608
761
|
if (p.video && config.showVideoFile && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
|
|
609
|
-
videoItems.push(p);
|
|
762
|
+
videoItems.push({ parsed: p, shortUrl });
|
|
763
|
+
if (shortUrl) {
|
|
764
|
+
forwardMessages.push(buildForwardNode(session, `视频链接:${shortUrl}`, botName));
|
|
765
|
+
}
|
|
610
766
|
}
|
|
611
767
|
}
|
|
612
768
|
if (forwardMessages.length) {
|
|
613
769
|
const forwardMsg = (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100));
|
|
614
770
|
try {
|
|
771
|
+
debugLog('INFO', `发送合并转发消息,包含 ${forwardMessages.length} 条内容`);
|
|
615
772
|
await sendWithTimeout(session, forwardMsg, config.retryTimes);
|
|
616
773
|
}
|
|
617
774
|
catch (err) {
|
|
@@ -622,13 +779,12 @@ function apply(ctx, config) {
|
|
|
622
779
|
}
|
|
623
780
|
}
|
|
624
781
|
}
|
|
625
|
-
for (const
|
|
782
|
+
for (const item of videoItems) {
|
|
626
783
|
try {
|
|
627
|
-
await sendVideoFile(session,
|
|
784
|
+
await sendVideoFile(session, item.parsed.video, item.shortUrl);
|
|
628
785
|
}
|
|
629
786
|
catch (err) {
|
|
630
|
-
debugLog('ERROR',
|
|
631
|
-
await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
|
|
787
|
+
debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
|
|
632
788
|
}
|
|
633
789
|
await delay(500);
|
|
634
790
|
}
|
|
@@ -637,6 +793,7 @@ function apply(ctx, config) {
|
|
|
637
793
|
for (const item of items) {
|
|
638
794
|
const p = item.parsed;
|
|
639
795
|
const text = item.text;
|
|
796
|
+
const shortUrl = item.shortUrl;
|
|
640
797
|
if (text && config.showImageText) {
|
|
641
798
|
await sendWithTimeout(session, text);
|
|
642
799
|
await delay(300);
|
|
@@ -645,13 +802,12 @@ function apply(ctx, config) {
|
|
|
645
802
|
await sendWithTimeout(session, koishi_1.h.image(p.cover)).catch(() => { });
|
|
646
803
|
await delay(300);
|
|
647
804
|
}
|
|
648
|
-
if (p.video &&
|
|
805
|
+
if (p.video && (p.type === 'video' || (p.type === 'live' && !p.live_photo?.length && !p.images?.length))) {
|
|
649
806
|
try {
|
|
650
|
-
await sendVideoFile(session, p.video);
|
|
807
|
+
await sendVideoFile(session, p.video, shortUrl);
|
|
651
808
|
}
|
|
652
809
|
catch (err) {
|
|
653
|
-
debugLog('ERROR',
|
|
654
|
-
await sendWithTimeout(session, `视频链接:${p.video}`).catch(() => { });
|
|
810
|
+
debugLog('ERROR', `视频发送失败: ${getErrorMessage(err)}`);
|
|
655
811
|
}
|
|
656
812
|
await delay(500);
|
|
657
813
|
}
|
|
@@ -664,36 +820,78 @@ function apply(ctx, config) {
|
|
|
664
820
|
}
|
|
665
821
|
}
|
|
666
822
|
}
|
|
823
|
+
debugLog('INFO', '所有内容处理完成');
|
|
667
824
|
}
|
|
668
825
|
ctx.on('message', async (session) => {
|
|
669
826
|
if (!config.enable)
|
|
670
827
|
return;
|
|
828
|
+
if (session.subtype === 'file_upload')
|
|
829
|
+
return;
|
|
830
|
+
if (session.elements?.some(elem => elem.type === 'file' || elem.type === 'folder'))
|
|
831
|
+
return;
|
|
832
|
+
if (session.selfId === session.userId)
|
|
833
|
+
return;
|
|
671
834
|
const urls = extractAllUrlsFromMessage(session);
|
|
672
835
|
if (!urls.length)
|
|
673
836
|
return;
|
|
837
|
+
debugLog('INFO', `检测到 ${urls.length} 个链接,开始处理`);
|
|
674
838
|
if (config.showWaitingTip) {
|
|
675
839
|
try {
|
|
676
840
|
await sendWithTimeout(session, texts.waitingTipText);
|
|
677
841
|
}
|
|
678
|
-
catch {
|
|
842
|
+
catch (e) {
|
|
843
|
+
debugLog('WARN', '发送等待提示失败:', e);
|
|
844
|
+
}
|
|
679
845
|
}
|
|
680
846
|
await flush(session, urls);
|
|
681
847
|
});
|
|
682
848
|
ctx.command('parse <url>', '手动解析视频').action(async ({ session }, url) => {
|
|
849
|
+
if (!url) {
|
|
850
|
+
await sendWithTimeout(session, texts.invalidLinkText);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
683
853
|
const us = extractUrl(url);
|
|
684
854
|
if (!us.length) {
|
|
685
855
|
await sendWithTimeout(session, texts.invalidLinkText);
|
|
686
856
|
return;
|
|
687
857
|
}
|
|
858
|
+
if (config.showWaitingTip) {
|
|
859
|
+
try {
|
|
860
|
+
await sendWithTimeout(session, texts.waitingTipText);
|
|
861
|
+
}
|
|
862
|
+
catch { }
|
|
863
|
+
}
|
|
688
864
|
await flush(session, us);
|
|
689
865
|
});
|
|
690
|
-
setInterval(() => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
866
|
+
const tempCleanupInterval = setInterval(async () => {
|
|
867
|
+
try {
|
|
868
|
+
const tempDir = config.tempDir || './temp_videos';
|
|
869
|
+
const files = await promises_1.default.readdir(tempDir);
|
|
870
|
+
const now = Date.now();
|
|
871
|
+
let deletedCount = 0;
|
|
872
|
+
for (const file of files) {
|
|
873
|
+
if (file.startsWith('video_') && file.endsWith('.mp4')) {
|
|
874
|
+
const filePath = path_1.default.join(tempDir, file);
|
|
875
|
+
const stats = await promises_1.default.stat(filePath);
|
|
876
|
+
if (now - stats.mtimeMs > 3600000) {
|
|
877
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
878
|
+
deletedCount++;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (deletedCount > 0) {
|
|
883
|
+
debugLog('INFO', `清理了 ${deletedCount} 个过期临时视频文件`);
|
|
884
|
+
}
|
|
695
885
|
}
|
|
696
|
-
|
|
886
|
+
catch (e) {
|
|
887
|
+
debugLog('WARN', '清理临时文件失败:', e);
|
|
888
|
+
}
|
|
889
|
+
}, 3600000);
|
|
890
|
+
ctx.on('dispose', () => {
|
|
891
|
+
clearInterval(tempCleanupInterval);
|
|
892
|
+
urlCache.clear();
|
|
893
|
+
debugLog('INFO', '插件已卸载,资源已清理');
|
|
894
|
+
});
|
|
697
895
|
process.on('exit', async () => {
|
|
698
896
|
try {
|
|
699
897
|
const tempDir = config.tempDir || './temp_videos';
|
|
@@ -703,6 +901,7 @@ function apply(ctx, config) {
|
|
|
703
901
|
await promises_1.default.unlink(path_1.default.join(tempDir, file)).catch(() => { });
|
|
704
902
|
}
|
|
705
903
|
}
|
|
904
|
+
debugLog('INFO', '进程退出,已清理所有临时视频文件');
|
|
706
905
|
}
|
|
707
906
|
catch { }
|
|
708
907
|
});
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- 📤 支持OneBot平台消息合并转发,优化多图文展示体验
|
|
12
12
|
- 💬 所有提示文案均可自定义,适配多语言场景
|
|
13
13
|
- 🔁 消息发送支持自动重试,与API重试配置联动,增强稳定性
|
|
14
|
-
- 🚀
|
|
14
|
+
- 🚀 内置LRU内存缓存,避免短时间内重复解析同一链接;并发控制,防止资源耗尽
|
|
15
15
|
- ⚡ 智能视频发送策略:普通平台优先直接发送URL,特殊平台自动降级为本地文件发送
|
|
16
16
|
- 🛡️ 可选视频大小限制,防止超大文件占满服务器磁盘;自动清理所有临时文件
|
|
17
17
|
|
|
@@ -24,7 +24,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
|
|
|
24
24
|
- 📤 Support OneBot message forwarding for better image/video display
|
|
25
25
|
- 💬 All prompt texts are customizable for multilingual scenarios
|
|
26
26
|
- 🔁 Message sending supports automatic retries, linked with API retry configuration for improved stability
|
|
27
|
-
- 🚀 Built-in memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
|
|
27
|
+
- 🚀 Built-in LRU memory cache to avoid repeated parsing of the same URL; concurrency control to prevent resource exhaustion
|
|
28
28
|
- ⚡ Smart video sending strategy: priority to send URL directly for common platforms, auto downgrade to local file for special platforms
|
|
29
29
|
- 🛡️ Optional video size limit to prevent oversized files from filling up server disk; automatic cleanup of all temporary files
|
|
30
30
|
|
|
@@ -63,13 +63,14 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
|
|
|
63
63
|
| `tempDir` | string | `./temp_videos` | 临时视频存储目录 |
|
|
64
64
|
| `maxVideoSize` | number | 0 | 最大下载视频大小(MB),0 为不限制大小 |
|
|
65
65
|
| `forceDownloadVideo` | boolean | true | 强制下载视频后发送(解决B站、小红书等平台URL无法直接发送的问题) |
|
|
66
|
+
| `videoLoadWaitTime` | number | 180000 | 视频链接加载等待时间(毫秒),获取到视频链接后等待指定时间再发送,0为不等待 |
|
|
66
67
|
|
|
67
68
|
### 网络与 API 设置
|
|
68
69
|
| 配置项 | 类型 | 默认值 | 说明 |
|
|
69
70
|
|--------|------|--------|------|
|
|
70
71
|
| `timeout` | number | 180000 | API 请求超时时间(毫秒) |
|
|
71
72
|
| `videoSendTimeout` | number | 60000 | 视频消息发送超时时间(毫秒,0 为不限制) |
|
|
72
|
-
| `userAgent` | string | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36` | API 请求使用的 User-Agent |
|
|
73
|
+
| `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 |
|
|
73
74
|
|
|
74
75
|
### 错误与重试设置
|
|
75
76
|
| 配置项 | 类型 | 默认值 | 说明 |
|
|
@@ -110,6 +111,7 @@ This is a **multi-platform video/image parsing plugin** developed for the Koishi
|
|
|
110
111
|
| `${图片数量}` | 图集/实况图片数量 | 图集/实况 |
|
|
111
112
|
| `${作者ID}` | 作者唯一标识ID | 部分平台 |
|
|
112
113
|
| `${封面}` | 封面图片地址 | 所有平台 |
|
|
114
|
+
| `${视频链接}` | 视频原始链接 | 视频 |
|
|
113
115
|
|
|
114
116
|
> 注:部分变量可能因平台API返回数据不同而显示为空,某行所有变量为空(或为"0")时该行会自动隐藏。
|
|
115
117
|
|