@zhin.js/plugin-music 1.0.25 → 1.1.0
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/README.md +1 -0
- package/commands/cookie/delete/[source].js +23 -0
- package/commands/cookie/delete/[source].ts +27 -0
- package/commands/cookie/get/[source].js +29 -0
- package/commands/cookie/get/[source].ts +33 -0
- package/commands/cookie/set/[source].js +23 -0
- package/commands/cookie/set/[source].ts +27 -0
- package/commands/cookie/status.js +25 -0
- package/commands/cookie/status.ts +25 -0
- package/commands/login/[platform].js +64 -0
- package/commands/login/[platform].ts +76 -0
- package/commands//347/202/271/346/255/214/[keyword].js +62 -0
- package/commands//347/202/271/346/255/214/[keyword].ts +74 -0
- package/components/share-music.js +24 -0
- package/components/share-music.ts +29 -0
- package/lib/config.d.ts +3 -3
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +36 -7
- package/lib/config.js.map +1 -1
- package/lib/credential-store.d.ts +34 -0
- package/lib/credential-store.d.ts.map +1 -0
- package/lib/credential-store.js +111 -0
- package/lib/credential-store.js.map +1 -0
- package/lib/index.d.ts +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -112
- package/lib/index.js.map +1 -1
- package/lib/login/index.d.ts +20 -0
- package/lib/login/index.d.ts.map +1 -0
- package/lib/login/index.js +100 -0
- package/lib/login/index.js.map +1 -0
- package/lib/login/netease.d.ts +6 -0
- package/lib/login/netease.d.ts.map +1 -0
- package/lib/login/netease.js +58 -0
- package/lib/login/netease.js.map +1 -0
- package/lib/login/qq.d.ts +7 -0
- package/lib/login/qq.d.ts.map +1 -0
- package/lib/login/qq.js +139 -0
- package/lib/login/qq.js.map +1 -0
- package/lib/login/types.d.ts +19 -0
- package/lib/login/types.d.ts.map +1 -0
- package/lib/login/types.js +11 -0
- package/lib/login/types.js.map +1 -0
- package/lib/music-lib.d.ts +31 -0
- package/lib/music-lib.d.ts.map +1 -0
- package/lib/music-lib.js +59 -0
- package/lib/music-lib.js.map +1 -0
- package/lib/session.d.ts +29 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/session.js +39 -0
- package/lib/session.js.map +1 -0
- package/lib/sources/index.d.ts +2 -1
- package/lib/sources/index.d.ts.map +1 -1
- package/lib/sources/index.js +6 -2
- package/lib/sources/index.js.map +1 -1
- package/lib/sources/kugou.d.ts +6 -0
- package/lib/sources/kugou.d.ts.map +1 -0
- package/lib/sources/kugou.js +54 -0
- package/lib/sources/kugou.js.map +1 -0
- package/lib/sources/kuwo.d.ts +7 -0
- package/lib/sources/kuwo.d.ts.map +1 -0
- package/lib/sources/kuwo.js +69 -0
- package/lib/sources/kuwo.js.map +1 -0
- package/lib/sources/netease.d.ts +1 -1
- package/lib/sources/netease.d.ts.map +1 -1
- package/lib/sources/netease.js +22 -6
- package/lib/sources/netease.js.map +1 -1
- package/lib/sources/qq.d.ts +1 -14
- package/lib/sources/qq.d.ts.map +1 -1
- package/lib/sources/qq.js +114 -82
- package/lib/sources/qq.js.map +1 -1
- package/lib/types.d.ts +4 -24
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +0 -1
- package/lib/types.js.map +1 -1
- package/middlewares/music-selection.js +46 -0
- package/middlewares/music-selection.ts +56 -0
- package/package.json +62 -13
- package/plugin.js +31 -0
- package/schema.json +6 -0
- package/src/config.ts +54 -30
- package/src/credential-store.ts +169 -0
- package/src/index.ts +45 -0
- package/src/login/index.ts +135 -0
- package/src/login/netease.ts +69 -0
- package/src/login/qq.ts +154 -0
- package/src/login/types.ts +27 -0
- package/src/music-lib.ts +70 -0
- package/src/session.ts +60 -0
- package/src/sources/index.ts +12 -8
- package/src/sources/kugou.ts +78 -0
- package/src/sources/kuwo.ts +94 -0
- package/src/sources/netease.ts +29 -11
- package/src/sources/qq.ts +158 -126
- package/src/types.ts +6 -28
- package/tools/music-search.js +21 -0
- package/tools/music-search.ts +27 -0
- package/tools/music-share.js +20 -0
- package/tools/music-share.ts +24 -0
- package/lib/async-jsx.d.ts +0 -19
- package/lib/async-jsx.d.ts.map +0 -1
- package/lib/async-jsx.js +0 -48
- package/lib/async-jsx.js.map +0 -1
- package/src/async-jsx.ts +0 -54
- package/src/index.tsx +0 -130
- /package/{skills/music/SKILL.md → agent/skills/music.md} +0 -0
package/src/login/qq.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { QrLoginProvider, QrCreateResult, QrPollResult } from './types.js';
|
|
2
|
+
import { extractSetCookies } from './types.js';
|
|
3
|
+
|
|
4
|
+
function hashPtqrtoken(qrsig: string): number {
|
|
5
|
+
let hash = 0;
|
|
6
|
+
for (let i = 0; i < qrsig.length; i++) {
|
|
7
|
+
hash += (hash << 5) + qrsig.charCodeAt(i);
|
|
8
|
+
}
|
|
9
|
+
return hash & 0x7fffffff;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const APPID = '716027609';
|
|
13
|
+
const DAID = '383';
|
|
14
|
+
const PT_3RD_AID = '100497308';
|
|
15
|
+
const REFERER = 'https://xui.ptlogin2.qq.com/';
|
|
16
|
+
const USER_AGENT =
|
|
17
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
18
|
+
|
|
19
|
+
function commonHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
20
|
+
return { 'User-Agent': USER_AGENT, Referer: REFERER, ...extra };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class QQLoginProvider implements QrLoginProvider {
|
|
24
|
+
async createQr(): Promise<QrCreateResult> {
|
|
25
|
+
// Step 1: 访问 xlogin 页面获取 pt_login_sig
|
|
26
|
+
const xloginUrl = new URL('https://xui.ptlogin2.qq.com/cgi-bin/xlogin');
|
|
27
|
+
xloginUrl.searchParams.set('appid', APPID);
|
|
28
|
+
xloginUrl.searchParams.set('daid', DAID);
|
|
29
|
+
xloginUrl.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
30
|
+
xloginUrl.searchParams.set('s_url', 'https://y.qq.com/');
|
|
31
|
+
xloginUrl.searchParams.set('style', '33');
|
|
32
|
+
xloginUrl.searchParams.set('low_login', '0');
|
|
33
|
+
xloginUrl.searchParams.set('qlogin_auto_login', '1');
|
|
34
|
+
|
|
35
|
+
const xloginResp = await fetch(xloginUrl.toString(), {
|
|
36
|
+
headers: commonHeaders(),
|
|
37
|
+
redirect: 'manual',
|
|
38
|
+
});
|
|
39
|
+
const xloginCookies = extractSetCookies(xloginResp.headers);
|
|
40
|
+
const loginSigMatch = xloginCookies.match(/pt_login_sig=([^;]+)/);
|
|
41
|
+
const loginSig = loginSigMatch?.[1] ?? '';
|
|
42
|
+
|
|
43
|
+
// Step 2: 获取二维码图片 + qrsig cookie
|
|
44
|
+
const qrUrl = new URL('https://ssl.ptlogin2.qq.com/ptqrshow');
|
|
45
|
+
qrUrl.searchParams.set('appid', APPID);
|
|
46
|
+
qrUrl.searchParams.set('e', '2');
|
|
47
|
+
qrUrl.searchParams.set('l', 'M');
|
|
48
|
+
qrUrl.searchParams.set('s', '3');
|
|
49
|
+
qrUrl.searchParams.set('d', '72');
|
|
50
|
+
qrUrl.searchParams.set('v', '4');
|
|
51
|
+
qrUrl.searchParams.set('t', String(Math.random()));
|
|
52
|
+
qrUrl.searchParams.set('daid', DAID);
|
|
53
|
+
qrUrl.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
54
|
+
|
|
55
|
+
const qrResp = await fetch(qrUrl.toString(), {
|
|
56
|
+
headers: commonHeaders({
|
|
57
|
+
Cookie: loginSig ? `pt_login_sig=${loginSig}` : '',
|
|
58
|
+
}),
|
|
59
|
+
redirect: 'manual',
|
|
60
|
+
});
|
|
61
|
+
const qrCookies = extractSetCookies(qrResp.headers);
|
|
62
|
+
const qrsigMatch = qrCookies.match(/qrsig=([^;]+)/);
|
|
63
|
+
const qrsig = qrsigMatch?.[1] ?? '';
|
|
64
|
+
|
|
65
|
+
const buffer = Buffer.from(await qrResp.arrayBuffer());
|
|
66
|
+
const imageBase64 = buffer.toString('base64');
|
|
67
|
+
const ptqrtoken = String(hashPtqrtoken(qrsig));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
imageSegment: {
|
|
71
|
+
type: 'image',
|
|
72
|
+
data: { base64: imageBase64, mime_type: 'image/png' },
|
|
73
|
+
},
|
|
74
|
+
pollData: { ptqrtoken, qrsig, loginSig },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async pollQr(pollData: Record<string, string>): Promise<QrPollResult> {
|
|
79
|
+
const { ptqrtoken, qrsig, loginSig } = pollData;
|
|
80
|
+
const url = new URL('https://ssl.ptlogin2.qq.com/ptqrlogin');
|
|
81
|
+
url.searchParams.set('u1', 'https://y.qq.com/');
|
|
82
|
+
url.searchParams.set('ptqrtoken', ptqrtoken!);
|
|
83
|
+
url.searchParams.set('ptredirect', '0');
|
|
84
|
+
url.searchParams.set('h', '1');
|
|
85
|
+
url.searchParams.set('t', '1');
|
|
86
|
+
url.searchParams.set('g', '1');
|
|
87
|
+
url.searchParams.set('from_ui', '1');
|
|
88
|
+
url.searchParams.set('ptlang', '2052');
|
|
89
|
+
url.searchParams.set('action', `0-0-${Date.now()}`);
|
|
90
|
+
url.searchParams.set('js_ver', '24112817');
|
|
91
|
+
url.searchParams.set('js_type', '1');
|
|
92
|
+
url.searchParams.set('login_sig', loginSig ?? '');
|
|
93
|
+
url.searchParams.set('pt_uistyle', '40');
|
|
94
|
+
url.searchParams.set('aid', APPID);
|
|
95
|
+
url.searchParams.set('daid', DAID);
|
|
96
|
+
url.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
97
|
+
url.searchParams.set('has_signing', '1');
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const cookieParts = [`qrsig=${qrsig}`];
|
|
101
|
+
if (loginSig) cookieParts.push(`pt_login_sig=${loginSig}`);
|
|
102
|
+
|
|
103
|
+
const response = await fetch(url.toString(), {
|
|
104
|
+
headers: commonHeaders({ Cookie: cookieParts.join('; ') }),
|
|
105
|
+
redirect: 'manual',
|
|
106
|
+
});
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
|
|
109
|
+
if (text.includes('二维码未失效')) {
|
|
110
|
+
return { status: 'waiting', message: '等待扫码...' };
|
|
111
|
+
}
|
|
112
|
+
if (text.includes('二维码认证中') || text.includes('已扫码')) {
|
|
113
|
+
return { status: 'scanned', message: '已扫码,请在手机上确认登录' };
|
|
114
|
+
}
|
|
115
|
+
if (text.includes('二维码已失效')) {
|
|
116
|
+
return { status: 'expired', message: '二维码已过期,请重新发起登录' };
|
|
117
|
+
}
|
|
118
|
+
if (text.includes('登录成功') || text.includes('ptsigx')) {
|
|
119
|
+
return this.handleLoginSuccess(text, response.headers);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!text) {
|
|
123
|
+
return { status: 'error', message: `服务端返回空响应 (HTTP ${response.status})` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { status: 'waiting', message: '等待扫码...' };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
status: 'error',
|
|
130
|
+
message: `轮询失败:${err instanceof Error ? err.message : String(err)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async handleLoginSuccess(text: string, headers: Headers): Promise<QrPollResult> {
|
|
136
|
+
const redirectMatch = text.match(/'(https?:\/\/[^']+)'/);
|
|
137
|
+
if (redirectMatch) {
|
|
138
|
+
const redirectResp = await fetch(redirectMatch[1]!, {
|
|
139
|
+
headers: commonHeaders(),
|
|
140
|
+
redirect: 'manual',
|
|
141
|
+
});
|
|
142
|
+
const cookie = extractSetCookies(redirectResp.headers);
|
|
143
|
+
if (cookie) {
|
|
144
|
+
return { status: 'confirmed', message: '登录成功', cookie };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const cookie = extractSetCookies(headers);
|
|
148
|
+
return {
|
|
149
|
+
status: cookie ? 'confirmed' : 'error',
|
|
150
|
+
message: cookie ? '登录成功' : '登录成功但获取 cookie 失败',
|
|
151
|
+
cookie: cookie || undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type QrLoginSource = 'qq' | 'netease';
|
|
2
|
+
|
|
3
|
+
export interface QrPollResult {
|
|
4
|
+
status: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'error';
|
|
5
|
+
message: string;
|
|
6
|
+
cookie?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface QrCreateResult {
|
|
10
|
+
imageSegment: { type: 'image'; data: Record<string, unknown> };
|
|
11
|
+
pollData: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QrLoginProvider {
|
|
15
|
+
createQr(): Promise<QrCreateResult>;
|
|
16
|
+
pollQr(pollData: Record<string, string>): Promise<QrPollResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractSetCookies(headers: Headers): string {
|
|
20
|
+
const raw = headers.getSetCookie?.() ?? [];
|
|
21
|
+
const cookies: string[] = [];
|
|
22
|
+
for (const entry of raw) {
|
|
23
|
+
const match = entry.match(/^([^;]+)/);
|
|
24
|
+
if (match) cookies.push(match[1]!);
|
|
25
|
+
}
|
|
26
|
+
return cookies.join('; ');
|
|
27
|
+
}
|
package/src/music-lib.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { musicServices } from './sources/index.js';
|
|
2
|
+
import { sourceConfigMap, SOURCE_DISPLAY_NAME, formatDuration } from './config.js';
|
|
3
|
+
import type { MusicInfo, MusicDetail, MusicSource } from './types.js';
|
|
4
|
+
|
|
5
|
+
export async function searchMusic(
|
|
6
|
+
keyword: string,
|
|
7
|
+
source?: MusicSource,
|
|
8
|
+
limit = 5,
|
|
9
|
+
): Promise<{
|
|
10
|
+
success: true;
|
|
11
|
+
keyword: string;
|
|
12
|
+
source: MusicSource;
|
|
13
|
+
results: MusicInfo[];
|
|
14
|
+
total: number;
|
|
15
|
+
}> {
|
|
16
|
+
const s: MusicSource = source ?? 'qq';
|
|
17
|
+
const service = musicServices[s];
|
|
18
|
+
const results = await service.search(keyword, limit);
|
|
19
|
+
return {
|
|
20
|
+
success: true,
|
|
21
|
+
keyword,
|
|
22
|
+
source: s,
|
|
23
|
+
results,
|
|
24
|
+
total: results.length,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function shareMusicDetail(id: string, source: MusicSource) {
|
|
29
|
+
const service = musicServices[source];
|
|
30
|
+
if (!service) {
|
|
31
|
+
return { success: false as const, error: `不支持的音乐源: ${source}` };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const detail = await service.getDetail(id);
|
|
35
|
+
return { success: true as const, music: detail };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
success: false as const,
|
|
39
|
+
error: error instanceof Error ? error.message : String(error),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatSearchResults(results: readonly MusicInfo[], source: MusicSource): string {
|
|
45
|
+
const sourceName = SOURCE_DISPLAY_NAME[source] ?? source;
|
|
46
|
+
if (results.length === 0) return `[${sourceName}] 未找到相关歌曲`;
|
|
47
|
+
const lines = results.map((m, i) => {
|
|
48
|
+
const parts = [m.title];
|
|
49
|
+
if (m.artist) parts.push(m.artist);
|
|
50
|
+
const duration = m.duration ? ` [${formatDuration(m.duration)}]` : '';
|
|
51
|
+
return `${i + 1}. ${parts.join(' - ')}${duration}`;
|
|
52
|
+
});
|
|
53
|
+
return `[${sourceName}] 搜索结果:\n${lines.join('\n')}\n\n回复序号播放,回复"取消"取消选择`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildMusicShareSegment(detail: MusicDetail) {
|
|
57
|
+
const config = sourceConfigMap[detail.source];
|
|
58
|
+
return {
|
|
59
|
+
type: 'share' as const,
|
|
60
|
+
data: {
|
|
61
|
+
title: detail.title,
|
|
62
|
+
url: detail.url,
|
|
63
|
+
image: detail.image,
|
|
64
|
+
audio: detail.audio,
|
|
65
|
+
duration: detail.duration,
|
|
66
|
+
artist: detail.artist,
|
|
67
|
+
config,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { MusicInfo, MusicSource } from './types.js';
|
|
2
|
+
|
|
3
|
+
export interface PendingSearch {
|
|
4
|
+
readonly results: readonly MusicInfo[];
|
|
5
|
+
readonly source: MusicSource;
|
|
6
|
+
readonly keyword: string;
|
|
7
|
+
readonly timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SEARCH_TIMEOUT_MS = 3 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
const pendingSessions = new Map<string, PendingSearch>();
|
|
13
|
+
|
|
14
|
+
export function sessionKey(
|
|
15
|
+
endpointId: string,
|
|
16
|
+
conversationId: string,
|
|
17
|
+
senderId: string,
|
|
18
|
+
): string {
|
|
19
|
+
return `${endpointId}:${conversationId}:${senderId}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveMessageIds(input: {
|
|
23
|
+
metadata?: Record<string, unknown>;
|
|
24
|
+
conversation?: { id?: unknown; endpoint?: { id?: unknown } };
|
|
25
|
+
sender?: { id?: unknown };
|
|
26
|
+
}): { endpointId: string; conversationId: string; senderId: string } | null {
|
|
27
|
+
const meta = input.metadata ?? {};
|
|
28
|
+
const endpointId = String(meta.endpointId ?? input.conversation?.endpoint?.id ?? '');
|
|
29
|
+
const conversationId = String(input.conversation?.id ?? '');
|
|
30
|
+
const senderId = String(input.sender?.id ?? '');
|
|
31
|
+
if (!endpointId || !conversationId || !senderId) return null;
|
|
32
|
+
return { endpointId, conversationId, senderId };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setPending(key: string, search: PendingSearch): void {
|
|
36
|
+
pendingSessions.set(key, search);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getPending(key: string): PendingSearch | undefined {
|
|
40
|
+
const session = pendingSessions.get(key);
|
|
41
|
+
if (!session) return undefined;
|
|
42
|
+
if (Date.now() - session.timestamp > SEARCH_TIMEOUT_MS) {
|
|
43
|
+
pendingSessions.delete(key);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearPending(key: string): void {
|
|
50
|
+
pendingSessions.delete(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cleanExpired(): void {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
for (const [key, session] of pendingSessions) {
|
|
56
|
+
if (now - session.timestamp > SEARCH_TIMEOUT_MS) {
|
|
57
|
+
pendingSessions.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/sources/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { QQMusicService } from './qq.js';
|
|
2
|
+
import { NeteaseMusicService } from './netease.js';
|
|
3
|
+
import { KuwoMusicService } from './kuwo.js';
|
|
4
|
+
import { KugouMusicService } from './kugou.js';
|
|
5
|
+
import type { MusicSource, MusicSearchService } from '../types.js';
|
|
5
6
|
|
|
6
|
-
/** 音乐源服务映射 */
|
|
7
7
|
export const musicServices: Record<MusicSource, MusicSearchService> = {
|
|
8
8
|
qq: new QQMusicService(),
|
|
9
9
|
netease: new NeteaseMusicService(),
|
|
10
|
-
|
|
10
|
+
kuwo: new KuwoMusicService(),
|
|
11
|
+
kugou: new KugouMusicService(),
|
|
12
|
+
};
|
|
11
13
|
|
|
12
|
-
export * from './qq.js'
|
|
13
|
-
export * from './netease.js'
|
|
14
|
+
export * from './qq.js';
|
|
15
|
+
export * from './netease.js';
|
|
16
|
+
export * from './kuwo.js';
|
|
17
|
+
export * from './kugou.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface KugouSearchItem {
|
|
4
|
+
hash: string;
|
|
5
|
+
songname: string;
|
|
6
|
+
singername: string;
|
|
7
|
+
album_name?: string;
|
|
8
|
+
duration: number;
|
|
9
|
+
album_id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class KugouMusicService implements MusicSearchService {
|
|
13
|
+
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL('https://mobilecdn.kugou.com/api/v3/search/song');
|
|
16
|
+
url.searchParams.set('format', 'json');
|
|
17
|
+
url.searchParams.set('keyword', keyword);
|
|
18
|
+
url.searchParams.set('page', '1');
|
|
19
|
+
url.searchParams.set('pagesize', String(limit));
|
|
20
|
+
url.searchParams.set('showtype', '1');
|
|
21
|
+
|
|
22
|
+
const response = await fetch(url, { method: 'GET' });
|
|
23
|
+
const data = (await response.json()) as {
|
|
24
|
+
data?: { info?: KugouSearchItem[] };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const items = data.data?.info ?? [];
|
|
28
|
+
return items.slice(0, limit).map((item) => ({
|
|
29
|
+
id: item.hash,
|
|
30
|
+
source: 'kugou' as const,
|
|
31
|
+
title: item.songname?.replace(/<[^>]+>/g, '') ?? '',
|
|
32
|
+
artist: item.singername,
|
|
33
|
+
album: item.album_name,
|
|
34
|
+
duration: item.duration,
|
|
35
|
+
url: `https://www.kugou.com/song/#hash=${item.hash}`,
|
|
36
|
+
}));
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getDetail(id: string): Promise<MusicDetail> {
|
|
43
|
+
const url = new URL('https://wwwapi.kugou.com/yy/index.php');
|
|
44
|
+
url.searchParams.set('r', 'play/getdata');
|
|
45
|
+
url.searchParams.set('hash', id);
|
|
46
|
+
url.searchParams.set('mid', 'music');
|
|
47
|
+
url.searchParams.set('platid', '4');
|
|
48
|
+
|
|
49
|
+
const response = await fetch(url, { method: 'GET' });
|
|
50
|
+
const result = (await response.json()) as {
|
|
51
|
+
data?: {
|
|
52
|
+
song_name?: string;
|
|
53
|
+
author_name?: string;
|
|
54
|
+
album_name?: string;
|
|
55
|
+
img?: string;
|
|
56
|
+
play_url?: string;
|
|
57
|
+
timelength?: number;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const info = result.data;
|
|
61
|
+
if (!info) throw new Error('Music not found');
|
|
62
|
+
|
|
63
|
+
const audio = info.play_url ?? '';
|
|
64
|
+
if (!audio) throw new Error('Audio URL not found');
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
source: 'kugou',
|
|
69
|
+
title: info.song_name ?? '',
|
|
70
|
+
artist: info.author_name,
|
|
71
|
+
album: info.album_name,
|
|
72
|
+
url: `https://www.kugou.com/song/#hash=${id}`,
|
|
73
|
+
image: info.img ?? '',
|
|
74
|
+
audio,
|
|
75
|
+
duration: info.timelength ? Math.floor(info.timelength / 1000) : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface KuwoSearchItem {
|
|
4
|
+
rid: number;
|
|
5
|
+
name: string;
|
|
6
|
+
artist: string;
|
|
7
|
+
album: string;
|
|
8
|
+
duration: number;
|
|
9
|
+
pic?: string;
|
|
10
|
+
albumpic?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class KuwoMusicService implements MusicSearchService {
|
|
14
|
+
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL('https://search.kuwo.cn/r.s');
|
|
17
|
+
url.searchParams.set('client', 'kt');
|
|
18
|
+
url.searchParams.set('all', keyword);
|
|
19
|
+
url.searchParams.set('pn', '0');
|
|
20
|
+
url.searchParams.set('rn', String(limit));
|
|
21
|
+
url.searchParams.set('ft', 'music');
|
|
22
|
+
url.searchParams.set('encoding', 'utf8');
|
|
23
|
+
url.searchParams.set('rformat', 'json');
|
|
24
|
+
url.searchParams.set('mobi', '1');
|
|
25
|
+
url.searchParams.set('vipver', '1');
|
|
26
|
+
|
|
27
|
+
const response = await fetch(url, { method: 'GET' });
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
const data = JSON.parse(text.replace(/'/g, '"')) as {
|
|
30
|
+
abslist?: KuwoSearchItem[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const items = data.abslist ?? [];
|
|
34
|
+
return items.slice(0, limit).map((item) => ({
|
|
35
|
+
id: String(item.rid),
|
|
36
|
+
source: 'kuwo' as const,
|
|
37
|
+
title: item.name?.replace(/ /g, ' ') ?? '',
|
|
38
|
+
artist: item.artist?.replace(/ /g, ' '),
|
|
39
|
+
album: item.album?.replace(/ /g, ' '),
|
|
40
|
+
duration: item.duration,
|
|
41
|
+
url: `https://www.kuwo.cn/play_detail/${item.rid}`,
|
|
42
|
+
}));
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getDetail(id: string): Promise<MusicDetail> {
|
|
49
|
+
const url = new URL('https://www.kuwo.cn/api/www/music/musicInfo');
|
|
50
|
+
url.searchParams.set('mid', id);
|
|
51
|
+
url.searchParams.set('httpsStatus', '1');
|
|
52
|
+
|
|
53
|
+
const response = await fetch(url, {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: { Referer: 'https://www.kuwo.cn/', csrf: id },
|
|
56
|
+
});
|
|
57
|
+
const result = (await response.json()) as {
|
|
58
|
+
data?: {
|
|
59
|
+
name?: string;
|
|
60
|
+
artist?: string;
|
|
61
|
+
album?: string;
|
|
62
|
+
pic?: string;
|
|
63
|
+
albumpic?: string;
|
|
64
|
+
duration?: number;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
const info = result.data;
|
|
68
|
+
if (!info) throw new Error('Music not found');
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
source: 'kuwo',
|
|
73
|
+
title: info.name ?? '',
|
|
74
|
+
artist: info.artist,
|
|
75
|
+
album: info.album,
|
|
76
|
+
url: `https://www.kuwo.cn/play_detail/${id}`,
|
|
77
|
+
image: info.pic ?? info.albumpic ?? '',
|
|
78
|
+
audio: await this.getAudioUrl(id),
|
|
79
|
+
duration: info.duration,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getAudioUrl(id: string): Promise<string> {
|
|
84
|
+
const url = `https://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${id}&format=mp3`;
|
|
85
|
+
const response = await fetch(url, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
redirect: 'follow',
|
|
88
|
+
});
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
const match = /http[^\s]+/.exec(text);
|
|
91
|
+
if (match) return match[0];
|
|
92
|
+
throw new Error('Audio URL not found');
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/sources/netease.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// plugins/utils/music/src/sources/netease.ts
|
|
2
|
-
import type { MusicSearchService, MusicDetail,MusicInfo, Music163 } from '../types.js'
|
|
2
|
+
import type { MusicSearchService, MusicDetail, MusicInfo, Music163 } from '../types.js';
|
|
3
|
+
import { getCredential } from '../credential-store.js';
|
|
3
4
|
|
|
4
5
|
/** 网易云音乐搜索服务 */
|
|
5
6
|
export class NeteaseMusicService implements MusicSearchService {
|
|
@@ -70,15 +71,32 @@ export class NeteaseMusicService implements MusicSearchService {
|
|
|
70
71
|
* @param metingAPI Meting API 地址(可选)
|
|
71
72
|
* @returns 音频直链 URL
|
|
72
73
|
*/
|
|
73
|
-
async getAudioUrl(id: string
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
async getAudioUrl(id: string): Promise<string> {
|
|
75
|
+
const cookie = await getCredential('netease', 'cookie');
|
|
76
|
+
if (cookie) {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(
|
|
79
|
+
`https://music.163.com/api/song/enhance/player/url?ids=[${id}]&br=320000`,
|
|
80
|
+
{
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers: { Cookie: cookie },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
const result = await response.json() as {
|
|
86
|
+
data?: Array<{ url?: string | null }>;
|
|
87
|
+
};
|
|
88
|
+
const audioUrl = result.data?.[0]?.url;
|
|
89
|
+
if (audioUrl) return audioUrl;
|
|
90
|
+
} catch {
|
|
91
|
+
// cookie-based fetch failed, fall through to public API
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const apiUrl = 'https://api.injahow.cn/meting/';
|
|
95
|
+
const url = `${apiUrl}?type=url&id=${id}`;
|
|
96
|
+
const response = await fetch(url, { method: 'GET' });
|
|
97
|
+
const data = await response.json() as { url?: string; data?: { url?: string } };
|
|
98
|
+
const audioUrl = data.url ?? data.data?.url;
|
|
99
|
+
if (!audioUrl) throw new Error('Audio URL not found');
|
|
100
|
+
return audioUrl;
|
|
83
101
|
}
|
|
84
102
|
}
|