koishi-plugin-video-parser-all 0.5.2 → 0.5.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 +28 -0
- package/lib/index.js +281 -90
- package/package.json +1 -1
- package/readme.md +0 -4
package/lib/index.d.ts
CHANGED
|
@@ -61,4 +61,32 @@ export declare const Config: Schema<{
|
|
|
61
61
|
} & {
|
|
62
62
|
autoClearCacheInterval: number;
|
|
63
63
|
}>;
|
|
64
|
+
export declare enum ErrorCode {
|
|
65
|
+
SUCCESS = 0,
|
|
66
|
+
UNKNOWN_ERROR = 1000,
|
|
67
|
+
UNSUPPORTED_PLATFORM = 1001,
|
|
68
|
+
PLATFORM_API_NOT_CONFIGURED = 1002,
|
|
69
|
+
REQUEST_TIMEOUT = 1003,
|
|
70
|
+
NETWORK_ERROR = 1004,
|
|
71
|
+
DUPLICATE_PARSE = 1005,
|
|
72
|
+
INVALID_URL = 1006,
|
|
73
|
+
API_RETURN_ERROR = 2000,
|
|
74
|
+
API_DATA_PARSE_FAILED = 2001,
|
|
75
|
+
API_EMPTY_RESPONSE = 2002,
|
|
76
|
+
API_INVALID_RESPONSE = 2003,
|
|
77
|
+
VIDEO_DOWNLOAD_FAILED = 3000,
|
|
78
|
+
VIDEO_SIZE_EXCEEDED = 3001,
|
|
79
|
+
UNSUPPORTED_CONTENT_TYPE = 3002,
|
|
80
|
+
NO_VIDEO_FOUND = 3003,
|
|
81
|
+
NO_IMAGE_FOUND = 3004,
|
|
82
|
+
MESSAGE_SEND_FAILED = 4000,
|
|
83
|
+
MESSAGE_SEND_TIMEOUT = 4001,
|
|
84
|
+
FORWARD_MESSAGE_FAILED = 4002,
|
|
85
|
+
DOUYIN_PARSE_FAILED = 5001,
|
|
86
|
+
XIAOHONGSHU_PARSE_FAILED = 5002,
|
|
87
|
+
BILIBILI_PARSE_FAILED = 5003,
|
|
88
|
+
KUAISHOU_PARSE_FAILED = 5004,
|
|
89
|
+
WEIBO_PARSE_FAILED = 5005
|
|
90
|
+
}
|
|
91
|
+
export declare const ErrorMessageMap: Record<ErrorCode, string>;
|
|
64
92
|
export declare function apply(ctx: Context, config: any): void;
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Config = exports.name = void 0;
|
|
6
|
+
exports.ErrorMessageMap = exports.ErrorCode = exports.Config = exports.name = void 0;
|
|
7
7
|
exports.apply = apply;
|
|
8
8
|
const koishi_1 = require("koishi");
|
|
9
9
|
const axios_1 = __importDefault(require("axios"));
|
|
@@ -22,7 +22,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
22
22
|
sameLinkInterval: koishi_1.Schema.number().min(0).default(180).description('相同链接重复解析间隔(秒)'),
|
|
23
23
|
}).description('基础设置'),
|
|
24
24
|
koishi_1.Schema.object({
|
|
25
|
-
unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default('标题:${标题}\n作者:${作者}\n简介:${简介}\n时长:${视频时长}\n点赞:${点赞数}\n投币:${投币数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n音乐:${音乐名}').description('
|
|
25
|
+
unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default('标题:${标题}\n作者:${作者}\n简介:${简介}\n时长:${视频时长}\n点赞:${点赞数}\n投币:${投币数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n音乐:${音乐名}').description('统一消息格式(无法获取的变量会自动隐藏)\n变量介绍:\n${标题} - 内容标题\n${作者} - 作者名称\n${简介} - 内容简介\n${视频时长} - 视频时长\n${点赞数} - 点赞数量\n${投币数} - 投币数量(仅B站)\n${收藏数} - 收藏数量\n${转发数} - 转发/分享数量\n${播放数} - 播放数量\n${评论数} - 评论数量\n${音乐名} - 背景音乐名称'),
|
|
26
26
|
}).description('统一消息格式'),
|
|
27
27
|
koishi_1.Schema.object({
|
|
28
28
|
showImageText: koishi_1.Schema.boolean().default(true).description('显示图文内容'),
|
|
@@ -43,17 +43,72 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
43
43
|
}).description('错误与重试设置'),
|
|
44
44
|
koishi_1.Schema.object({
|
|
45
45
|
enableForward: koishi_1.Schema.boolean().default(false).description('启用合并转发(仅OneBot平台)'),
|
|
46
|
-
downloadVideoBeforeSend: koishi_1.Schema.boolean().default(false).description('
|
|
46
|
+
downloadVideoBeforeSend: koishi_1.Schema.boolean().default(false).description('发送前先下载视频'),
|
|
47
47
|
maxVideoSize: koishi_1.Schema.number().min(0).default(0).description('最大视频大小限制(MB,0为不限制)'),
|
|
48
48
|
downloadThreads: koishi_1.Schema.number().min(0).max(10).default(0).description('多线程下载线程数(0为不使用多线程,1-10为启用对应线程数)'),
|
|
49
|
-
}).description('
|
|
49
|
+
}).description('发送方式设置'),
|
|
50
50
|
koishi_1.Schema.object({
|
|
51
51
|
messageBufferDelay: koishi_1.Schema.number().min(0).default(0).description('消息缓冲延迟(毫秒,批量处理链接)'),
|
|
52
52
|
}).description('消息处理设置'),
|
|
53
53
|
koishi_1.Schema.object({
|
|
54
54
|
autoClearCacheInterval: koishi_1.Schema.number().min(0).default(0).description('自动清理缓存间隔(分钟,0为关闭)'),
|
|
55
|
-
}).description('
|
|
55
|
+
}).description('缓存清理设置'),
|
|
56
56
|
]);
|
|
57
|
+
var ErrorCode;
|
|
58
|
+
(function (ErrorCode) {
|
|
59
|
+
ErrorCode[ErrorCode["SUCCESS"] = 0] = "SUCCESS";
|
|
60
|
+
ErrorCode[ErrorCode["UNKNOWN_ERROR"] = 1000] = "UNKNOWN_ERROR";
|
|
61
|
+
ErrorCode[ErrorCode["UNSUPPORTED_PLATFORM"] = 1001] = "UNSUPPORTED_PLATFORM";
|
|
62
|
+
ErrorCode[ErrorCode["PLATFORM_API_NOT_CONFIGURED"] = 1002] = "PLATFORM_API_NOT_CONFIGURED";
|
|
63
|
+
ErrorCode[ErrorCode["REQUEST_TIMEOUT"] = 1003] = "REQUEST_TIMEOUT";
|
|
64
|
+
ErrorCode[ErrorCode["NETWORK_ERROR"] = 1004] = "NETWORK_ERROR";
|
|
65
|
+
ErrorCode[ErrorCode["DUPLICATE_PARSE"] = 1005] = "DUPLICATE_PARSE";
|
|
66
|
+
ErrorCode[ErrorCode["INVALID_URL"] = 1006] = "INVALID_URL";
|
|
67
|
+
ErrorCode[ErrorCode["API_RETURN_ERROR"] = 2000] = "API_RETURN_ERROR";
|
|
68
|
+
ErrorCode[ErrorCode["API_DATA_PARSE_FAILED"] = 2001] = "API_DATA_PARSE_FAILED";
|
|
69
|
+
ErrorCode[ErrorCode["API_EMPTY_RESPONSE"] = 2002] = "API_EMPTY_RESPONSE";
|
|
70
|
+
ErrorCode[ErrorCode["API_INVALID_RESPONSE"] = 2003] = "API_INVALID_RESPONSE";
|
|
71
|
+
ErrorCode[ErrorCode["VIDEO_DOWNLOAD_FAILED"] = 3000] = "VIDEO_DOWNLOAD_FAILED";
|
|
72
|
+
ErrorCode[ErrorCode["VIDEO_SIZE_EXCEEDED"] = 3001] = "VIDEO_SIZE_EXCEEDED";
|
|
73
|
+
ErrorCode[ErrorCode["UNSUPPORTED_CONTENT_TYPE"] = 3002] = "UNSUPPORTED_CONTENT_TYPE";
|
|
74
|
+
ErrorCode[ErrorCode["NO_VIDEO_FOUND"] = 3003] = "NO_VIDEO_FOUND";
|
|
75
|
+
ErrorCode[ErrorCode["NO_IMAGE_FOUND"] = 3004] = "NO_IMAGE_FOUND";
|
|
76
|
+
ErrorCode[ErrorCode["MESSAGE_SEND_FAILED"] = 4000] = "MESSAGE_SEND_FAILED";
|
|
77
|
+
ErrorCode[ErrorCode["MESSAGE_SEND_TIMEOUT"] = 4001] = "MESSAGE_SEND_TIMEOUT";
|
|
78
|
+
ErrorCode[ErrorCode["FORWARD_MESSAGE_FAILED"] = 4002] = "FORWARD_MESSAGE_FAILED";
|
|
79
|
+
ErrorCode[ErrorCode["DOUYIN_PARSE_FAILED"] = 5001] = "DOUYIN_PARSE_FAILED";
|
|
80
|
+
ErrorCode[ErrorCode["XIAOHONGSHU_PARSE_FAILED"] = 5002] = "XIAOHONGSHU_PARSE_FAILED";
|
|
81
|
+
ErrorCode[ErrorCode["BILIBILI_PARSE_FAILED"] = 5003] = "BILIBILI_PARSE_FAILED";
|
|
82
|
+
ErrorCode[ErrorCode["KUAISHOU_PARSE_FAILED"] = 5004] = "KUAISHOU_PARSE_FAILED";
|
|
83
|
+
ErrorCode[ErrorCode["WEIBO_PARSE_FAILED"] = 5005] = "WEIBO_PARSE_FAILED";
|
|
84
|
+
})(ErrorCode || (exports.ErrorCode = ErrorCode = {}));
|
|
85
|
+
exports.ErrorMessageMap = {
|
|
86
|
+
[ErrorCode.SUCCESS]: '操作成功',
|
|
87
|
+
[ErrorCode.UNKNOWN_ERROR]: '未知错误',
|
|
88
|
+
[ErrorCode.UNSUPPORTED_PLATFORM]: '不支持该平台链接',
|
|
89
|
+
[ErrorCode.PLATFORM_API_NOT_CONFIGURED]: '该平台暂未配置解析接口',
|
|
90
|
+
[ErrorCode.REQUEST_TIMEOUT]: '请求超时',
|
|
91
|
+
[ErrorCode.NETWORK_ERROR]: '网络请求失败',
|
|
92
|
+
[ErrorCode.DUPLICATE_PARSE]: '请勿重复解析(相同链接解析间隔未到)',
|
|
93
|
+
[ErrorCode.INVALID_URL]: '无效的链接格式',
|
|
94
|
+
[ErrorCode.API_RETURN_ERROR]: 'API返回错误',
|
|
95
|
+
[ErrorCode.API_DATA_PARSE_FAILED]: '数据解析异常',
|
|
96
|
+
[ErrorCode.API_EMPTY_RESPONSE]: 'API返回空数据',
|
|
97
|
+
[ErrorCode.API_INVALID_RESPONSE]: 'API返回无效格式',
|
|
98
|
+
[ErrorCode.VIDEO_DOWNLOAD_FAILED]: '视频下载失败',
|
|
99
|
+
[ErrorCode.VIDEO_SIZE_EXCEEDED]: '视频大小超过限制',
|
|
100
|
+
[ErrorCode.UNSUPPORTED_CONTENT_TYPE]: '不支持的内容类型',
|
|
101
|
+
[ErrorCode.NO_VIDEO_FOUND]: '未找到视频内容',
|
|
102
|
+
[ErrorCode.NO_IMAGE_FOUND]: '未找到图片内容',
|
|
103
|
+
[ErrorCode.MESSAGE_SEND_FAILED]: '消息发送失败',
|
|
104
|
+
[ErrorCode.MESSAGE_SEND_TIMEOUT]: '消息发送超时',
|
|
105
|
+
[ErrorCode.FORWARD_MESSAGE_FAILED]: '合并转发失败',
|
|
106
|
+
[ErrorCode.DOUYIN_PARSE_FAILED]: '抖音链接解析失败',
|
|
107
|
+
[ErrorCode.XIAOHONGSHU_PARSE_FAILED]: '小红书链接解析失败',
|
|
108
|
+
[ErrorCode.BILIBILI_PARSE_FAILED]: 'B站链接解析失败',
|
|
109
|
+
[ErrorCode.KUAISHOU_PARSE_FAILED]: '快手链接解析失败',
|
|
110
|
+
[ErrorCode.WEIBO_PARSE_FAILED]: '微博链接解析失败',
|
|
111
|
+
};
|
|
57
112
|
const processed = new Map();
|
|
58
113
|
const linkBuffer = new Map();
|
|
59
114
|
const logger = new koishi_1.Logger(exports.name);
|
|
@@ -79,9 +134,16 @@ const API_CONFIG = {
|
|
|
79
134
|
pipixia: 'https://api.bugpk.com/api/ppx',
|
|
80
135
|
zuiyou: 'https://api.bugpk.com/api/zuiyou'
|
|
81
136
|
};
|
|
137
|
+
const PLATFORM_ERROR_CODE_MAP = {
|
|
138
|
+
douyin: ErrorCode.DOUYIN_PARSE_FAILED,
|
|
139
|
+
xiaohongshu: ErrorCode.XIAOHONGSHU_PARSE_FAILED,
|
|
140
|
+
bilibili: ErrorCode.BILIBILI_PARSE_FAILED,
|
|
141
|
+
kuaishou: ErrorCode.KUAISHOU_PARSE_FAILED,
|
|
142
|
+
weibo: ErrorCode.WEIBO_PARSE_FAILED
|
|
143
|
+
};
|
|
82
144
|
const VARIABLE_MAPPING = {
|
|
83
145
|
'标题': ['title', 'Title', 'TITLE'],
|
|
84
|
-
'作者': ['author', 'name', 'Author', 'Name'],
|
|
146
|
+
'作者': ['author.name', 'author', 'name', 'Author', 'Name'],
|
|
85
147
|
'简介': ['desc', 'description', 'Desc', 'Description', 'content', 'Content'],
|
|
86
148
|
'视频时长': ['duration', 'Duration', 'time', 'Time'],
|
|
87
149
|
'点赞数': ['like', 'Like', 'attitudes_count', 'digg_count', 'praise'],
|
|
@@ -92,6 +154,10 @@ const VARIABLE_MAPPING = {
|
|
|
92
154
|
'评论数': ['comment', 'Comment', 'comments_count', 'comment_count', 'discuss'],
|
|
93
155
|
'音乐名': ['music.title', 'music_name', 'audio_name', 'sound_name']
|
|
94
156
|
};
|
|
157
|
+
function getErrorInfo(code, detail) {
|
|
158
|
+
const baseMsg = exports.ErrorMessageMap[code] || exports.ErrorMessageMap[ErrorCode.UNKNOWN_ERROR];
|
|
159
|
+
return detail ? `[错误码: ${code}] ${baseMsg}:${detail}` : `[错误码: ${code}] ${baseMsg}`;
|
|
160
|
+
}
|
|
95
161
|
function getErrorMessage(error) {
|
|
96
162
|
if (error instanceof Error)
|
|
97
163
|
return error.message;
|
|
@@ -156,11 +222,11 @@ async function downloadVideo(url, filename, userAgent, maxSize, threads) {
|
|
|
156
222
|
const filePath = path_1.default.join(dir, `${filename}.mp4`);
|
|
157
223
|
try {
|
|
158
224
|
if (url.endsWith('.m4a') || url.endsWith('.mp3')) {
|
|
159
|
-
|
|
225
|
+
return { filePath: '', code: ErrorCode.UNSUPPORTED_CONTENT_TYPE };
|
|
160
226
|
}
|
|
161
227
|
const fileSize = await getFileSize(url, userAgent);
|
|
162
228
|
if (maxSize > 0 && fileSize > maxSize) {
|
|
163
|
-
|
|
229
|
+
return { filePath: '', code: ErrorCode.VIDEO_SIZE_EXCEEDED };
|
|
164
230
|
}
|
|
165
231
|
if (threads <= 0 || fileSize === 0) {
|
|
166
232
|
const response = await (0, axios_1.default)({
|
|
@@ -174,7 +240,7 @@ async function downloadVideo(url, filename, userAgent, maxSize, threads) {
|
|
|
174
240
|
});
|
|
175
241
|
const writeStream = fs_1.default.createWriteStream(filePath);
|
|
176
242
|
await (0, promises_1.pipeline)(response.data, writeStream);
|
|
177
|
-
return filePath;
|
|
243
|
+
return { filePath, code: ErrorCode.SUCCESS };
|
|
178
244
|
}
|
|
179
245
|
const totalSize = fileSize * 1024 * 1024;
|
|
180
246
|
const chunkSize = Math.ceil(totalSize / threads);
|
|
@@ -200,7 +266,7 @@ async function downloadVideo(url, filename, userAgent, maxSize, threads) {
|
|
|
200
266
|
fs_1.default.unlinkSync(result.filePath);
|
|
201
267
|
}
|
|
202
268
|
writeStream.end();
|
|
203
|
-
return filePath;
|
|
269
|
+
return { filePath, code: ErrorCode.SUCCESS };
|
|
204
270
|
}
|
|
205
271
|
catch (error) {
|
|
206
272
|
if (fs_1.default.existsSync(filePath)) {
|
|
@@ -213,7 +279,7 @@ async function downloadVideo(url, filename, userAgent, maxSize, threads) {
|
|
|
213
279
|
}
|
|
214
280
|
catch (e) { }
|
|
215
281
|
});
|
|
216
|
-
|
|
282
|
+
return { filePath: '', code: ErrorCode.VIDEO_DOWNLOAD_FAILED };
|
|
217
283
|
}
|
|
218
284
|
}
|
|
219
285
|
function extractUrl(content) {
|
|
@@ -328,9 +394,9 @@ function parseData(rawResponse, maxDescLength, platform) {
|
|
|
328
394
|
stat[varName] = value;
|
|
329
395
|
}
|
|
330
396
|
});
|
|
331
|
-
let type = 'video';
|
|
397
|
+
let type = data.type || 'video';
|
|
332
398
|
const title = findValueInObject(data, ['title']) || '无标题';
|
|
333
|
-
const author = findValueInObject(data, ['author', '
|
|
399
|
+
const author = findValueInObject(data, ['author.name', 'author']) || '未知作者';
|
|
334
400
|
const desc = (findValueInObject(data, ['desc', 'description', 'content']) || title).toString().slice(0, maxDescLength);
|
|
335
401
|
const cover = findValueInObject(data, ['cover', 'imgurl', 'pic', 'thumbnail']) || '';
|
|
336
402
|
let images = findValueInObject(data, ['imgurl', 'images', 'pics']) || [];
|
|
@@ -344,24 +410,23 @@ function parseData(rawResponse, maxDescLength, platform) {
|
|
|
344
410
|
findValueInObject(data, ['video_url'])
|
|
345
411
|
];
|
|
346
412
|
let video = '';
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
413
|
+
if (Array.isArray(videoUrls[2])) {
|
|
414
|
+
video = videoUrls[2][0]?.url || '';
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
for (const url of videoUrls) {
|
|
418
|
+
if (url && typeof url === 'string' && url.trim() !== '') {
|
|
419
|
+
video = url;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
351
422
|
}
|
|
352
423
|
}
|
|
353
424
|
const durationValue = findValueInObject(data, ['duration']);
|
|
354
425
|
const duration = typeof durationValue === 'number' ? durationValue : parseInt(durationValue) || 0;
|
|
355
426
|
const durationFormatted = formatDuration(durationValue || 0);
|
|
356
|
-
const
|
|
357
|
-
if (dataType === 'image' || (images.length > 0 && !video)) {
|
|
358
|
-
type = 'image';
|
|
359
|
-
}
|
|
360
|
-
if (video && (video.endsWith('.m4a') || video.endsWith('.mp3'))) {
|
|
361
|
-
video = '';
|
|
362
|
-
}
|
|
427
|
+
const live_photo = data.live_photo || [];
|
|
363
428
|
return {
|
|
364
|
-
type,
|
|
429
|
+
type: type,
|
|
365
430
|
rawData: rawResponse,
|
|
366
431
|
title,
|
|
367
432
|
author,
|
|
@@ -371,7 +436,8 @@ function parseData(rawResponse, maxDescLength, platform) {
|
|
|
371
436
|
video,
|
|
372
437
|
duration,
|
|
373
438
|
durationFormatted,
|
|
374
|
-
stat
|
|
439
|
+
stat,
|
|
440
|
+
live_photo
|
|
375
441
|
};
|
|
376
442
|
}
|
|
377
443
|
function generateFormattedText(platform, parseData, config) {
|
|
@@ -401,6 +467,9 @@ function generateFormattedText(platform, parseData, config) {
|
|
|
401
467
|
}
|
|
402
468
|
});
|
|
403
469
|
result = validLines.join('\n').trim();
|
|
470
|
+
if (!result) {
|
|
471
|
+
result = `标题:${parseData.title}\n作者:${parseData.author}\n简介:${parseData.desc}`;
|
|
472
|
+
}
|
|
404
473
|
return result;
|
|
405
474
|
}
|
|
406
475
|
function clearAllCache() {
|
|
@@ -447,13 +516,17 @@ function apply(ctx, config) {
|
|
|
447
516
|
const realUrl = await resolveShortUrl(url);
|
|
448
517
|
const platform = getPlatformType(realUrl);
|
|
449
518
|
if (!platform) {
|
|
450
|
-
|
|
451
|
-
|
|
519
|
+
const code = ErrorCode.UNSUPPORTED_PLATFORM;
|
|
520
|
+
const msg = getErrorInfo(code, url);
|
|
521
|
+
logger.error(`[${code}] ${exports.ErrorMessageMap[code]}: ${url}`);
|
|
522
|
+
return { data: null, code, msg };
|
|
452
523
|
}
|
|
453
524
|
const apiUrl = API_CONFIG[platform];
|
|
454
525
|
if (!apiUrl) {
|
|
455
|
-
|
|
456
|
-
|
|
526
|
+
const code = ErrorCode.PLATFORM_API_NOT_CONFIGURED;
|
|
527
|
+
const msg = getErrorInfo(code, platform);
|
|
528
|
+
logger.error(`[${code}] ${exports.ErrorMessageMap[code]}: ${platform}`);
|
|
529
|
+
return { data: null, code, msg };
|
|
457
530
|
}
|
|
458
531
|
try {
|
|
459
532
|
const res = await http.get(apiUrl, {
|
|
@@ -461,40 +534,88 @@ function apply(ctx, config) {
|
|
|
461
534
|
timeout: config.timeout
|
|
462
535
|
});
|
|
463
536
|
if (res.data.code !== 200 && res.data.code !== 0) {
|
|
464
|
-
|
|
465
|
-
|
|
537
|
+
const apiErrorMsg = res.data.msg || '解析失败';
|
|
538
|
+
const platformCode = PLATFORM_ERROR_CODE_MAP[platform] || ErrorCode.API_RETURN_ERROR;
|
|
539
|
+
const code = platformCode;
|
|
540
|
+
const msg = getErrorInfo(code, apiErrorMsg);
|
|
541
|
+
logger.error(`[${code}] API返回错误: ${platform}, URL: ${url}, 错误: ${apiErrorMsg}`);
|
|
542
|
+
return { data: null, code, msg };
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const parseResult = parseData(res.data, config.maxDescLength, platform);
|
|
546
|
+
if (!parseResult.video && !parseResult.images.length && !parseResult.live_photo?.length) {
|
|
547
|
+
const code = ErrorCode.NO_VIDEO_FOUND;
|
|
548
|
+
const msg = getErrorInfo(code, '未找到视频或图片内容');
|
|
549
|
+
logger.warn(`[${code}] 解析成功但无有效内容: ${platform}, URL: ${url}`);
|
|
550
|
+
return { data: null, code, msg };
|
|
551
|
+
}
|
|
552
|
+
logger.info(`[${ErrorCode.SUCCESS}] ${platform}解析成功: ${url}`);
|
|
553
|
+
return {
|
|
554
|
+
data: parseResult,
|
|
555
|
+
code: ErrorCode.SUCCESS,
|
|
556
|
+
msg: getErrorInfo(ErrorCode.SUCCESS)
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
catch (parseError) {
|
|
560
|
+
const errorMsg = getErrorMessage(parseError);
|
|
561
|
+
const code = ErrorCode.API_DATA_PARSE_FAILED;
|
|
562
|
+
const msg = getErrorInfo(code, errorMsg);
|
|
563
|
+
logger.error(`[${code}] 解析数据失败: ${platform}, URL: ${url}, 错误: ${errorMsg}`);
|
|
564
|
+
return { data: null, code, msg };
|
|
466
565
|
}
|
|
467
|
-
const parseResult = parseData(res.data, config.maxDescLength, platform);
|
|
468
|
-
return { data: parseResult, msg: `${platform}解析成功` };
|
|
469
566
|
}
|
|
470
567
|
catch (error) {
|
|
471
|
-
|
|
472
|
-
|
|
568
|
+
const errorMsg = getErrorMessage(error);
|
|
569
|
+
let code = ErrorCode.UNKNOWN_ERROR;
|
|
570
|
+
if (errorMsg.includes('timeout')) {
|
|
571
|
+
code = ErrorCode.REQUEST_TIMEOUT;
|
|
572
|
+
}
|
|
573
|
+
else if (errorMsg.includes('Network') || errorMsg.includes('network')) {
|
|
574
|
+
code = ErrorCode.NETWORK_ERROR;
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
code = ErrorCode.NETWORK_ERROR;
|
|
578
|
+
}
|
|
579
|
+
const msg = getErrorInfo(code, errorMsg);
|
|
580
|
+
logger.error(`[${code}] 解析请求失败: ${platform}, URL: ${url}, 错误: ${errorMsg}`);
|
|
581
|
+
return { data: null, code, msg };
|
|
473
582
|
}
|
|
474
583
|
}
|
|
475
584
|
async function processSingleUrl(session, url) {
|
|
476
585
|
const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
|
|
477
586
|
const now = Date.now();
|
|
478
587
|
if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000) {
|
|
479
|
-
|
|
588
|
+
const code = ErrorCode.DUPLICATE_PARSE;
|
|
589
|
+
const msg = getErrorInfo(code);
|
|
590
|
+
return { data: null, code, msg };
|
|
480
591
|
}
|
|
481
592
|
processed.set(hash, now);
|
|
482
593
|
const result = await parse(url);
|
|
483
594
|
if (!result.data)
|
|
484
|
-
return { data: null, msg: result.msg };
|
|
595
|
+
return { data: null, code: result.code, msg: result.msg };
|
|
485
596
|
const parseData = result.data;
|
|
486
597
|
const platform = getPlatformType(url);
|
|
487
598
|
const text = generateFormattedText(platform, parseData, config);
|
|
488
599
|
return {
|
|
489
|
-
data: {
|
|
490
|
-
|
|
600
|
+
data: {
|
|
601
|
+
text,
|
|
602
|
+
cover: parseData.cover,
|
|
603
|
+
images: parseData.images,
|
|
604
|
+
video: parseData.video,
|
|
605
|
+
type: parseData.type,
|
|
606
|
+
live_photo: parseData.live_photo
|
|
607
|
+
},
|
|
608
|
+
code: ErrorCode.SUCCESS,
|
|
609
|
+
msg: getErrorInfo(ErrorCode.SUCCESS)
|
|
491
610
|
};
|
|
492
611
|
}
|
|
493
612
|
async function sendTimeout(session, content) {
|
|
494
613
|
if (config.videoSendTimeout <= 0) {
|
|
495
614
|
return session.send(content).catch((err) => {
|
|
615
|
+
const errorMsg = getErrorMessage(err);
|
|
616
|
+
logger.error(`[${ErrorCode.MESSAGE_SEND_FAILED}] 发送消息失败: ${errorMsg}`);
|
|
496
617
|
if (!config.ignoreSendError)
|
|
497
|
-
|
|
618
|
+
return null;
|
|
498
619
|
return null;
|
|
499
620
|
});
|
|
500
621
|
}
|
|
@@ -502,8 +623,11 @@ function apply(ctx, config) {
|
|
|
502
623
|
session.send(content),
|
|
503
624
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), config.videoSendTimeout))
|
|
504
625
|
]).catch((err) => {
|
|
626
|
+
const errorMsg = getErrorMessage(err);
|
|
627
|
+
const code = errorMsg.includes('timeout') ? ErrorCode.MESSAGE_SEND_TIMEOUT : ErrorCode.MESSAGE_SEND_FAILED;
|
|
628
|
+
logger.error(`[${code}] 发送消息失败: ${errorMsg}`);
|
|
505
629
|
if (!config.ignoreSendError)
|
|
506
|
-
|
|
630
|
+
return null;
|
|
507
631
|
return null;
|
|
508
632
|
});
|
|
509
633
|
}
|
|
@@ -516,43 +640,32 @@ function apply(ctx, config) {
|
|
|
516
640
|
linkBuffer.delete(key);
|
|
517
641
|
}
|
|
518
642
|
const items = [];
|
|
519
|
-
const
|
|
643
|
+
const errors = [];
|
|
520
644
|
for (const url of urls) {
|
|
521
645
|
const result = await processSingleUrl(session, url);
|
|
522
646
|
if (result.data) {
|
|
523
647
|
items.push(result.data);
|
|
524
648
|
}
|
|
525
649
|
else {
|
|
526
|
-
|
|
527
|
-
logger.error(
|
|
650
|
+
errors.push({ url, code: result.code, msg: result.msg });
|
|
651
|
+
logger.error(`[${result.code}] 解析失败: ${url}, 原因: ${result.msg}`);
|
|
528
652
|
}
|
|
529
653
|
}
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (errs.length) {
|
|
537
|
-
const errorMsg = `⚠ 部分解析失败\n${errs.join('\n')}`;
|
|
538
|
-
if (enableForward) {
|
|
539
|
-
forwardMessages.push(buildForwardNode(session, errorMsg, botName));
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
await sendTimeout(session, errorMsg);
|
|
543
|
-
await delay(600);
|
|
544
|
-
}
|
|
654
|
+
if (errors.length > 0) {
|
|
655
|
+
const errorLines = errors.map(err => `【${err.url}】: ${err.msg}`);
|
|
656
|
+
const errorMsg = `❌ 解析失败列表(共${errors.length}个链接):\n${errorLines.join('\n')}`;
|
|
657
|
+
logger.error(`解析失败数量: ${errors.length}, 错误码列表: ${errors.map(e => e.code).join(', ')}`);
|
|
658
|
+
await sendTimeout(session, errorMsg);
|
|
659
|
+
await delay(500);
|
|
545
660
|
}
|
|
546
661
|
if (items.length === 0) {
|
|
547
|
-
const failMsg =
|
|
548
|
-
|
|
549
|
-
forwardMessages.push(buildForwardNode(session, failMsg, botName));
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
await sendTimeout(session, failMsg);
|
|
553
|
-
}
|
|
662
|
+
const failMsg = getErrorInfo(ErrorCode.UNKNOWN_ERROR, '所有链接均解析失败,请检查链接是否有效或稍后重试');
|
|
663
|
+
await sendTimeout(session, `⚠ ${failMsg}`);
|
|
554
664
|
return;
|
|
555
665
|
}
|
|
666
|
+
const enableForward = config.enableForward && session.platform === 'onebot';
|
|
667
|
+
const forwardMessages = [];
|
|
668
|
+
const botName = config.botName || '视频解析机器人';
|
|
556
669
|
for (const item of items) {
|
|
557
670
|
try {
|
|
558
671
|
if (enableForward) {
|
|
@@ -565,28 +678,60 @@ function apply(ctx, config) {
|
|
|
565
678
|
forwardMessages.push(buildForwardNode(session, koishi_1.h.image(item.images[i]), botName));
|
|
566
679
|
}
|
|
567
680
|
}
|
|
681
|
+
if (item.type === 'live' || item.type === 'live_photo') {
|
|
682
|
+
if (item.live_photo?.length) {
|
|
683
|
+
for (let i = 0; i < item.live_photo.length && forwardMessages.length < 100; i++) {
|
|
684
|
+
const liveItem = item.live_photo[i];
|
|
685
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.image(liveItem.image), botName));
|
|
686
|
+
if (liveItem.video) {
|
|
687
|
+
try {
|
|
688
|
+
const videoElem = koishi_1.h.video(liveItem.video);
|
|
689
|
+
forwardMessages.push(buildForwardNode(session, videoElem, botName));
|
|
690
|
+
}
|
|
691
|
+
catch (e) {
|
|
692
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.text(`视频链接: ${liveItem.video}`), botName));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else if (item.images?.length) {
|
|
698
|
+
for (let i = 0; i < item.images.length && forwardMessages.length < 100; i++) {
|
|
699
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.image(item.images[i]), botName));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
568
703
|
if (item.video && config.showVideoFile && forwardMessages.length < 100) {
|
|
569
704
|
let videoElem;
|
|
570
705
|
try {
|
|
571
706
|
if (config.downloadVideoBeforeSend) {
|
|
572
707
|
const filename = crypto_1.default.createHash('md5').update(item.video).digest('hex');
|
|
573
|
-
const
|
|
574
|
-
|
|
708
|
+
const downloadResult = await downloadVideo(item.video, filename, config.userAgent, config.maxVideoSize, config.downloadThreads);
|
|
709
|
+
if (downloadResult.code !== ErrorCode.SUCCESS) {
|
|
710
|
+
const errorMsg = getErrorInfo(downloadResult.code);
|
|
711
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.text(`${errorMsg}\n链接:${item.video}`), botName));
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
videoElem = koishi_1.h.file(downloadResult.filePath);
|
|
715
|
+
forwardMessages.push(buildForwardNode(session, videoElem, botName));
|
|
716
|
+
}
|
|
575
717
|
}
|
|
576
718
|
else {
|
|
577
719
|
const fileSize = await getFileSize(item.video, config.userAgent);
|
|
578
720
|
if (config.maxVideoSize > 0 && fileSize > config.maxVideoSize) {
|
|
579
|
-
|
|
721
|
+
const code = ErrorCode.VIDEO_SIZE_EXCEEDED;
|
|
722
|
+
const errorMsg = getErrorInfo(code, `${fileSize}MB超过限制${config.maxVideoSize}MB`);
|
|
723
|
+
videoElem = koishi_1.h.text(`${errorMsg},仅发送链接:${item.video}`);
|
|
580
724
|
}
|
|
581
725
|
else {
|
|
582
726
|
videoElem = koishi_1.h.video(item.video);
|
|
583
727
|
}
|
|
584
728
|
}
|
|
585
|
-
forwardMessages.push(buildForwardNode(session, videoElem, botName));
|
|
586
729
|
}
|
|
587
730
|
catch (error) {
|
|
588
|
-
|
|
589
|
-
|
|
731
|
+
const errorMsg = getErrorMessage(error);
|
|
732
|
+
const code = ErrorCode.VIDEO_DOWNLOAD_FAILED;
|
|
733
|
+
logger.error(`[${code}] 视频处理失败: ${errorMsg}`);
|
|
734
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.text(getErrorInfo(code, errorMsg) + `\n链接:${item.video}`), botName));
|
|
590
735
|
}
|
|
591
736
|
}
|
|
592
737
|
}
|
|
@@ -595,7 +740,28 @@ function apply(ctx, config) {
|
|
|
595
740
|
await sendTimeout(session, item.text);
|
|
596
741
|
await delay(300);
|
|
597
742
|
}
|
|
598
|
-
if (item.type === '
|
|
743
|
+
if (item.type === 'live' || item.type === 'live_photo') {
|
|
744
|
+
if (item.live_photo?.length) {
|
|
745
|
+
for (const liveItem of item.live_photo) {
|
|
746
|
+
await sendTimeout(session, koishi_1.h.image(liveItem.image));
|
|
747
|
+
await delay(200);
|
|
748
|
+
if (liveItem.video) {
|
|
749
|
+
try {
|
|
750
|
+
await sendTimeout(session, koishi_1.h.video(liveItem.video));
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
await sendTimeout(session, koishi_1.h.text(`Live Photo 视频链接: ${liveItem.video}`));
|
|
754
|
+
}
|
|
755
|
+
await delay(300);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else if (item.images?.length) {
|
|
760
|
+
const imgMsg = (0, koishi_1.h)('message', ...item.images.map((url) => koishi_1.h.image(url)));
|
|
761
|
+
await sendTimeout(session, imgMsg);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
else if (item.type === 'image' && item.images?.length) {
|
|
599
765
|
const imgMsg = (0, koishi_1.h)('message', ...item.images.map((url) => koishi_1.h.image(url)));
|
|
600
766
|
await sendTimeout(session, imgMsg);
|
|
601
767
|
}
|
|
@@ -609,13 +775,22 @@ function apply(ctx, config) {
|
|
|
609
775
|
let videoElem;
|
|
610
776
|
if (config.downloadVideoBeforeSend) {
|
|
611
777
|
const filename = crypto_1.default.createHash('md5').update(item.video).digest('hex');
|
|
612
|
-
const
|
|
613
|
-
|
|
778
|
+
const downloadResult = await downloadVideo(item.video, filename, config.userAgent, config.maxVideoSize, config.downloadThreads);
|
|
779
|
+
if (downloadResult.code !== ErrorCode.SUCCESS) {
|
|
780
|
+
const errorMsg = getErrorInfo(downloadResult.code);
|
|
781
|
+
await sendTimeout(session, koishi_1.h.text(`${errorMsg}\n链接:${item.video}`));
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
videoElem = koishi_1.h.file(downloadResult.filePath);
|
|
786
|
+
}
|
|
614
787
|
}
|
|
615
788
|
else {
|
|
616
789
|
const fileSize = await getFileSize(item.video, config.userAgent);
|
|
617
790
|
if (config.maxVideoSize > 0 && fileSize > config.maxVideoSize) {
|
|
618
|
-
|
|
791
|
+
const code = ErrorCode.VIDEO_SIZE_EXCEEDED;
|
|
792
|
+
const errorMsg = getErrorInfo(code, `${fileSize}MB超过限制${config.maxVideoSize}MB`);
|
|
793
|
+
videoElem = koishi_1.h.text(`${errorMsg},仅发送链接:${item.video}`);
|
|
619
794
|
}
|
|
620
795
|
else {
|
|
621
796
|
videoElem = koishi_1.h.video(item.video);
|
|
@@ -624,8 +799,10 @@ function apply(ctx, config) {
|
|
|
624
799
|
await sendTimeout(session, videoElem);
|
|
625
800
|
}
|
|
626
801
|
catch (error) {
|
|
627
|
-
|
|
628
|
-
|
|
802
|
+
const errorMsg = getErrorMessage(error);
|
|
803
|
+
const code = ErrorCode.VIDEO_DOWNLOAD_FAILED;
|
|
804
|
+
logger.error(`[${code}] 视频处理失败: ${errorMsg}`);
|
|
805
|
+
await sendTimeout(session, koishi_1.h.text(getErrorInfo(code, errorMsg) + `\n链接:${item.video}`));
|
|
629
806
|
}
|
|
630
807
|
}
|
|
631
808
|
}
|
|
@@ -633,8 +810,10 @@ function apply(ctx, config) {
|
|
|
633
810
|
}
|
|
634
811
|
}
|
|
635
812
|
catch (error) {
|
|
636
|
-
|
|
637
|
-
|
|
813
|
+
const errorMsg = getErrorMessage(error);
|
|
814
|
+
const code = ErrorCode.UNKNOWN_ERROR;
|
|
815
|
+
logger.error(`[${code}] 处理内容失败: ${errorMsg}`);
|
|
816
|
+
await sendTimeout(session, koishi_1.h.text(getErrorInfo(code, `处理${item.type}内容失败: ${errorMsg}`)));
|
|
638
817
|
}
|
|
639
818
|
}
|
|
640
819
|
if (enableForward && forwardMessages.length) {
|
|
@@ -644,7 +823,9 @@ function apply(ctx, config) {
|
|
|
644
823
|
await sendTimeout(session, forwardMsg);
|
|
645
824
|
}
|
|
646
825
|
catch (error) {
|
|
647
|
-
|
|
826
|
+
const errorMsg = getErrorMessage(error);
|
|
827
|
+
const code = ErrorCode.FORWARD_MESSAGE_FAILED;
|
|
828
|
+
logger.error(`[${code}] 合并转发失败: ${errorMsg}`);
|
|
648
829
|
for (const node of forwardMessages) {
|
|
649
830
|
await sendTimeout(session, node.data.content);
|
|
650
831
|
await delay(500);
|
|
@@ -687,21 +868,25 @@ function apply(ctx, config) {
|
|
|
687
868
|
});
|
|
688
869
|
ctx.command('parse <url>', '手动解析视频链接')
|
|
689
870
|
.action(async ({ session }, url) => {
|
|
690
|
-
if (!url)
|
|
691
|
-
|
|
871
|
+
if (!url) {
|
|
872
|
+
const code = ErrorCode.INVALID_URL;
|
|
873
|
+
return getErrorInfo(code, '请输入视频链接');
|
|
874
|
+
}
|
|
692
875
|
let urls = extractUrl(url);
|
|
693
876
|
if (urls.length === 0 && hasPlatformKeyword(url)) {
|
|
694
877
|
const allLinks = url.match(/https?:\/\/[^\s\"\'\>\]]+/gi) || [];
|
|
695
878
|
urls = allLinks.filter((u) => getPlatformType(u));
|
|
696
879
|
}
|
|
697
|
-
if (urls.length === 0)
|
|
698
|
-
|
|
880
|
+
if (urls.length === 0) {
|
|
881
|
+
const code = ErrorCode.UNSUPPORTED_PLATFORM;
|
|
882
|
+
return getErrorInfo(code, '不支持该链接');
|
|
883
|
+
}
|
|
699
884
|
await flush(session, urls);
|
|
700
885
|
});
|
|
701
886
|
ctx.command('clear-cache', '清空解析缓存与临时文件')
|
|
702
887
|
.action(() => {
|
|
703
888
|
clearAllCache();
|
|
704
|
-
return '
|
|
889
|
+
return getErrorInfo(ErrorCode.SUCCESS, '解析缓存已清空');
|
|
705
890
|
});
|
|
706
891
|
setInterval(() => {
|
|
707
892
|
const now = Date.now();
|
|
@@ -720,18 +905,24 @@ function apply(ctx, config) {
|
|
|
720
905
|
const stat = fs_1.default.statSync(path_1.default.join(tempDir, file));
|
|
721
906
|
if (now - stat.mtimeMs > 3600000) {
|
|
722
907
|
fs_1.default.unlinkSync(path_1.default.join(tempDir, file));
|
|
908
|
+
logger.info(`清理过期临时文件: ${file}`);
|
|
723
909
|
}
|
|
724
910
|
}
|
|
725
911
|
catch (error) {
|
|
726
|
-
|
|
912
|
+
const errorMsg = getErrorMessage(error);
|
|
913
|
+
logger.error(`[${ErrorCode.UNKNOWN_ERROR}] 清理临时文件失败: ${file}, ${errorMsg}`);
|
|
727
914
|
}
|
|
728
915
|
});
|
|
729
916
|
}, 1800000);
|
|
730
917
|
if (config.autoClearCacheInterval > 0) {
|
|
731
918
|
setInterval(() => {
|
|
732
919
|
clearAllCache();
|
|
920
|
+
logger.info(getErrorInfo(ErrorCode.SUCCESS, '自动清理缓存完成'));
|
|
733
921
|
}, config.autoClearCacheInterval * 60000);
|
|
734
922
|
}
|
|
735
|
-
process.on('exit',
|
|
736
|
-
|
|
923
|
+
process.on('exit', () => {
|
|
924
|
+
clearAllCache();
|
|
925
|
+
logger.info(getErrorInfo(ErrorCode.SUCCESS, '进程退出,已清理缓存'));
|
|
926
|
+
});
|
|
927
|
+
logger.info(getErrorInfo(ErrorCode.SUCCESS, '视频解析插件已加载'));
|
|
737
928
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
- 🎨 自定义解析结果格式、返回内容类型(封面/链接/视频)
|
|
9
9
|
- ⚡ 内置防重复解析、接口重试、自动缓存清理等实用功能
|
|
10
10
|
- 📤 支持 OneBot 平台消息合并转发,优化展示体验
|
|
11
|
-
- 🔌 内置多套解析 API,自动降级容错,提升解析成功率
|
|
12
11
|
|
|
13
12
|
### English
|
|
14
13
|
This is a **video parsing plugin** developed for the Koishi bot framework, supporting automatic recognition and parsing of short video links from mainstream platforms such as Douyin, Kuaishou, Bilibili, Xiaohongshu, Weibo, Toutiao, Pipi Funny, Pipi Shrimp, and Zuiyou. Core features:
|
|
@@ -16,7 +15,6 @@ This is a **video parsing plugin** developed for the Koishi bot framework, suppo
|
|
|
16
15
|
- 🎨 Customize the parsing result format and return content type (cover/link/video)
|
|
17
16
|
- ⚡ Built-in duplicate prevention, retry logic, auto cache cleanup
|
|
18
17
|
- 📤 Support OneBot message forwarding for better display experience
|
|
19
|
-
- 🔌 Multiple built-in parsing APIs with automatic failover
|
|
20
18
|
|
|
21
19
|
## 项目仓库 (Repository)
|
|
22
20
|
- GitHub: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all`
|
|
@@ -76,8 +74,6 @@ This is a **video parsing plugin** developed for the Koishi bot framework, suppo
|
|
|
76
74
|
|----------------------|-------------------------|
|
|
77
75
|
| Minecraft-1314 | 插件完整开发 (Complete plugin development) |
|
|
78
76
|
| JH-Ahua | BugPk-Api 支持 |
|
|
79
|
-
| 素颜API | 素颜API 支持 |
|
|
80
|
-
| 星之阁API | 星之阁API 支持 |
|
|
81
77
|
| (欢迎提交 PR 加入贡献者列表) | (Welcome to submit PR to join the contributor list) |
|
|
82
78
|
|
|
83
79
|
## 许可协议 (License)
|