koishi-plugin-music-parser-all 0.0.1
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 +142 -0
- package/lib/index.js +835 -0
- package/package.json +76 -0
- package/readme.md +199 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
declare module 'koishi' {
|
|
3
|
+
interface Context {
|
|
4
|
+
downloads?: {
|
|
5
|
+
download(url: string, dest: string, options?: Record<string, unknown>): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export declare const name = "music-parser-all";
|
|
10
|
+
export declare const Config: Schema<{
|
|
11
|
+
enable?: boolean | null | undefined;
|
|
12
|
+
botName?: string | null | undefined;
|
|
13
|
+
showWaitingTip?: boolean | null | undefined;
|
|
14
|
+
debug?: boolean | null | undefined;
|
|
15
|
+
platformEnabled?: ({
|
|
16
|
+
netease?: boolean | null | undefined;
|
|
17
|
+
kuwo?: boolean | null | undefined;
|
|
18
|
+
qqmusic?: boolean | null | undefined;
|
|
19
|
+
qishui?: boolean | null | undefined;
|
|
20
|
+
} & import("cosmokit").Dict) | null | undefined;
|
|
21
|
+
} & import("cosmokit").Dict & {
|
|
22
|
+
unifiedMessageFormat?: string | null | undefined;
|
|
23
|
+
} & {
|
|
24
|
+
showMusicText?: boolean | null | undefined;
|
|
25
|
+
showCoverImage?: boolean | null | undefined;
|
|
26
|
+
showMusicVoice?: boolean | null | undefined;
|
|
27
|
+
showMusicVoiceFile?: boolean | null | undefined;
|
|
28
|
+
forceDownloadMusicVoice?: boolean | null | undefined;
|
|
29
|
+
forceDownloadImage?: boolean | null | undefined;
|
|
30
|
+
} & {
|
|
31
|
+
maxLyricLength?: number | null | undefined;
|
|
32
|
+
maxConcurrent?: number | null | undefined;
|
|
33
|
+
downloadConcurrency?: number | null | undefined;
|
|
34
|
+
mediaDownloadTimeout?: number | null | undefined;
|
|
35
|
+
maxMediaSize?: number | null | undefined;
|
|
36
|
+
downloadEngine?: "internal" | "aria2" | "downloads" | null | undefined;
|
|
37
|
+
aria2Host?: string | null | undefined;
|
|
38
|
+
aria2Port?: number | null | undefined;
|
|
39
|
+
aria2Secret?: string | null | undefined;
|
|
40
|
+
resumeDownload?: boolean | null | undefined;
|
|
41
|
+
} & {
|
|
42
|
+
timeout?: number | null | undefined;
|
|
43
|
+
videoSendTimeout?: number | null | undefined;
|
|
44
|
+
userAgent?: string | null | undefined;
|
|
45
|
+
proxy?: ({
|
|
46
|
+
enabled?: boolean | null | undefined;
|
|
47
|
+
protocol?: "http" | "https" | null | undefined;
|
|
48
|
+
host?: string | null | undefined;
|
|
49
|
+
port?: number | null | undefined;
|
|
50
|
+
auth?: ({
|
|
51
|
+
username?: string | null | undefined;
|
|
52
|
+
password?: string | null | undefined;
|
|
53
|
+
} & import("cosmokit").Dict) | null | undefined;
|
|
54
|
+
} & import("cosmokit").Dict) | null | undefined;
|
|
55
|
+
customHeaders?: ({
|
|
56
|
+
name?: string | null | undefined;
|
|
57
|
+
value?: string | null | undefined;
|
|
58
|
+
} & import("cosmokit").Dict)[] | null | undefined;
|
|
59
|
+
} & {
|
|
60
|
+
ignoreSendError?: boolean | null | undefined;
|
|
61
|
+
retryTimes?: number | null | undefined;
|
|
62
|
+
retryInterval?: number | null | undefined;
|
|
63
|
+
enableForward?: boolean | null | undefined;
|
|
64
|
+
} & {
|
|
65
|
+
deduplicationInterval?: number | null | undefined;
|
|
66
|
+
cacheTTL?: number | null | undefined;
|
|
67
|
+
cacheDir?: string | null | undefined;
|
|
68
|
+
} & {
|
|
69
|
+
waitingTipText?: string | null | undefined;
|
|
70
|
+
unsupportedPlatformText?: string | null | undefined;
|
|
71
|
+
invalidLinkText?: string | null | undefined;
|
|
72
|
+
parseErrorPrefix?: string | null | undefined;
|
|
73
|
+
parseErrorItemFormat?: string | null | undefined;
|
|
74
|
+
}, {
|
|
75
|
+
enable: boolean;
|
|
76
|
+
botName: string;
|
|
77
|
+
showWaitingTip: boolean;
|
|
78
|
+
debug: boolean;
|
|
79
|
+
platformEnabled: Schemastery.ObjectT<{
|
|
80
|
+
netease: Schema<boolean, boolean>;
|
|
81
|
+
kuwo: Schema<boolean, boolean>;
|
|
82
|
+
qqmusic: Schema<boolean, boolean>;
|
|
83
|
+
qishui: Schema<boolean, boolean>;
|
|
84
|
+
}>;
|
|
85
|
+
} & import("cosmokit").Dict & {
|
|
86
|
+
unifiedMessageFormat: string;
|
|
87
|
+
} & {
|
|
88
|
+
showMusicText: boolean;
|
|
89
|
+
showCoverImage: boolean;
|
|
90
|
+
showMusicVoice: boolean;
|
|
91
|
+
showMusicVoiceFile: boolean;
|
|
92
|
+
forceDownloadMusicVoice: boolean;
|
|
93
|
+
forceDownloadImage: boolean;
|
|
94
|
+
} & {
|
|
95
|
+
maxLyricLength: number;
|
|
96
|
+
maxConcurrent: number;
|
|
97
|
+
downloadConcurrency: number;
|
|
98
|
+
mediaDownloadTimeout: number;
|
|
99
|
+
maxMediaSize: number;
|
|
100
|
+
downloadEngine: "internal" | "aria2" | "downloads";
|
|
101
|
+
aria2Host: string;
|
|
102
|
+
aria2Port: number;
|
|
103
|
+
aria2Secret: string;
|
|
104
|
+
resumeDownload: boolean;
|
|
105
|
+
} & {
|
|
106
|
+
timeout: number;
|
|
107
|
+
videoSendTimeout: number;
|
|
108
|
+
userAgent: string;
|
|
109
|
+
proxy: Schemastery.ObjectT<{
|
|
110
|
+
enabled: Schema<boolean, boolean>;
|
|
111
|
+
protocol: Schema<"http" | "https", "http" | "https">;
|
|
112
|
+
host: Schema<string, string>;
|
|
113
|
+
port: Schema<number, number>;
|
|
114
|
+
auth: Schema<Schemastery.ObjectS<{
|
|
115
|
+
username: Schema<string, string>;
|
|
116
|
+
password: Schema<string, string>;
|
|
117
|
+
}>, Schemastery.ObjectT<{
|
|
118
|
+
username: Schema<string, string>;
|
|
119
|
+
password: Schema<string, string>;
|
|
120
|
+
}>>;
|
|
121
|
+
}>;
|
|
122
|
+
customHeaders: Schemastery.ObjectT<{
|
|
123
|
+
name: Schema<string, string>;
|
|
124
|
+
value: Schema<string, string>;
|
|
125
|
+
}>[];
|
|
126
|
+
} & {
|
|
127
|
+
ignoreSendError: boolean;
|
|
128
|
+
retryTimes: number;
|
|
129
|
+
retryInterval: number;
|
|
130
|
+
enableForward: boolean;
|
|
131
|
+
} & {
|
|
132
|
+
deduplicationInterval: number;
|
|
133
|
+
cacheTTL: number;
|
|
134
|
+
cacheDir: string;
|
|
135
|
+
} & {
|
|
136
|
+
waitingTipText: string;
|
|
137
|
+
unsupportedPlatformText: string;
|
|
138
|
+
invalidLinkText: string;
|
|
139
|
+
parseErrorPrefix: string;
|
|
140
|
+
parseErrorItemFormat: string;
|
|
141
|
+
}>;
|
|
142
|
+
export declare function apply(ctx: Context, config: any): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Config = exports.name = void 0;
|
|
7
|
+
exports.apply = apply;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const axios_1 = __importDefault(require("axios"));
|
|
10
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const promises_2 = require("stream/promises");
|
|
14
|
+
const crypto_1 = require("crypto");
|
|
15
|
+
class SimpleLRUCache {
|
|
16
|
+
constructor(max, ttlMs) {
|
|
17
|
+
this.max = max;
|
|
18
|
+
this.ttlMs = ttlMs;
|
|
19
|
+
this.map = new Map();
|
|
20
|
+
}
|
|
21
|
+
get(key) {
|
|
22
|
+
const entry = this.map.get(key);
|
|
23
|
+
if (!entry)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (Date.now() > entry.expireAt) {
|
|
26
|
+
this.map.delete(key);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return entry.value;
|
|
30
|
+
}
|
|
31
|
+
set(key, value) {
|
|
32
|
+
this.map.delete(key);
|
|
33
|
+
while (this.map.size >= this.max) {
|
|
34
|
+
const k = this.map.keys().next().value;
|
|
35
|
+
if (k === undefined)
|
|
36
|
+
break;
|
|
37
|
+
this.map.delete(k);
|
|
38
|
+
}
|
|
39
|
+
this.map.set(key, { value, expireAt: Date.now() + this.ttlMs });
|
|
40
|
+
}
|
|
41
|
+
clear() {
|
|
42
|
+
this.map.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
class ConcurrencyLimiter {
|
|
46
|
+
constructor(max) {
|
|
47
|
+
this.max = max;
|
|
48
|
+
this.running = 0;
|
|
49
|
+
this.queue = [];
|
|
50
|
+
}
|
|
51
|
+
async acquire() {
|
|
52
|
+
if (this.running < this.max) {
|
|
53
|
+
this.running++;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
this.queue.push(() => {
|
|
58
|
+
this.running++;
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
release() {
|
|
64
|
+
this.running--;
|
|
65
|
+
const next = this.queue.shift();
|
|
66
|
+
if (next)
|
|
67
|
+
next();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.name = 'music-parser-all';
|
|
71
|
+
exports.Config = koishi_1.Schema.intersect([
|
|
72
|
+
koishi_1.Schema.object({
|
|
73
|
+
enable: koishi_1.Schema.boolean().default(true).description('是否启用音乐解析插件'),
|
|
74
|
+
botName: koishi_1.Schema.string().default('音乐解析机器人').description('合并转发中显示的昵称'),
|
|
75
|
+
showWaitingTip: koishi_1.Schema.boolean().default(true).description('解析时显示等待提示'),
|
|
76
|
+
debug: koishi_1.Schema.boolean().default(false).description('开启调试日志'),
|
|
77
|
+
platformEnabled: koishi_1.Schema.object({
|
|
78
|
+
netease: koishi_1.Schema.boolean().default(true).description('网易云音乐'),
|
|
79
|
+
kuwo: koishi_1.Schema.boolean().default(true).description('酷我音乐'),
|
|
80
|
+
qqmusic: koishi_1.Schema.boolean().default(true).description('QQ音乐'),
|
|
81
|
+
qishui: koishi_1.Schema.boolean().default(true).description('汽水音乐'),
|
|
82
|
+
}).description('各平台解析开关'),
|
|
83
|
+
}).description('基本设置'),
|
|
84
|
+
koishi_1.Schema.object({
|
|
85
|
+
unifiedMessageFormat: koishi_1.Schema.string().role('textarea').default('歌名:${name}\n歌手:${artist}\n专辑:${album}\n音质:${level}\n歌词:${lyric}').description('文字格式,支持变量,空行自动隐藏'),
|
|
86
|
+
}).description('消息格式'),
|
|
87
|
+
koishi_1.Schema.object({
|
|
88
|
+
showMusicText: koishi_1.Schema.boolean().default(true).description('发送文字内容'),
|
|
89
|
+
showCoverImage: koishi_1.Schema.boolean().default(true).description('发送封面图片'),
|
|
90
|
+
showMusicVoice: koishi_1.Schema.boolean().default(false).description('音乐链接以语音形式发送'),
|
|
91
|
+
showMusicVoiceFile: koishi_1.Schema.boolean().default(true).description('音乐链接是否以语音形式发送(关闭则只发送链接)'),
|
|
92
|
+
forceDownloadMusicVoice: koishi_1.Schema.boolean().default(false).description('强制下载音乐语音'),
|
|
93
|
+
forceDownloadImage: koishi_1.Schema.boolean().default(false).description('强制下载封面图片'),
|
|
94
|
+
}).description('媒体发送与音乐语音'),
|
|
95
|
+
koishi_1.Schema.object({
|
|
96
|
+
maxLyricLength: koishi_1.Schema.number().min(0).step(1).default(500).description('歌词长度上限(0不限制)'),
|
|
97
|
+
maxConcurrent: koishi_1.Schema.number().min(1).step(1).default(3).description('解析最大并发数'),
|
|
98
|
+
downloadConcurrency: koishi_1.Schema.number().min(1).step(1).default(3).description('下载线程数'),
|
|
99
|
+
mediaDownloadTimeout: koishi_1.Schema.number().min(0).step(1).default(120000).description('统一下载超时 (ms)'),
|
|
100
|
+
maxMediaSize: koishi_1.Schema.number().min(0).step(1).default(0).description('最大下载文件大小 (MB),0 为不限制'),
|
|
101
|
+
downloadEngine: koishi_1.Schema.union([
|
|
102
|
+
koishi_1.Schema.const('internal').description('内置下载'),
|
|
103
|
+
koishi_1.Schema.const('aria2').description('aria2 下载'),
|
|
104
|
+
koishi_1.Schema.const('downloads').description('downloads 服务下载'),
|
|
105
|
+
]).default('internal').description('下载引擎'),
|
|
106
|
+
aria2Host: koishi_1.Schema.string().default('127.0.0.1').description('aria2 RPC 地址'),
|
|
107
|
+
aria2Port: koishi_1.Schema.number().default(6800).description('aria2 RPC 端口'),
|
|
108
|
+
aria2Secret: koishi_1.Schema.string().default('').description('aria2 RPC 密钥'),
|
|
109
|
+
resumeDownload: koishi_1.Schema.boolean().default(true).description('启用断点续传(仅 aria2 模式)'),
|
|
110
|
+
}).description('性能与限制'),
|
|
111
|
+
koishi_1.Schema.object({
|
|
112
|
+
timeout: koishi_1.Schema.number().min(0).step(1).default(180000).description('API 请求超时 (ms)'),
|
|
113
|
+
videoSendTimeout: koishi_1.Schema.number().min(0).step(1).default(180000).description('消息发送超时 (ms)'),
|
|
114
|
+
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('User-Agent'),
|
|
115
|
+
proxy: koishi_1.Schema.object({
|
|
116
|
+
enabled: koishi_1.Schema.boolean().default(false).description('启用代理'),
|
|
117
|
+
protocol: koishi_1.Schema.union([
|
|
118
|
+
koishi_1.Schema.const('http').description('HTTP'),
|
|
119
|
+
koishi_1.Schema.const('https').description('HTTPS'),
|
|
120
|
+
]).default('http').description('协议'),
|
|
121
|
+
host: koishi_1.Schema.string().default('127.0.0.1').description('地址'),
|
|
122
|
+
port: koishi_1.Schema.number().default(7890).description('端口'),
|
|
123
|
+
auth: koishi_1.Schema.object({
|
|
124
|
+
username: koishi_1.Schema.string().default('').description('用户名'),
|
|
125
|
+
password: koishi_1.Schema.string().default('').description('密码'),
|
|
126
|
+
}).description('认证'),
|
|
127
|
+
}).description('HTTP/HTTPS 代理'),
|
|
128
|
+
customHeaders: koishi_1.Schema.array(koishi_1.Schema.object({
|
|
129
|
+
name: koishi_1.Schema.string().required().description('头名称'),
|
|
130
|
+
value: koishi_1.Schema.string().required().description('头值'),
|
|
131
|
+
})).default([]).description('自定义请求头'),
|
|
132
|
+
}).description('网络与请求'),
|
|
133
|
+
koishi_1.Schema.object({
|
|
134
|
+
ignoreSendError: koishi_1.Schema.boolean().default(true).description('忽略发送失败'),
|
|
135
|
+
retryTimes: koishi_1.Schema.number().min(0).step(1).default(3).description('重试次数'),
|
|
136
|
+
retryInterval: koishi_1.Schema.number().min(0).step(1).default(1000).description('重试间隔 (ms)'),
|
|
137
|
+
enableForward: koishi_1.Schema.boolean().default(false).description('合并转发(OneBot/Satori)'),
|
|
138
|
+
}).description('发送与重试'),
|
|
139
|
+
koishi_1.Schema.object({
|
|
140
|
+
deduplicationInterval: koishi_1.Schema.number().min(0).step(1).default(180).description('去重间隔 (s)'),
|
|
141
|
+
cacheTTL: koishi_1.Schema.number().min(0).step(1).default(600).description('缓存时间 (s)'),
|
|
142
|
+
cacheDir: koishi_1.Schema.string().default('./temp_cache_music').description('统一临时目录'),
|
|
143
|
+
}).description('缓存与临时文件'),
|
|
144
|
+
koishi_1.Schema.object({
|
|
145
|
+
waitingTipText: koishi_1.Schema.string().default('正在解析音乐,请稍候...').description('等待提示'),
|
|
146
|
+
unsupportedPlatformText: koishi_1.Schema.string().default('暂不支持该平台音乐链接').description('不支持提示'),
|
|
147
|
+
invalidLinkText: koishi_1.Schema.string().default('无效的音乐链接').description('无效链接提示'),
|
|
148
|
+
parseErrorPrefix: koishi_1.Schema.string().default('❌ 解析失败:').description('错误前缀'),
|
|
149
|
+
parseErrorItemFormat: koishi_1.Schema.string().default('【${url}】: ${msg}').description('错误格式'),
|
|
150
|
+
}).description('界面文本'),
|
|
151
|
+
]);
|
|
152
|
+
const logger = new koishi_1.Logger(exports.name);
|
|
153
|
+
let debugEnabled = false;
|
|
154
|
+
function debugLog(level, ...args) {
|
|
155
|
+
if (!debugEnabled)
|
|
156
|
+
return;
|
|
157
|
+
logger.info(`[${new Date().toISOString()}] [${level}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`);
|
|
158
|
+
}
|
|
159
|
+
const BUILTIN_LINK_RULES = [
|
|
160
|
+
{ pattern: /https?:\/\/(?:music\.163\.com\/(?:#\/)?song\?id=\d{3,}|163cn\.tv\/[A-Za-z0-9]+|y\.music\.163\.com\/m\/song\?id=\d{3,})[^\s]*/gi, type: 'netease' },
|
|
161
|
+
{ pattern: /https?:\/\/www\.kuwo\.cn\/play_detail\/\d+[^\s]*/gi, type: 'kuwo' },
|
|
162
|
+
{ pattern: /https?:\/\/y\.qq\.com\/n\/ryqq\/songDetail\/[A-Za-z0-9]+[^\s]*/gi, type: 'qqmusic' },
|
|
163
|
+
{ pattern: /https?:\/\/i\.y\.qq\.com\/v8\/playsong\.html\?songid=\d+[^\s]*/gi, type: 'qqmusic' },
|
|
164
|
+
{ pattern: /https?:\/\/qishui\.douyin\.com\/s\/[A-Za-z0-9]+[^\s]*/gi, type: 'qishui' },
|
|
165
|
+
];
|
|
166
|
+
function linkTypeParser(content, customRules = []) {
|
|
167
|
+
content = content.replace(/\\\//g, '/');
|
|
168
|
+
const allRules = [...BUILTIN_LINK_RULES, ...customRules];
|
|
169
|
+
const matches = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const rule of allRules) {
|
|
172
|
+
let match;
|
|
173
|
+
rule.pattern.lastIndex = 0;
|
|
174
|
+
while ((match = rule.pattern.exec(content)) !== null) {
|
|
175
|
+
const url = match[0];
|
|
176
|
+
if (seen.has(url))
|
|
177
|
+
continue;
|
|
178
|
+
seen.add(url);
|
|
179
|
+
matches.push({ type: rule.type, url, id: match[1] || url });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return matches;
|
|
183
|
+
}
|
|
184
|
+
function extractAllUrlsFromMessage(session, customRules) {
|
|
185
|
+
const content = session.content?.trim() || '';
|
|
186
|
+
const matchedLinks = linkTypeParser(content, customRules);
|
|
187
|
+
const cardsContent = [];
|
|
188
|
+
if (session.elements) {
|
|
189
|
+
for (const elem of session.elements) {
|
|
190
|
+
if (elem.type === 'xml' && elem.data)
|
|
191
|
+
cardsContent.push(elem.data);
|
|
192
|
+
else if (elem.type === 'json' && elem.data) {
|
|
193
|
+
try {
|
|
194
|
+
const json = JSON.parse(elem.data);
|
|
195
|
+
const extract = (obj) => {
|
|
196
|
+
if (!obj || typeof obj !== 'object')
|
|
197
|
+
return;
|
|
198
|
+
for (const val of Object.values(obj)) {
|
|
199
|
+
if (typeof val === 'string')
|
|
200
|
+
cardsContent.push(val);
|
|
201
|
+
else if (typeof val === 'object')
|
|
202
|
+
extract(val);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
extract(json);
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const cardContent of cardsContent) {
|
|
212
|
+
matchedLinks.push(...linkTypeParser(cardContent, customRules));
|
|
213
|
+
}
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
const result = [];
|
|
216
|
+
for (const link of matchedLinks) {
|
|
217
|
+
if (!seen.has(link.url)) {
|
|
218
|
+
seen.add(link.url);
|
|
219
|
+
result.push(link);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
function cleanUrl(url) {
|
|
225
|
+
try {
|
|
226
|
+
url = url.replace(/&/g, '&');
|
|
227
|
+
const urlObj = new URL(url);
|
|
228
|
+
if (urlObj.protocol === 'http:')
|
|
229
|
+
urlObj.protocol = 'https:';
|
|
230
|
+
if (urlObj.hostname.includes('music.163.com') || urlObj.hostname.includes('163cn.tv')) {
|
|
231
|
+
['userid', 'app_version', 'hdsuffix'].forEach(p => urlObj.searchParams.delete(p));
|
|
232
|
+
return urlObj.origin + urlObj.pathname + (urlObj.search ? '?' + urlObj.search : '');
|
|
233
|
+
}
|
|
234
|
+
return urlObj.toString();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return url.replace(/&/g, '&').replace(/\?.*/, '');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const API_ENDPOINTS = {
|
|
241
|
+
netease: 'https://api.bugpk.com/api/163_music',
|
|
242
|
+
kuwo: 'https://api.bugpk.com/api/kuwo',
|
|
243
|
+
qqmusic: 'https://api.bugpk.com/api/qqmusic',
|
|
244
|
+
qishui: 'https://api.bugpk.com/api/qsmusic',
|
|
245
|
+
};
|
|
246
|
+
async function fetchMusicApi(type, url, config, http) {
|
|
247
|
+
const apiUrl = API_ENDPOINTS[type];
|
|
248
|
+
if (!apiUrl)
|
|
249
|
+
throw new Error(`不支持的平台: ${type}`);
|
|
250
|
+
const params = { url: cleanUrl(url) };
|
|
251
|
+
if (type === 'netease')
|
|
252
|
+
params.type = 'json';
|
|
253
|
+
const res = await http.get(apiUrl, { params });
|
|
254
|
+
if (res.data && (res.data.code === 200 || res.data.code === 0)) {
|
|
255
|
+
return res.data.data;
|
|
256
|
+
}
|
|
257
|
+
throw new Error(res.data?.msg || `API返回错误码: ${res.data?.code}`);
|
|
258
|
+
}
|
|
259
|
+
function parseMusicResponse(rawData, type, maxLyricLen) {
|
|
260
|
+
debugLog('DEBUG', `解析原始数据 [${type}]:`, rawData);
|
|
261
|
+
switch (type) {
|
|
262
|
+
case 'netease':
|
|
263
|
+
return {
|
|
264
|
+
type: 'netease',
|
|
265
|
+
name: rawData.name || '',
|
|
266
|
+
artist: rawData.ar_name || '',
|
|
267
|
+
album: rawData.al_name || '',
|
|
268
|
+
cover: rawData.pic || '',
|
|
269
|
+
musicUrl: rawData.url || '',
|
|
270
|
+
lyric: (rawData.lyric || '').slice(0, maxLyricLen),
|
|
271
|
+
level: rawData.level || '',
|
|
272
|
+
size: rawData.size || '',
|
|
273
|
+
};
|
|
274
|
+
case 'kuwo':
|
|
275
|
+
return {
|
|
276
|
+
type: 'kuwo',
|
|
277
|
+
name: rawData.title || '',
|
|
278
|
+
artist: rawData.artist || '',
|
|
279
|
+
album: rawData.album || '',
|
|
280
|
+
cover: rawData.pic || rawData.albumpic || '',
|
|
281
|
+
musicUrl: rawData.music_url || '',
|
|
282
|
+
lyric: (rawData.lyrics_url || '').slice(0, maxLyricLen),
|
|
283
|
+
level: '',
|
|
284
|
+
size: '',
|
|
285
|
+
};
|
|
286
|
+
case 'qqmusic':
|
|
287
|
+
return {
|
|
288
|
+
type: 'qqmusic',
|
|
289
|
+
name: rawData.name || '',
|
|
290
|
+
artist: rawData.author || '',
|
|
291
|
+
album: rawData.album || '',
|
|
292
|
+
cover: rawData.cover || '',
|
|
293
|
+
musicUrl: rawData.url || '',
|
|
294
|
+
lyric: (rawData.lrc_data || '').slice(0, maxLyricLen),
|
|
295
|
+
level: '',
|
|
296
|
+
size: '',
|
|
297
|
+
};
|
|
298
|
+
case 'qishui':
|
|
299
|
+
const cover = Array.isArray(rawData.artistsmedium_avatar_url) ? rawData.artistsmedium_avatar_url[0] : '';
|
|
300
|
+
return {
|
|
301
|
+
type: 'qishui',
|
|
302
|
+
name: rawData.albumname || '',
|
|
303
|
+
artist: rawData.artistsname || '',
|
|
304
|
+
album: rawData.albumname || '',
|
|
305
|
+
cover: cover,
|
|
306
|
+
musicUrl: rawData.url || '',
|
|
307
|
+
lyric: (rawData.lyric || '').slice(0, maxLyricLen),
|
|
308
|
+
level: rawData.Format || '',
|
|
309
|
+
size: rawData.Size || '',
|
|
310
|
+
};
|
|
311
|
+
default:
|
|
312
|
+
throw new Error('未知平台类型');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function generateFormattedText(p, format) {
|
|
316
|
+
const vars = {
|
|
317
|
+
name: p.name,
|
|
318
|
+
artist: p.artist,
|
|
319
|
+
album: p.album,
|
|
320
|
+
cover: p.cover,
|
|
321
|
+
music_url: p.musicUrl,
|
|
322
|
+
lyric: p.lyric,
|
|
323
|
+
level: p.level || '未知',
|
|
324
|
+
size: p.size || '未知',
|
|
325
|
+
};
|
|
326
|
+
const formatVarRegex = /\$\{([^}]+)\}/g;
|
|
327
|
+
const lines = format.split('\n');
|
|
328
|
+
const resultLines = [];
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const varMatches = line.match(formatVarRegex);
|
|
331
|
+
if (varMatches && varMatches.length > 0) {
|
|
332
|
+
let allEmpty = true;
|
|
333
|
+
for (const match of varMatches) {
|
|
334
|
+
const varName = match.slice(2, -1);
|
|
335
|
+
const val = vars[varName];
|
|
336
|
+
if (val && val !== '0') {
|
|
337
|
+
allEmpty = false;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (allEmpty)
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
let newLine = line;
|
|
345
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
346
|
+
newLine = newLine.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value);
|
|
347
|
+
}
|
|
348
|
+
resultLines.push(newLine);
|
|
349
|
+
}
|
|
350
|
+
return resultLines.join('\n').trim();
|
|
351
|
+
}
|
|
352
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
353
|
+
function buildForwardNode(session, content, botName) {
|
|
354
|
+
let messageContent;
|
|
355
|
+
if (Array.isArray(content))
|
|
356
|
+
messageContent = content;
|
|
357
|
+
else if (content && typeof content === 'object' && content.type)
|
|
358
|
+
messageContent = [content];
|
|
359
|
+
else
|
|
360
|
+
messageContent = [koishi_1.h.text(String(content))];
|
|
361
|
+
return (0, koishi_1.h)('node', { user: { nickname: botName.substring(0, 15), user_id: session.selfId } }, messageContent);
|
|
362
|
+
}
|
|
363
|
+
function getErrorMessage(error) {
|
|
364
|
+
if (error instanceof Error)
|
|
365
|
+
return error.message;
|
|
366
|
+
if (error && typeof error === 'object' && 'message' in error)
|
|
367
|
+
return String(error.message);
|
|
368
|
+
return String(error);
|
|
369
|
+
}
|
|
370
|
+
function apply(ctx, config) {
|
|
371
|
+
debugEnabled = config.debug || false;
|
|
372
|
+
debugLog('INFO', '音乐解析插件启动');
|
|
373
|
+
const dedupCache = new SimpleLRUCache(1000, config.deduplicationInterval * 1000);
|
|
374
|
+
const cacheTTL = (config.cacheTTL || 600) * 1000;
|
|
375
|
+
const urlCacheLocal = new SimpleLRUCache(500, cacheTTL);
|
|
376
|
+
const contentDedupCache = new SimpleLRUCache(1000, config.deduplicationInterval * 1000);
|
|
377
|
+
function contentFingerprint(p) {
|
|
378
|
+
return [p.type, p.name, p.artist, p.album, p.musicUrl].map(v => String(v ?? '')).join('::');
|
|
379
|
+
}
|
|
380
|
+
const texts = {
|
|
381
|
+
waitingTipText: config.waitingTipText || '正在解析音乐,请稍候...',
|
|
382
|
+
unsupportedPlatformText: config.unsupportedPlatformText || '暂不支持该平台音乐链接',
|
|
383
|
+
invalidLinkText: config.invalidLinkText || '无效的音乐链接',
|
|
384
|
+
parseErrorPrefix: config.parseErrorPrefix || '❌ 解析失败:',
|
|
385
|
+
parseErrorItemFormat: config.parseErrorItemFormat || '【${url}】: ${msg}',
|
|
386
|
+
};
|
|
387
|
+
const proxyConfig = config.proxy || {};
|
|
388
|
+
const cacheDir = config.cacheDir || './temp_cache_music';
|
|
389
|
+
const downloadLimiter = new ConcurrencyLimiter(config.downloadConcurrency || 3);
|
|
390
|
+
const mediaDownloadTimeout = config.mediaDownloadTimeout ?? 120000;
|
|
391
|
+
const maxMediaSize = config.maxMediaSize ?? 0;
|
|
392
|
+
const downloadEngine = config.downloadEngine || 'internal';
|
|
393
|
+
let aria2 = null;
|
|
394
|
+
if (downloadEngine === 'aria2') {
|
|
395
|
+
try {
|
|
396
|
+
const Aria2 = require('aria2');
|
|
397
|
+
aria2 = new Aria2({
|
|
398
|
+
host: config.aria2Host || '127.0.0.1',
|
|
399
|
+
port: config.aria2Port || 6800,
|
|
400
|
+
secure: false,
|
|
401
|
+
secret: config.aria2Secret || '',
|
|
402
|
+
path: '/jsonrpc'
|
|
403
|
+
});
|
|
404
|
+
aria2.open();
|
|
405
|
+
logger.info('aria2 连接成功');
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
logger.warn('aria2 连接失败,回退到内置下载');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function downloadFile(url, timeout, maxSize, filePrefix, fileExts) {
|
|
412
|
+
if (!url)
|
|
413
|
+
throw new Error('链接为空');
|
|
414
|
+
await promises_1.default.mkdir(cacheDir, { recursive: true });
|
|
415
|
+
const ext = fileExts.find(e => new RegExp('\\.' + e + '(\\?|$)', 'i').test(url)) || fileExts[0];
|
|
416
|
+
const fileName = `${filePrefix}_${Date.now()}_${(0, crypto_1.randomBytes)(4).toString('hex')}.${ext}`;
|
|
417
|
+
const filePath = path_1.default.resolve(cacheDir, fileName);
|
|
418
|
+
if (downloadEngine === 'downloads' && ctx.downloads) {
|
|
419
|
+
try {
|
|
420
|
+
const dest = await ctx.downloads.download(url, path_1.default.join(cacheDir, fileName), {
|
|
421
|
+
headers: { 'User-Agent': config.userAgent },
|
|
422
|
+
timeout
|
|
423
|
+
});
|
|
424
|
+
const stat = await promises_1.default.stat(dest);
|
|
425
|
+
if (maxSize > 0 && stat.size > maxSize * 1024 * 1024) {
|
|
426
|
+
await promises_1.default.unlink(dest).catch(() => { });
|
|
427
|
+
throw new Error(`文件过大(${Math.round(stat.size / 1024 / 1024)}MB),超过限制(${maxSize}MB)`);
|
|
428
|
+
}
|
|
429
|
+
return dest;
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
debugLog('ERROR', `downloads 下载失败,回退内置下载: ${getErrorMessage(e)}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else if (downloadEngine === 'aria2' && aria2 && config.resumeDownload) {
|
|
436
|
+
try {
|
|
437
|
+
const gid = await aria2.call('aria2.addUri', [url], {
|
|
438
|
+
dir: cacheDir,
|
|
439
|
+
out: fileName,
|
|
440
|
+
split: 4,
|
|
441
|
+
continue: true,
|
|
442
|
+
maxConnectionPerServer: 5,
|
|
443
|
+
timeout: timeout / 1000,
|
|
444
|
+
maxFileNotFound: 5,
|
|
445
|
+
maxTries: 5,
|
|
446
|
+
retryWait: 2,
|
|
447
|
+
header: [`User-Agent: ${config.userAgent}`, `Referer: https://www.baidu.com/`]
|
|
448
|
+
});
|
|
449
|
+
let completed = false;
|
|
450
|
+
const ariaStartTime = Date.now();
|
|
451
|
+
while (!completed) {
|
|
452
|
+
if (Date.now() - ariaStartTime > timeout) {
|
|
453
|
+
await aria2.call('aria2.remove', gid).catch(() => { });
|
|
454
|
+
throw new Error('aria2下载超时');
|
|
455
|
+
}
|
|
456
|
+
const status = await aria2.call('aria2.tellStatus', gid);
|
|
457
|
+
if (status.status === 'complete') {
|
|
458
|
+
completed = true;
|
|
459
|
+
}
|
|
460
|
+
else if (status.status === 'error' || status.status === 'removed') {
|
|
461
|
+
throw new Error('aria2下载失败');
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
await delay(1000);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const stat = await promises_1.default.stat(filePath);
|
|
468
|
+
if (maxSize > 0 && stat.size > maxSize * 1024 * 1024) {
|
|
469
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
470
|
+
throw new Error(`文件过大(${Math.round(stat.size / 1024 / 1024)}MB),超过限制(${maxSize}MB)`);
|
|
471
|
+
}
|
|
472
|
+
return filePath;
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
debugLog('ERROR', `aria2下载失败,回退内置下载: ${getErrorMessage(e)}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const writer = (0, fs_1.createWriteStream)(filePath);
|
|
479
|
+
let response;
|
|
480
|
+
try {
|
|
481
|
+
response = await http({
|
|
482
|
+
method: 'GET',
|
|
483
|
+
url,
|
|
484
|
+
responseType: 'stream',
|
|
485
|
+
timeout,
|
|
486
|
+
headers: { 'User-Agent': config.userAgent, 'Referer': 'https://www.baidu.com/' },
|
|
487
|
+
maxRedirects: 5,
|
|
488
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
writer.destroy();
|
|
493
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
494
|
+
throw new Error(`下载失败: ${getErrorMessage(e)}`);
|
|
495
|
+
}
|
|
496
|
+
const maxSizeBytes = maxSize * 1024 * 1024;
|
|
497
|
+
const contentLength = Number(response.headers['content-length'] || 0);
|
|
498
|
+
if (maxSizeBytes > 0 && contentLength > maxSizeBytes) {
|
|
499
|
+
writer.destroy();
|
|
500
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
501
|
+
throw new Error(`文件过大(${Math.round(contentLength / 1024 / 1024)}MB),超过限制(${maxSize}MB)`);
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
await (0, promises_2.pipeline)(response.data, writer);
|
|
505
|
+
return filePath;
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
509
|
+
throw new Error(`写入文件失败: ${getErrorMessage(e)}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function sendMedia(session, url, type, forceDownload, showFile) {
|
|
513
|
+
if (!url)
|
|
514
|
+
return;
|
|
515
|
+
await downloadLimiter.acquire();
|
|
516
|
+
try {
|
|
517
|
+
const sendLink = async () => { await sendWithTimeout(session, `${type === 'audio' ? '音乐' : '封面'}链接:${url}`).catch(() => { }); };
|
|
518
|
+
const extMap = {
|
|
519
|
+
image: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
|
|
520
|
+
audio: ['mp3', 'm4a', 'flac', 'wav', 'ogg', 'aac']
|
|
521
|
+
};
|
|
522
|
+
const prefixMap = { image: 'img', audio: 'music' };
|
|
523
|
+
const sendFunc = type === 'audio' ? koishi_1.h.audio : koishi_1.h.image;
|
|
524
|
+
if (forceDownload) {
|
|
525
|
+
try {
|
|
526
|
+
const localPath = await downloadFile(url, mediaDownloadTimeout, maxMediaSize, prefixMap[type], extMap[type]);
|
|
527
|
+
try {
|
|
528
|
+
await sendWithTimeout(session, sendFunc(`file://${localPath}`));
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
await promises_1.default.unlink(localPath).catch(() => { });
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
debugLog('ERROR', `强制下载${type}失败,尝试URL发送:`, getErrorMessage(e));
|
|
537
|
+
try {
|
|
538
|
+
await sendWithTimeout(session, sendFunc(url));
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
await sendLink();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!showFile) {
|
|
547
|
+
await sendLink();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
await sendWithTimeout(session, sendFunc(url));
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
try {
|
|
555
|
+
const localPath = await downloadFile(url, mediaDownloadTimeout, maxMediaSize, prefixMap[type], extMap[type]);
|
|
556
|
+
try {
|
|
557
|
+
await sendWithTimeout(session, sendFunc(`file://${localPath}`));
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
await promises_1.default.unlink(localPath).catch(() => { });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
await sendLink();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
finally {
|
|
569
|
+
downloadLimiter.release();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function flush(session, matches) {
|
|
573
|
+
debugLog('INFO', `开始解析 ${matches.length} 个链接`);
|
|
574
|
+
const items = [];
|
|
575
|
+
const errors = [];
|
|
576
|
+
const limiter = new ConcurrencyLimiter(config.maxConcurrent || 3);
|
|
577
|
+
const promises = matches.map(async (match) => {
|
|
578
|
+
await limiter.acquire();
|
|
579
|
+
try {
|
|
580
|
+
const platformEnabled = config.platformEnabled?.[match.type] ?? true;
|
|
581
|
+
if (!platformEnabled) {
|
|
582
|
+
debugLog('INFO', `平台 ${match.type} 已禁用,跳过链接: ${match.url}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (config.deduplicationInterval > 0) {
|
|
586
|
+
const lastTime = dedupCache.get(match.url);
|
|
587
|
+
if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
|
|
588
|
+
debugLog('INFO', `跳过重复链接: ${match.url}`);
|
|
589
|
+
const shortUrl = match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url;
|
|
590
|
+
await sendWithTimeout(session, `链接 ${shortUrl} 在最近 ${config.deduplicationInterval} 秒内已解析过,已跳过。`).catch(() => { });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
debugLog('INFO', `解析链接: ${match.url} (${match.type})`);
|
|
595
|
+
const result = await processSingleUrl(match.url, match.type);
|
|
596
|
+
if (result.success) {
|
|
597
|
+
if (config.deduplicationInterval > 0) {
|
|
598
|
+
const fp = contentFingerprint(result.data.parsed);
|
|
599
|
+
const lastDedup = contentDedupCache.get(fp);
|
|
600
|
+
if (lastDedup && (Date.now() - lastDedup < config.deduplicationInterval * 1000)) {
|
|
601
|
+
debugLog('INFO', `跳过重复内容: ${match.url}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
contentDedupCache.set(fp, Date.now());
|
|
605
|
+
dedupCache.set(match.url, Date.now());
|
|
606
|
+
}
|
|
607
|
+
items.push(result.data);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
const item = texts.parseErrorItemFormat.replace(/\$\{url\}/g, match.url.length > 50 ? match.url.slice(0, 50) + '...' : match.url).replace(/\$\{msg\}/g, result.msg);
|
|
611
|
+
errors.push(item);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
limiter.release();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
await Promise.all(promises);
|
|
619
|
+
if (errors.length)
|
|
620
|
+
await sendWithTimeout(session, `${texts.parseErrorPrefix}\n${errors.join('\n')}`);
|
|
621
|
+
if (!items.length)
|
|
622
|
+
return;
|
|
623
|
+
const enableForward = config.enableForward && (session.platform === 'onebot' || session.platform === 'satori');
|
|
624
|
+
const botName = config.botName || '音乐解析机器人';
|
|
625
|
+
if (enableForward) {
|
|
626
|
+
const forwardMessages = [];
|
|
627
|
+
for (const item of items) {
|
|
628
|
+
const p = item.parsed;
|
|
629
|
+
const text = item.text;
|
|
630
|
+
if (text && config.showMusicText)
|
|
631
|
+
forwardMessages.push(buildForwardNode(session, text, botName));
|
|
632
|
+
if (p.cover && config.showCoverImage)
|
|
633
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.image(p.cover), botName));
|
|
634
|
+
if (p.musicUrl && config.showMusicVoice)
|
|
635
|
+
forwardMessages.push(buildForwardNode(session, koishi_1.h.audio(p.musicUrl), botName));
|
|
636
|
+
}
|
|
637
|
+
if (forwardMessages.length) {
|
|
638
|
+
try {
|
|
639
|
+
await sendWithTimeout(session, (0, koishi_1.h)('message', { forward: true }, forwardMessages.slice(0, 100)), config.retryTimes);
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
debugLog('ERROR', '合并转发失败,降级逐条发送:', err);
|
|
643
|
+
for (const node of forwardMessages) {
|
|
644
|
+
await sendWithTimeout(session, node.data.content).catch(() => { });
|
|
645
|
+
await delay(300);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
for (const item of items) {
|
|
652
|
+
const p = item.parsed;
|
|
653
|
+
const text = item.text;
|
|
654
|
+
if (text && config.showMusicText) {
|
|
655
|
+
await sendWithTimeout(session, text);
|
|
656
|
+
await delay(300);
|
|
657
|
+
}
|
|
658
|
+
if (p.cover && config.showCoverImage) {
|
|
659
|
+
await sendMedia(session, p.cover, 'image', config.forceDownloadImage, true).catch(() => { });
|
|
660
|
+
await delay(300);
|
|
661
|
+
}
|
|
662
|
+
if (p.musicUrl && config.showMusicVoice) {
|
|
663
|
+
await sendMedia(session, p.musicUrl, 'audio', config.forceDownloadMusicVoice, config.showMusicVoiceFile).catch(() => { });
|
|
664
|
+
await delay(500);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
debugLog('INFO', '处理完成');
|
|
669
|
+
}
|
|
670
|
+
async function processSingleUrl(url, type) {
|
|
671
|
+
const cacheKey = url;
|
|
672
|
+
const cached = urlCacheLocal.get(cacheKey);
|
|
673
|
+
if (cached && cached.expire > Date.now()) {
|
|
674
|
+
const text = generateFormattedText(cached.data, config.unifiedMessageFormat);
|
|
675
|
+
return { success: true, data: { text, parsed: cached.data } };
|
|
676
|
+
}
|
|
677
|
+
for (let attempt = 0; attempt <= config.retryTimes; attempt++) {
|
|
678
|
+
try {
|
|
679
|
+
const data = await fetchMusicApi(type, url, config, http);
|
|
680
|
+
const parsed = parseMusicResponse(data, type, config.maxLyricLength);
|
|
681
|
+
urlCacheLocal.set(cacheKey, { data: parsed, expire: Date.now() + cacheTTL });
|
|
682
|
+
const text = generateFormattedText(parsed, config.unifiedMessageFormat);
|
|
683
|
+
return { success: true, data: { text, parsed } };
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
const errMsg = getErrorMessage(error);
|
|
687
|
+
debugLog('ERROR', `解析尝试 ${attempt + 1} 失败: ${errMsg}`);
|
|
688
|
+
if (attempt < config.retryTimes)
|
|
689
|
+
await delay(config.retryInterval);
|
|
690
|
+
else
|
|
691
|
+
return { success: false, msg: errMsg, url };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { success: false, msg: texts.unsupportedPlatformText, url };
|
|
695
|
+
}
|
|
696
|
+
async function sendWithTimeout(session, content, customRetries) {
|
|
697
|
+
const maxRetries = customRetries ?? config.retryTimes ?? 3;
|
|
698
|
+
const retryDelay = config.retryInterval || 1000;
|
|
699
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
700
|
+
try {
|
|
701
|
+
let sendPromise = session.send(content);
|
|
702
|
+
if (config.videoSendTimeout > 0) {
|
|
703
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('发送超时')), config.videoSendTimeout));
|
|
704
|
+
return await Promise.race([sendPromise, timeoutPromise]);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
return await sendPromise;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
const errMsg = getErrorMessage(err);
|
|
712
|
+
debugLog('ERROR', `发送失败尝试 ${attempt + 1}: ${errMsg}`);
|
|
713
|
+
if (attempt < maxRetries)
|
|
714
|
+
await delay(retryDelay);
|
|
715
|
+
else if (!config.ignoreSendError)
|
|
716
|
+
throw err;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
const axiosConfig = {
|
|
722
|
+
timeout: config.timeout,
|
|
723
|
+
headers: {
|
|
724
|
+
'User-Agent': config.userAgent,
|
|
725
|
+
'Referer': 'https://www.baidu.com/',
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
if (proxyConfig.enabled && proxyConfig.host) {
|
|
729
|
+
axiosConfig.proxy = {
|
|
730
|
+
protocol: proxyConfig.protocol || 'http',
|
|
731
|
+
host: proxyConfig.host,
|
|
732
|
+
port: proxyConfig.port || 7890,
|
|
733
|
+
auth: proxyConfig.auth?.username ? {
|
|
734
|
+
username: proxyConfig.auth.username,
|
|
735
|
+
password: proxyConfig.auth.password || ''
|
|
736
|
+
} : undefined
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const customHeaders = config.customHeaders || [];
|
|
740
|
+
const http = axios_1.default.create(axiosConfig);
|
|
741
|
+
http.interceptors.request.use((config) => {
|
|
742
|
+
for (const h of customHeaders) {
|
|
743
|
+
if (h.name && h.value)
|
|
744
|
+
config.headers[h.name] = h.value;
|
|
745
|
+
}
|
|
746
|
+
return config;
|
|
747
|
+
});
|
|
748
|
+
ctx.on('message', async (session) => {
|
|
749
|
+
if (!config.enable)
|
|
750
|
+
return;
|
|
751
|
+
if (/^\s*parse\b/i.test(session.content || ''))
|
|
752
|
+
return;
|
|
753
|
+
if (session.subtype === 'file_upload')
|
|
754
|
+
return;
|
|
755
|
+
if (session.elements?.some(elem => elem.type === 'file' || elem.type === 'folder'))
|
|
756
|
+
return;
|
|
757
|
+
if (session.selfId === session.userId)
|
|
758
|
+
return;
|
|
759
|
+
const matches = extractAllUrlsFromMessage(session, []);
|
|
760
|
+
if (!matches.length)
|
|
761
|
+
return;
|
|
762
|
+
debugLog('INFO', `检测到 ${matches.length} 个音乐链接`);
|
|
763
|
+
if (config.showWaitingTip) {
|
|
764
|
+
try {
|
|
765
|
+
await sendWithTimeout(session, texts.waitingTipText);
|
|
766
|
+
}
|
|
767
|
+
catch (e) {
|
|
768
|
+
debugLog('WARN', '等待提示发送失败:', e);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
await flush(session, matches);
|
|
772
|
+
});
|
|
773
|
+
ctx.command('music <url>', '手动解析音乐').action(async ({ session }, url) => {
|
|
774
|
+
if (!url) {
|
|
775
|
+
await sendWithTimeout(session, texts.invalidLinkText);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const matches = linkTypeParser(url, []);
|
|
779
|
+
if (!matches.length) {
|
|
780
|
+
await sendWithTimeout(session, texts.invalidLinkText);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (config.showWaitingTip) {
|
|
784
|
+
try {
|
|
785
|
+
await sendWithTimeout(session, texts.waitingTipText);
|
|
786
|
+
}
|
|
787
|
+
catch { }
|
|
788
|
+
}
|
|
789
|
+
await flush(session, matches);
|
|
790
|
+
});
|
|
791
|
+
const tempCleanupInterval = setInterval(async () => {
|
|
792
|
+
try {
|
|
793
|
+
const files = await promises_1.default.readdir(cacheDir);
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
for (const file of files) {
|
|
796
|
+
if ((file.startsWith('music_') || file.startsWith('img_')) &&
|
|
797
|
+
(file.match(/\.(mp3|m4a|flac|wav|ogg|aac|png|jpg|jpeg|gif|webp)$/i))) {
|
|
798
|
+
const filePath = path_1.default.join(cacheDir, file);
|
|
799
|
+
const stats = await promises_1.default.stat(filePath);
|
|
800
|
+
if (now - stats.mtimeMs > 3600000) {
|
|
801
|
+
await promises_1.default.unlink(filePath).catch(() => { });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
if (e?.code !== 'ENOENT')
|
|
808
|
+
debugLog('WARN', '清理临时文件失败:', e);
|
|
809
|
+
}
|
|
810
|
+
}, 3600000);
|
|
811
|
+
ctx.on('dispose', () => {
|
|
812
|
+
clearInterval(tempCleanupInterval);
|
|
813
|
+
if (aria2)
|
|
814
|
+
aria2.close();
|
|
815
|
+
urlCacheLocal.clear();
|
|
816
|
+
dedupCache.clear();
|
|
817
|
+
debugLog('INFO', '音乐解析插件已卸载');
|
|
818
|
+
});
|
|
819
|
+
process.on('beforeExit', async () => {
|
|
820
|
+
try {
|
|
821
|
+
const files = await promises_1.default.readdir(cacheDir);
|
|
822
|
+
for (const file of files) {
|
|
823
|
+
if ((file.startsWith('music_') || file.startsWith('img_')) &&
|
|
824
|
+
(file.match(/\.(mp3|m4a|flac|wav|ogg|aac|png|jpg|jpeg|gif|webp)$/i))) {
|
|
825
|
+
await promises_1.default.unlink(path_1.default.join(cacheDir, file)).catch(() => { });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch (e) {
|
|
830
|
+
if (e?.code !== 'ENOENT')
|
|
831
|
+
debugLog('WARN', '退出清理临时文件失败:', e);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
debugLog('INFO', '音乐解析插件初始化完成');
|
|
835
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-music-parser-all",
|
|
3
|
+
"description": "Koishi 全平台音乐解析插件,支持网易云音乐/酷我音乐/QQ音乐/汽水音乐等平台",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsc -w",
|
|
14
|
+
"clean": "rimraf lib"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"chatbot",
|
|
18
|
+
"koishi",
|
|
19
|
+
"plugin",
|
|
20
|
+
"music",
|
|
21
|
+
"parser",
|
|
22
|
+
"netease",
|
|
23
|
+
"kuwo",
|
|
24
|
+
"qqmusic",
|
|
25
|
+
"qishui",
|
|
26
|
+
"音频解析",
|
|
27
|
+
"音乐解析",
|
|
28
|
+
"多平台",
|
|
29
|
+
"网易云音乐",
|
|
30
|
+
"酷我音乐",
|
|
31
|
+
"QQ音乐",
|
|
32
|
+
"汽水音乐"
|
|
33
|
+
],
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@koishijs/client": "^5.30.4",
|
|
36
|
+
"@koishijs/scripts": "^4.6.2",
|
|
37
|
+
"rimraf": "^5.0.5",
|
|
38
|
+
"typescript": "^5.3.3"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"axios": "^1.16.1"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@koishijs/plugin-console": "^5.30.4",
|
|
45
|
+
"koishi": "^4.18.7",
|
|
46
|
+
"koishi-plugin-downloads": "^1.0.0",
|
|
47
|
+
"koishi-plugin-silk": "^1.0.0",
|
|
48
|
+
"koishi-plugin-ffmpeg": "^1.0.0",
|
|
49
|
+
"aria2": "^4.1.2"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"koishi-plugin-downloads": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"koishi-plugin-silk": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"koishi-plugin-ffmpeg": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"aria2": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/Minecraft-1314/koishi-plugin-music-parser-all.git"
|
|
68
|
+
},
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/Minecraft-1314/koishi-plugin-music-parser-all/issues"
|
|
71
|
+
},
|
|
72
|
+
"homepage": "https://github.com/Minecraft-1314/koishi-plugin-music-parser-all#readme",
|
|
73
|
+
"engines": {
|
|
74
|
+
"node": ">=16.0.0"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# koishi-plugin-video-parser-all
|
|
2
|
+
|
|
3
|
+
## 项目介绍 (Project Introduction)
|
|
4
|
+
|
|
5
|
+
### 中文
|
|
6
|
+
这是一个为 Koishi 机器人框架开发的**全平台视频/图集解析插件**,使用统一API接口,支持自动识别并解析抖音、快手、B站、小红书、微博、YouTube、TikTok、剪映、AcFun、知乎、虎牙、绿洲、视频号等20+主流平台的短视频/图集/实况链接。
|
|
7
|
+
|
|
8
|
+
### English
|
|
9
|
+
This is a **multi-platform video/image parsing plugin** developed for the Koishi bot framework, using a unified API interface to automatically recognize and parse short video/image/live photo links from 20+ mainstream platforms such as Douyin, Kuaishou, Bilibili, Xiaohongshu, Weibo, YouTube, TikTok, Jianying, AcFun, Zhihu, Huya, Oasis, WeChat Channels and more.
|
|
10
|
+
|
|
11
|
+
## 项目仓库 (Repository)
|
|
12
|
+
- GitHub: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all`
|
|
13
|
+
- Issues: `https://github.com/Minecraft-1314/koishi-plugin-video-parser-all/issues`
|
|
14
|
+
|
|
15
|
+
## 核心指令 (Core Commands)
|
|
16
|
+
|
|
17
|
+
| 指令 (Command) | 说明 (Description) | 示例 (Example) |
|
|
18
|
+
|----------------|--------------------|----------------|
|
|
19
|
+
| `parse <url>` | 手动解析指定的视频/图集链接 | `parse https://v.douyin.com/xxxx/` |
|
|
20
|
+
|
|
21
|
+
## 配置项说明 (Configuration)
|
|
22
|
+
|
|
23
|
+
### 基本设置
|
|
24
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
25
|
+
|--------|------|--------|------|
|
|
26
|
+
| `enable` | boolean | true | 启用插件 |
|
|
27
|
+
| `botName` | string | 视频解析机器人 | 合并转发中的昵称 |
|
|
28
|
+
| `showWaitingTip` | boolean | true | 显示等待提示 |
|
|
29
|
+
| `debug` | boolean | false | Debug 日志 |
|
|
30
|
+
| `platformEnabled` | object | 全开 | 各平台开关 |
|
|
31
|
+
|
|
32
|
+
### 消息格式
|
|
33
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
34
|
+
|--------|------|--------|------|
|
|
35
|
+
| `unifiedMessageFormat` | string | 见预设 | 文字格式,支持变量,空行自动隐藏 |
|
|
36
|
+
|
|
37
|
+
### 媒体发送
|
|
38
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
39
|
+
|--------|------|--------|------|
|
|
40
|
+
| `showImageText` | boolean | true | 发送文字内容 |
|
|
41
|
+
| `showCoverImage` | boolean | true | 发送封面图片 |
|
|
42
|
+
| `showMusicCover` | boolean | true | 发送音乐封面 |
|
|
43
|
+
| `showImageFile` | boolean | true | 封面/图片是否以图片形式发送(关闭则只发送链接) |
|
|
44
|
+
| `showVideoFile` | boolean | true | 视频是否以视频形式发送(关闭则只发送链接) |
|
|
45
|
+
| `forceDownloadImage` | boolean | false | 强制下载封面/图片 |
|
|
46
|
+
| `forceDownloadVideo` | boolean | false | 强制下载视频 |
|
|
47
|
+
|
|
48
|
+
### 音乐语音(需 silk 和 ffmpeg)
|
|
49
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
50
|
+
|--------|------|--------|------|
|
|
51
|
+
| `showMusicVoice` | boolean | false | 音乐链接以语音发送 |
|
|
52
|
+
| `showMusicVoiceFile` | boolean | true | 音乐链接是否以语音形式发送(关闭则只发送链接) |
|
|
53
|
+
| `forceDownloadMusicVoice` | boolean | false | 强制下载音乐语音 |
|
|
54
|
+
|
|
55
|
+
### 性能与限制
|
|
56
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
57
|
+
|--------|------|--------|------|
|
|
58
|
+
| `maxDescLength` | number | 200 | 简介长度上限 |
|
|
59
|
+
| `maxConcurrent` | number | 3 | 解析最大并发数 |
|
|
60
|
+
| `downloadConcurrency` | number | 3 | 下载线程数 |
|
|
61
|
+
| `mediaDownloadTimeout` | number | 120000 | 统一下载超时 (ms) |
|
|
62
|
+
| `maxMediaSize` | number | 0 | 最大下载文件大小 (MB),0 为不限制 |
|
|
63
|
+
| `downloadEngine` | string | internal | 下载引擎(internal / aria2 / downloads) |
|
|
64
|
+
| `aria2Host` | string | 127.0.0.1 | aria2 RPC 地址 |
|
|
65
|
+
| `aria2Port` | number | 6800 | aria2 RPC 端口 |
|
|
66
|
+
| `aria2Secret` | string | | aria2 RPC 密钥 |
|
|
67
|
+
| `resumeDownload` | boolean | true | 启用断点续传(仅 aria2) |
|
|
68
|
+
|
|
69
|
+
### 网络与请求
|
|
70
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
71
|
+
|--------|------|--------|------|
|
|
72
|
+
| `timeout` | number | 180000 | API 超时 (ms) |
|
|
73
|
+
| `videoSendTimeout` | number | 180000 | 发送超时 (ms) |
|
|
74
|
+
| `userAgent` | string | 见预设 | User-Agent |
|
|
75
|
+
| `proxy` | object | ... | HTTP/HTTPS 代理 |
|
|
76
|
+
| `customHeaders` | array | [] | 自定义请求头 |
|
|
77
|
+
|
|
78
|
+
### 发送与重试
|
|
79
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
80
|
+
|--------|------|--------|------|
|
|
81
|
+
| `ignoreSendError` | boolean | true | 忽略发送失败 |
|
|
82
|
+
| `retryTimes` | number | 3 | 重试次数 |
|
|
83
|
+
| `retryInterval` | number | 1000 | 重试间隔 (ms) |
|
|
84
|
+
| `enableForward` | boolean | false | 合并转发(OneBot/Satori) |
|
|
85
|
+
|
|
86
|
+
### 缓存与临时文件
|
|
87
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
88
|
+
|--------|------|--------|------|
|
|
89
|
+
| `deduplicationInterval` | number | 180 | 去重间隔 (s) |
|
|
90
|
+
| `cacheTTL` | number | 600 | 缓存时间 (s) |
|
|
91
|
+
| `cacheDir` | string | ./temp_cache | 统一临时目录 |
|
|
92
|
+
|
|
93
|
+
### API 与平台
|
|
94
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
95
|
+
|--------|------|--------|------|
|
|
96
|
+
| `platformDedicatedFirst` | object | 全关 | 优先专属 API |
|
|
97
|
+
| `customApis` | array | [] | 覆盖内置平台 API |
|
|
98
|
+
| `customPlatforms` | array | [] | 自定义新平台 |
|
|
99
|
+
| `globalFieldMapping` | string | 预设 | 全局字段映射 JSON |
|
|
100
|
+
|
|
101
|
+
### 界面文本
|
|
102
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
103
|
+
|--------|------|--------|------|
|
|
104
|
+
| `waitingTipText` | string | 正在解析... | 等待提示 |
|
|
105
|
+
| `unsupportedPlatformText` | string | 不支持该平台 | 不支持提示 |
|
|
106
|
+
| `invalidLinkText` | string | 无效链接 | 无效链接提示 |
|
|
107
|
+
| `parseErrorPrefix` | string | ❌ 解析失败: | 错误前缀 |
|
|
108
|
+
| `parseErrorItemFormat` | string | ... | 错误格式 |
|
|
109
|
+
|
|
110
|
+
## 支持的变量 (Supported Variables)
|
|
111
|
+
在 `unifiedMessageFormat` 中可使用以下变量,空行自动隐藏:
|
|
112
|
+
|
|
113
|
+
| 变量名 | 说明 |
|
|
114
|
+
|--------|------|
|
|
115
|
+
| `${标题}` | 视频/图集标题 |
|
|
116
|
+
| `${作者}` | 作者名称 |
|
|
117
|
+
| `${简介}` | 内容简介 |
|
|
118
|
+
| `${视频时长}` | 视频时长(时:分:秒) |
|
|
119
|
+
| `${点赞数}` | 点赞数量 |
|
|
120
|
+
| `${收藏数}` | 收藏数量 |
|
|
121
|
+
| `${转发数}` | 转发/分享数量 |
|
|
122
|
+
| `${播放数}` | 播放量 |
|
|
123
|
+
| `${评论数}` | 评论数量 |
|
|
124
|
+
| `${发布时间}` | 发布时间(格式化) |
|
|
125
|
+
| `${图片数量}` | 图集/实况图片数量 |
|
|
126
|
+
| `${作者ID}` | 作者唯一标识ID |
|
|
127
|
+
| `${视频链接}` | 视频原始链接 |
|
|
128
|
+
| `${音乐标题}` | 音乐标题 |
|
|
129
|
+
| `${音乐作者}` | 音乐作者 |
|
|
130
|
+
|
|
131
|
+
## 依赖说明 (Dependencies)
|
|
132
|
+
### 音乐语音(可选)
|
|
133
|
+
若启用 `showMusicVoice`,请安装:
|
|
134
|
+
- `koishi-plugin-silk`:silk 编解码
|
|
135
|
+
- `koishi-plugin-ffmpeg`:音频重采样
|
|
136
|
+
### aria2 下载引擎(可选)
|
|
137
|
+
若启用 `downloadEngine: 'aria2'`,请安装并启动 aria2 服务,并安装 npm 包 `aria2`:
|
|
138
|
+
- 安装 aria2 服务端:https://github.com/aria2/aria2
|
|
139
|
+
- 安装 npm 客户端:`npm install aria2`
|
|
140
|
+
- 启动 RPC:`aria2c --enable-rpc --rpc-listen-all=true --rpc-allow-origin-all`
|
|
141
|
+
未满足条件时自动降级为内置下载,不影响正常使用。
|
|
142
|
+
### downloads 服务(可选)
|
|
143
|
+
若启用 `downloadEngine: 'downloads'`,请安装可选依赖 `koishi-plugin-downloads`,失败时回退到内置下载。
|
|
144
|
+
|
|
145
|
+
## 支持的平台 (Supported Platforms)
|
|
146
|
+
| 平台名称 | 关键词识别 | 解析能力 |
|
|
147
|
+
|----------|------------|----------|
|
|
148
|
+
| 哔哩哔哩 (B站) | bilibili, b23.tv, bilibili.com | 视频 |
|
|
149
|
+
| 抖音 | douyin, v.douyin.com | 短视频、图集、实况 |
|
|
150
|
+
| 快手 | kuaishou, v.kuaishou.com | 短视频、图集 |
|
|
151
|
+
| 小红书 | xiaohongshu, xhslink.com | 图文、视频 |
|
|
152
|
+
| 微博 | weibo, video.weibo.com | 视频、图集 |
|
|
153
|
+
| 剪映 / 即梦 | jianying, jimeng.jianying.com | 视频模板 |
|
|
154
|
+
| 今日头条 / 西瓜视频 | toutiao, ixigua.com | 短视频 |
|
|
155
|
+
| AcFun(A站) | acfun, acfun.cn | 视频 |
|
|
156
|
+
| 知乎 | zhihu, zhihu.com | 视频、回答 |
|
|
157
|
+
| 微视 | weishi, weishi.qq.com | 短视频 |
|
|
158
|
+
| 虎牙 | huya, huya.com | 直播、视频 |
|
|
159
|
+
| YouTube(油管) | youtube, youtu.be | 视频 |
|
|
160
|
+
| TikTok(国际版抖音) | tiktok, tiktok.com | 短视频 |
|
|
161
|
+
| 好看视频 | haokan, haokan.baidu.com | 短视频 |
|
|
162
|
+
| 美拍 | meipai, meipai.com | 短视频 |
|
|
163
|
+
| Twitter / X | twitter, x.com | 视频、图文 |
|
|
164
|
+
| Instagram | instagram, instagram.com | 图文、Reels |
|
|
165
|
+
| 豆包 | doubao (doubao.com/video) | 视频 |
|
|
166
|
+
| 皮皮搞笑 | pipigx, h5.pipigx.com | 短视频 |
|
|
167
|
+
| 皮皮虾 | pipixia, h5.pipix.com | 短视频 |
|
|
168
|
+
| 最右 | zuiyou, xiaochuankeji.cn | 短视频 |
|
|
169
|
+
| 梨视频 | video.li, pearvideo.com | 短视频 |
|
|
170
|
+
| 全民直播 | quanmin (quanmin.tv) | 直播 |
|
|
171
|
+
| 绿洲 (Oasis) | oasis.weibo.com | 视频、图文 |
|
|
172
|
+
| 视频号 (WeChat Channels) | channels.weixin.qq.com, weixin.qq.com/sph/ | 短视频 |
|
|
173
|
+
| 🔧 自定义平台 | 通过 `customPlatforms` 添加 | 取决于 API |
|
|
174
|
+
|
|
175
|
+
## 项目贡献者 (Contributors)
|
|
176
|
+
|
|
177
|
+
| 贡献者 (Contributor) | 贡献内容 (Contribution) |
|
|
178
|
+
|----------------------|-------------------------|
|
|
179
|
+
| Minecraft-1314 | 插件完整开发 (Complete plugin development) |
|
|
180
|
+
| ShiraiKuroko003 | 修复消息格式设置问题并且PR-1.2.5版本已修复 |
|
|
181
|
+
| cyavb | 提交功能建议-给自定义API添加KEY认证-已修复 |
|
|
182
|
+
| Keep785 | 提交Bug-无法正常关闭发送封面-已修复<br>提交Bug-解析问题-已修复 |
|
|
183
|
+
| dzt2008 + Apricityx | 提交Bug-会对非支持视频平台URL进行误解析-已修复 |
|
|
184
|
+
| JH-Ahua | BugPk-Api 支持 |
|
|
185
|
+
| shangxue | 灵感来源 |
|
|
186
|
+
|
|
187
|
+
(欢迎通过 Issues 或 PR 加入贡献者列表)
|
|
188
|
+
|
|
189
|
+
## 许可协议 (License)
|
|
190
|
+
|
|
191
|
+
本项目采用 MIT 许可证,详情参见 [LICENSE](LICENSE) 文件。
|
|
192
|
+
|
|
193
|
+
This project is licensed under the MIT License, see the [LICENSE](LICENSE) file for details.
|
|
194
|
+
|
|
195
|
+
## 支持我们 (Support Us)
|
|
196
|
+
|
|
197
|
+
如果这个项目对您有帮助,欢迎点亮右上角的 Star ⭐ 支持我们!
|
|
198
|
+
|
|
199
|
+
If this project is helpful to you, please feel free to star it in the upper right corner ⭐ to support us!
|