代理kuwo
This commit is contained in:
108
src/app/api/music/proxy/route.ts
Normal file
108
src/app/api/music/proxy/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 代理音频流
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 url 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 安全检查:只允许代理音乐平台的音频和图片 CDN
|
||||
const allowedDomains = [
|
||||
'sycdn.kuwo.cn',
|
||||
'kwcdn.kuwo.cn',
|
||||
'img1.kwcdn.kuwo.cn',
|
||||
'img2.kwcdn.kuwo.cn',
|
||||
'img3.kwcdn.kuwo.cn',
|
||||
'img4.kwcdn.kuwo.cn',
|
||||
'music.163.com',
|
||||
'y.qq.com',
|
||||
'ws.stream.qqmusic.qq.com',
|
||||
'isure.stream.qqmusic.qq.com',
|
||||
'dl.stream.qqmusic.qq.com',
|
||||
];
|
||||
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: '无效的 URL' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = allowedDomains.some(domain =>
|
||||
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(`拒绝代理音频请求: ${urlObj.hostname}`);
|
||||
return NextResponse.json(
|
||||
{ error: '不允许的目标域名' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 发起请求获取音频流
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'http://www.kuwo.cn/',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取音频失败' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取响应头
|
||||
const contentType = response.headers.get('content-type') || 'audio/mpeg';
|
||||
const contentLength = response.headers.get('content-length');
|
||||
|
||||
// 创建响应头
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
|
||||
if (contentLength) {
|
||||
headers['Content-Length'] = contentLength;
|
||||
}
|
||||
|
||||
// 支持 Range 请求(用于音频拖动)
|
||||
const range = request.headers.get('range');
|
||||
if (range) {
|
||||
headers['Accept-Ranges'] = 'bytes';
|
||||
}
|
||||
|
||||
// 返回音频流
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('代理音频失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '代理请求失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,17 +79,56 @@ async function executeMethod(
|
||||
let url = config.url;
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// 先将 variables 中的值转换为可执行的变量
|
||||
const evalContext: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
// 尝试将字符串转换为数字(如果可能)
|
||||
const numValue = Number(value);
|
||||
evalContext[key] = isNaN(numValue) ? value : numValue;
|
||||
}
|
||||
|
||||
// 递归处理对象中的模板变量
|
||||
function processTemplateValue(value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
// 处理包含模板变量的表达式
|
||||
const expressionRegex = /\{\{(.+?)\}\}/g;
|
||||
return value.replace(expressionRegex, (match, expression) => {
|
||||
try {
|
||||
// 创建一个函数来执行表达式,传入所有变量作为参数
|
||||
// eslint-disable-next-line no-new-func
|
||||
const func = new Function(...Object.keys(evalContext), `return ${expression}`);
|
||||
const result = func(...Object.values(evalContext));
|
||||
return String(result);
|
||||
} catch (err) {
|
||||
console.error(`[executeMethod] 执行表达式失败: ${expression}`, err);
|
||||
return '0'; // 默认值
|
||||
}
|
||||
});
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.map(item => processTemplateValue(item));
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
const result: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
result[k] = processTemplateValue(v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 处理 URL 参数
|
||||
if (config.params) {
|
||||
for (const [key, value] of Object.entries(config.params)) {
|
||||
let processedValue = String(value);
|
||||
// 替换所有模板变量
|
||||
for (const [varName, varValue] of Object.entries(variables)) {
|
||||
processedValue = processedValue.replace(`{{${varName}}}`, varValue);
|
||||
}
|
||||
params[key] = processedValue;
|
||||
params[key] = processTemplateValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 POST body
|
||||
let processedBody = config.body;
|
||||
if (config.body) {
|
||||
processedBody = processTemplateValue(config.body);
|
||||
}
|
||||
|
||||
// 3. 构建完整 URL
|
||||
if (config.method === 'GET' && Object.keys(params).length > 0) {
|
||||
const urlObj = new URL(url);
|
||||
@@ -105,8 +144,8 @@ async function executeMethod(
|
||||
headers: config.headers || {},
|
||||
};
|
||||
|
||||
if (config.method === 'POST' && config.body) {
|
||||
requestOptions.body = JSON.stringify(config.body);
|
||||
if (config.method === 'POST' && processedBody) {
|
||||
requestOptions.body = JSON.stringify(processedBody);
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -123,10 +162,31 @@ async function executeMethod(
|
||||
const transformFn = eval(`(${config.transform})`);
|
||||
data = transformFn(data);
|
||||
} catch (err) {
|
||||
console.error('Transform 函数执行失败:', err);
|
||||
console.error('[executeMethod] Transform 函数执行失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理酷我音乐的图片 URL(转换为代理 URL)
|
||||
if (platform === 'kuwo') {
|
||||
const processKuwoImages = (obj: any): any => {
|
||||
if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) {
|
||||
// 将 HTTP 图片 URL 转换为代理 URL
|
||||
return `/api/music/proxy?url=${encodeURIComponent(obj)}`;
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(item => processKuwoImages(item));
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = processKuwoImages(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
data = processKuwoImages(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -231,7 +291,7 @@ export async function GET(request: NextRequest) {
|
||||
// 搜索歌曲
|
||||
const platform = searchParams.get('platform');
|
||||
const keyword = searchParams.get('keyword');
|
||||
const page = searchParams.get('page') || '0';
|
||||
const page = searchParams.get('page') || '1';
|
||||
const pageSize = searchParams.get('pageSize') || '20';
|
||||
|
||||
if (!platform || !keyword) {
|
||||
@@ -248,11 +308,15 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(cached.data);
|
||||
}
|
||||
|
||||
// 注意:不同平台可能使用不同的变量名
|
||||
// 统一传递 keyword, page, pageSize, limit (limit = pageSize)
|
||||
const data = await executeMethod(baseUrl, platform, 'search', {
|
||||
keyword,
|
||||
page,
|
||||
pageSize,
|
||||
limit: pageSize, // 有些平台使用 limit 而不是 pageSize
|
||||
});
|
||||
|
||||
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return NextResponse.json(data);
|
||||
|
||||
Reference in New Issue
Block a user