Upload [...path].mjs
Browse files- api/proxy/[...path].mjs +451 -0
api/proxy/[...path].mjs
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// /api/proxy/[...path].mjs - Vercel Serverless Function (ES Module)
|
| 2 |
+
|
| 3 |
+
import fetch from 'node-fetch';
|
| 4 |
+
import { URL } from 'url'; // 使用 Node.js 内置 URL 处理
|
| 5 |
+
|
| 6 |
+
// --- 配置 (从环境变量读取) ---
|
| 7 |
+
const DEBUG_ENABLED = process.env.DEBUG === 'true';
|
| 8 |
+
const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // 默认 24 小时
|
| 9 |
+
const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // 默认 5 层
|
| 10 |
+
|
| 11 |
+
// --- User Agent 处理 ---
|
| 12 |
+
// 默认 User Agent 列表
|
| 13 |
+
let USER_AGENTS = [
|
| 14 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
| 15 |
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
| 16 |
+
];
|
| 17 |
+
// 尝试从环境变量读取并解析 USER_AGENTS_JSON
|
| 18 |
+
try {
|
| 19 |
+
const agentsJsonString = process.env.USER_AGENTS_JSON;
|
| 20 |
+
if (agentsJsonString) {
|
| 21 |
+
const parsedAgents = JSON.parse(agentsJsonString);
|
| 22 |
+
// 检查解析结果是否为非空数组
|
| 23 |
+
if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
|
| 24 |
+
USER_AGENTS = parsedAgents; // 使用环境变量中的数组
|
| 25 |
+
console.log(`[代理日志] 已从环境变量加载 ${USER_AGENTS.length} 个 User Agent。`);
|
| 26 |
+
} else {
|
| 27 |
+
console.warn("[代理日志] 环境变量 USER_AGENTS_JSON 不是有效的非空数组,使用默认值。");
|
| 28 |
+
}
|
| 29 |
+
} else {
|
| 30 |
+
console.log("[代理日志] 未设置环境变量 USER_AGENTS_JSON,使用默认 User Agent。");
|
| 31 |
+
}
|
| 32 |
+
} catch (e) {
|
| 33 |
+
// 如果 JSON 解析失败,记录错误并使用默认值
|
| 34 |
+
console.error(`[代理日志] 解析环境变量 USER_AGENTS_JSON 出错: ${e.message}。使用默认 User Agent。`);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// 广告过滤在代理中禁用,由播放器处理
|
| 38 |
+
const FILTER_DISCONTINUITY = false;
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
// --- 辅助函数 ---
|
| 42 |
+
|
| 43 |
+
function logDebug(message) {
|
| 44 |
+
if (DEBUG_ENABLED) {
|
| 45 |
+
console.log(`[代理日志] ${message}`);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* 从代理请求路径中提取编码后的目标 URL。
|
| 51 |
+
* @param {string} encodedPath - URL 编码后的路径部分 (例如 "https%3A%2F%2F...")
|
| 52 |
+
* @returns {string|null} 解码后的目标 URL,如果无效则返回 null。
|
| 53 |
+
*/
|
| 54 |
+
function getTargetUrlFromPath(encodedPath) {
|
| 55 |
+
if (!encodedPath) {
|
| 56 |
+
logDebug("getTargetUrlFromPath 收到空路径。");
|
| 57 |
+
return null;
|
| 58 |
+
}
|
| 59 |
+
try {
|
| 60 |
+
const decodedUrl = decodeURIComponent(encodedPath);
|
| 61 |
+
// 基础检查,看是否像一个 HTTP/HTTPS URL
|
| 62 |
+
if (decodedUrl.match(/^https?:\/\/.+/i)) {
|
| 63 |
+
return decodedUrl;
|
| 64 |
+
} else {
|
| 65 |
+
logDebug(`无效的解码 URL 格式: ${decodedUrl}`);
|
| 66 |
+
// 备选检查:原始路径是否未编码但看起来像 URL?
|
| 67 |
+
if (encodedPath.match(/^https?:\/\/.+/i)) {
|
| 68 |
+
logDebug(`警告: 路径未编码但看起来像 URL: ${encodedPath}`);
|
| 69 |
+
return encodedPath;
|
| 70 |
+
}
|
| 71 |
+
return null;
|
| 72 |
+
}
|
| 73 |
+
} catch (e) {
|
| 74 |
+
// 捕获解码错误 (例如格式错误的 URI)
|
| 75 |
+
logDebug(`解码目标 URL 出错: ${encodedPath} - ${e.message}`);
|
| 76 |
+
return null;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function getBaseUrl(urlStr) {
|
| 81 |
+
if (!urlStr) return '';
|
| 82 |
+
try {
|
| 83 |
+
const parsedUrl = new URL(urlStr);
|
| 84 |
+
// 处理根目录或只有文件名的情况
|
| 85 |
+
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); // 移除空字符串
|
| 86 |
+
if (pathSegments.length <= 1) {
|
| 87 |
+
return `${parsedUrl.origin}/`;
|
| 88 |
+
}
|
| 89 |
+
pathSegments.pop(); // 移除最后一段
|
| 90 |
+
return `${parsedUrl.origin}/${pathSegments.join('/')}/`;
|
| 91 |
+
} catch (e) {
|
| 92 |
+
logDebug(`获取 BaseUrl 失败: "${urlStr}": ${e.message}`);
|
| 93 |
+
// 备用方法:查找最后一个斜杠
|
| 94 |
+
const lastSlashIndex = urlStr.lastIndexOf('/');
|
| 95 |
+
if (lastSlashIndex > urlStr.indexOf('://') + 2) { // 确保不是协议部分的斜杠
|
| 96 |
+
return urlStr.substring(0, lastSlashIndex + 1);
|
| 97 |
+
}
|
| 98 |
+
return urlStr + '/'; // 如果没有路径,添加斜杠
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function resolveUrl(baseUrl, relativeUrl) {
|
| 103 |
+
if (!relativeUrl) return ''; // 处理空的 relativeUrl
|
| 104 |
+
if (relativeUrl.match(/^https?:\/\/.+/i)) {
|
| 105 |
+
return relativeUrl; // 已经是绝对 URL
|
| 106 |
+
}
|
| 107 |
+
if (!baseUrl) return relativeUrl; // 没有基础 URL 无法解析
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
// 使用 Node.js 的 URL 构造函数处理相对路径
|
| 111 |
+
return new URL(relativeUrl, baseUrl).toString();
|
| 112 |
+
} catch (e) {
|
| 113 |
+
logDebug(`URL 解析失败: base="${baseUrl}", relative="${relativeUrl}". 错误: ${e.message}`);
|
| 114 |
+
// 简单的备用逻辑
|
| 115 |
+
if (relativeUrl.startsWith('/')) {
|
| 116 |
+
try {
|
| 117 |
+
const baseOrigin = new URL(baseUrl).origin;
|
| 118 |
+
return `${baseOrigin}${relativeUrl}`;
|
| 119 |
+
} catch { return relativeUrl; } // 如果 baseUrl 也无效,返回原始相对路径
|
| 120 |
+
} else {
|
| 121 |
+
// 假设相对于包含基础 URL ���源的目录
|
| 122 |
+
return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ** 已修正:确保生成 /proxy/ 前缀的链接 **
|
| 128 |
+
function rewriteUrlToProxy(targetUrl) {
|
| 129 |
+
if (!targetUrl || typeof targetUrl !== 'string') return '';
|
| 130 |
+
// 返回与 vercel.json 的 "source" 和前端 PROXY_URL 一致的路径
|
| 131 |
+
return `/proxy/${encodeURIComponent(targetUrl)}`;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function getRandomUserAgent() {
|
| 135 |
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async function fetchContentWithType(targetUrl, requestHeaders) {
|
| 139 |
+
// 准备请求头
|
| 140 |
+
const headers = {
|
| 141 |
+
'User-Agent': getRandomUserAgent(),
|
| 142 |
+
'Accept': requestHeaders['accept'] || '*/*', // 传递原始 Accept 头(如果有)
|
| 143 |
+
'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',
|
| 144 |
+
// 尝试设置一个合理的 Referer
|
| 145 |
+
'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,
|
| 146 |
+
};
|
| 147 |
+
// 清理空值的头
|
| 148 |
+
Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});
|
| 149 |
+
|
| 150 |
+
logDebug(`准备请求目标: ${targetUrl},请求头: ${JSON.stringify(headers)}`);
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
// 发起 fetch 请求
|
| 154 |
+
const response = await fetch(targetUrl, { headers, redirect: 'follow' });
|
| 155 |
+
|
| 156 |
+
// 检查响应是否成功
|
| 157 |
+
if (!response.ok) {
|
| 158 |
+
const errorBody = await response.text().catch(() => ''); // 尝试获取错误响应体
|
| 159 |
+
logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);
|
| 160 |
+
// 创建一个包含状态码的错误对象
|
| 161 |
+
const err = new Error(`HTTP 错误 ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);
|
| 162 |
+
err.status = response.status; // 将状态码附加到错误对象
|
| 163 |
+
throw err; // 抛出错误
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// 读取响应内容
|
| 167 |
+
const content = await response.text();
|
| 168 |
+
const contentType = response.headers.get('content-type') || '';
|
| 169 |
+
logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);
|
| 170 |
+
// 返回结果
|
| 171 |
+
return { content, contentType, responseHeaders: response.headers };
|
| 172 |
+
|
| 173 |
+
} catch (error) {
|
| 174 |
+
// 捕获 fetch 本身的错误(网络、超时等)或上面抛出的 HTTP 错误
|
| 175 |
+
logDebug(`请求异常 ${targetUrl}: ${error.message}`);
|
| 176 |
+
// 重新抛出,确保包含原始错误信息
|
| 177 |
+
throw new Error(`请求目标 URL 失败 ${targetUrl}: ${error.message}`);
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function isM3u8Content(content, contentType) {
|
| 182 |
+
if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {
|
| 183 |
+
return true;
|
| 184 |
+
}
|
| 185 |
+
return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function processKeyLine(line, baseUrl) {
|
| 189 |
+
return line.replace(/URI="([^"]+)"/, (match, uri) => {
|
| 190 |
+
const absoluteUri = resolveUrl(baseUrl, uri);
|
| 191 |
+
logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);
|
| 192 |
+
return `URI="${rewriteUrlToProxy(absoluteUri)}"`;
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function processMapLine(line, baseUrl) {
|
| 197 |
+
return line.replace(/URI="([^"]+)"/, (match, uri) => {
|
| 198 |
+
const absoluteUri = resolveUrl(baseUrl, uri);
|
| 199 |
+
logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);
|
| 200 |
+
return `URI="${rewriteUrlToProxy(absoluteUri)}"`;
|
| 201 |
+
});
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function processMediaPlaylist(url, content) {
|
| 205 |
+
const baseUrl = getBaseUrl(url);
|
| 206 |
+
if (!baseUrl) {
|
| 207 |
+
logDebug(`无法确定媒体列表的 Base URL: ${url},相对路径可能无法处理。`);
|
| 208 |
+
}
|
| 209 |
+
const lines = content.split('\n');
|
| 210 |
+
const output = [];
|
| 211 |
+
for (let i = 0; i < lines.length; i++) {
|
| 212 |
+
const line = lines[i].trim();
|
| 213 |
+
// 保留最后一个空行
|
| 214 |
+
if (!line && i === lines.length - 1) { output.push(line); continue; }
|
| 215 |
+
if (!line) continue; // 跳过中间空行
|
| 216 |
+
// 广告过滤已禁用
|
| 217 |
+
if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }
|
| 218 |
+
if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }
|
| 219 |
+
if (line.startsWith('#EXTINF')) { output.push(line); continue; }
|
| 220 |
+
// 处理 URL 行
|
| 221 |
+
if (!line.startsWith('#')) {
|
| 222 |
+
const absoluteUrl = resolveUrl(baseUrl, line);
|
| 223 |
+
logDebug(`重写媒体片段: 原始='${line}', 解析后='${absoluteUrl}'`);
|
| 224 |
+
output.push(rewriteUrlToProxy(absoluteUrl)); continue;
|
| 225 |
+
}
|
| 226 |
+
// 保留其他 M3U8 标签
|
| 227 |
+
output.push(line);
|
| 228 |
+
}
|
| 229 |
+
return output.join('\n');
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
|
| 233 |
+
// 判断是主列表还是媒体列表
|
| 234 |
+
if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {
|
| 235 |
+
logDebug(`检测到主播放列表: ${targetUrl} (深度: ${recursionDepth})`);
|
| 236 |
+
return await processMasterPlaylist(targetUrl, content, recursionDepth);
|
| 237 |
+
}
|
| 238 |
+
logDebug(`检测到媒体播放列表: ${targetUrl} (深度: ${recursionDepth})`);
|
| 239 |
+
return processMediaPlaylist(targetUrl, content);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
async function processMasterPlaylist(url, content, recursionDepth) {
|
| 243 |
+
// 检查递归深度
|
| 244 |
+
if (recursionDepth > MAX_RECURSION) {
|
| 245 |
+
throw new Error(`处理主播放列表时,递归深度超过最大限制 (${MAX_RECURSION}): ${url}`);
|
| 246 |
+
}
|
| 247 |
+
const baseUrl = getBaseUrl(url);
|
| 248 |
+
const lines = content.split('\n');
|
| 249 |
+
let highestBandwidth = -1;
|
| 250 |
+
let bestVariantUrl = '';
|
| 251 |
+
|
| 252 |
+
// 查找最高带宽的流
|
| 253 |
+
for (let i = 0; i < lines.length; i++) {
|
| 254 |
+
if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
|
| 255 |
+
const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/);
|
| 256 |
+
const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
|
| 257 |
+
let variantUriLine = '';
|
| 258 |
+
// 找到下一行的 URI
|
| 259 |
+
for (let j = i + 1; j < lines.length; j++) {
|
| 260 |
+
const line = lines[j].trim();
|
| 261 |
+
if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; }
|
| 262 |
+
}
|
| 263 |
+
if (variantUriLine && currentBandwidth >= highestBandwidth) {
|
| 264 |
+
highestBandwidth = currentBandwidth;
|
| 265 |
+
bestVariantUrl = resolveUrl(baseUrl, variantUriLine);
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
// 如果没有找到带宽信息,尝试查找第一个 .m3u8 链接
|
| 270 |
+
if (!bestVariantUrl) {
|
| 271 |
+
logDebug(`主播放列表中未找到 BANDWIDTH 信息,尝试查找第一个 URI: ${url}`);
|
| 272 |
+
for (let i = 0; i < lines.length; i++) {
|
| 273 |
+
const line = lines[i].trim();
|
| 274 |
+
// 更可靠地匹配 .m3u8 链接
|
| 275 |
+
if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) {
|
| 276 |
+
bestVariantUrl = resolveUrl(baseUrl, line);
|
| 277 |
+
logDebug(`备选方案: 找到第一个子播放列表 URI: ${bestVariantUrl}`);
|
| 278 |
+
break;
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
// 如果仍然没有找到子列表 URL
|
| 283 |
+
if (!bestVariantUrl) {
|
| 284 |
+
logDebug(`在主播放列表 ${url} 中未找到有效的子列表 URI,将其作为媒体列表处理。`);
|
| 285 |
+
return processMediaPlaylist(url, content);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
logDebug(`选择的子播放列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);
|
| 289 |
+
// 请求选定的子播放列表内容 (注意:这里传递 {} 作为请求头,不传递客户端的原始请求头)
|
| 290 |
+
const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});
|
| 291 |
+
|
| 292 |
+
// 检查获取的内容是否是 M3U8
|
| 293 |
+
if (!isM3u8Content(variantContent, variantContentType)) {
|
| 294 |
+
logDebug(`获取的子播放列表 ${bestVariantUrl} 不是 M3U8 (类型: ${variantContentType}),将其作为媒体列表处理。`);
|
| 295 |
+
return processMediaPlaylist(bestVariantUrl, variantContent);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// 递归处理获取到的子 M3U8 内容
|
| 299 |
+
return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
// --- Vercel Handler 函数 ---
|
| 304 |
+
export default async function handler(req, res) {
|
| 305 |
+
// --- 记录请求开始 ---
|
| 306 |
+
console.info('--- Vercel 代理请求开始 ---');
|
| 307 |
+
console.info('时间:', new Date().toISOString());
|
| 308 |
+
console.info('方法:', req.method);
|
| 309 |
+
console.info('URL:', req.url); // 原始请求 URL (例如 /proxy/...)
|
| 310 |
+
console.info('查询参数:', JSON.stringify(req.query)); // Vercel 解析的查询参数
|
| 311 |
+
|
| 312 |
+
// --- 提前设置 CORS 头 ---
|
| 313 |
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
| 314 |
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
| 315 |
+
res.setHeader('Access-Control-Allow-Headers', '*'); // 允许所有请求头
|
| 316 |
+
|
| 317 |
+
// --- 处理 OPTIONS 预检请求 ---
|
| 318 |
+
if (req.method === 'OPTIONS') {
|
| 319 |
+
console.info("处理 OPTIONS 预检请求");
|
| 320 |
+
res.status(204).setHeader('Access-Control-Max-Age', '86400').end(); // 缓存预检结果 24 小时
|
| 321 |
+
return;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
let targetUrl = null; // 初始化目标 URL
|
| 325 |
+
|
| 326 |
+
try { // ---- 开始主处理逻辑的 try 块 ----
|
| 327 |
+
|
| 328 |
+
// --- 提取目标 URL (主要依赖 req.query["...path"]) ---
|
| 329 |
+
// Vercel 将 :path* 捕获的内容(可能包含斜杠)放入 req.query["...path"] 数组
|
| 330 |
+
const pathData = req.query["...path"]; // 使用正确的键名
|
| 331 |
+
let encodedUrlPath = '';
|
| 332 |
+
|
| 333 |
+
if (pathData) {
|
| 334 |
+
if (Array.isArray(pathData)) {
|
| 335 |
+
encodedUrlPath = pathData.join('/'); // 重新组合
|
| 336 |
+
console.info(`从 req.query["...path"] (数组) 组合的编码路径: ${encodedUrlPath}`);
|
| 337 |
+
} else if (typeof pathData === 'string') {
|
| 338 |
+
encodedUrlPath = pathData; // 也处理 Vercel 可能只返回字符串的情况
|
| 339 |
+
console.info(`从 req.query["...path"] (字符串) 获取的编码路径: ${encodedUrlPath}`);
|
| 340 |
+
} else {
|
| 341 |
+
console.warn(`[代理警告] req.query["...path"] 类型未知: ${typeof pathData}`);
|
| 342 |
+
}
|
| 343 |
+
} else {
|
| 344 |
+
console.warn(`[代理警告] req.query["...path"] 为空或未定义。`);
|
| 345 |
+
// 备选:尝试从 req.url 提取(如果需要)
|
| 346 |
+
if (req.url && req.url.startsWith('/proxy/')) {
|
| 347 |
+
encodedUrlPath = req.url.substring('/proxy/'.length);
|
| 348 |
+
console.info(`使用备选方法从 req.url 提取的编码路径: ${encodedUrlPath}`);
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// 如果仍然为空,则无法继续
|
| 353 |
+
if (!encodedUrlPath) {
|
| 354 |
+
throw new Error("无法从请求中确定编码后的目标路径。");
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// 解析目标 URL
|
| 358 |
+
targetUrl = getTargetUrlFromPath(encodedUrlPath);
|
| 359 |
+
console.info(`解析出的目标 URL: ${targetUrl || 'null'}`); // 记录解析结果
|
| 360 |
+
|
| 361 |
+
// 检查目标 URL 是否有效
|
| 362 |
+
if (!targetUrl) {
|
| 363 |
+
// 抛出包含更多上下文的错误
|
| 364 |
+
throw new Error(`无效的代理请求路径。无法从组合路径 "${encodedUrlPath}" 中提取有效的目标 URL。`);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
console.info(`开始处理目标 URL 的代理请求: ${targetUrl}`);
|
| 368 |
+
|
| 369 |
+
// --- 获取并处理目标内容 ---
|
| 370 |
+
const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, req.headers);
|
| 371 |
+
|
| 372 |
+
// --- 如果是 M3U8,处理并返回 ---
|
| 373 |
+
if (isM3u8Content(content, contentType)) {
|
| 374 |
+
console.info(`正在处理 M3U8 内容: ${targetUrl}`);
|
| 375 |
+
const processedM3u8 = await processM3u8Content(targetUrl, content);
|
| 376 |
+
|
| 377 |
+
console.info(`成功处理 M3U8: ${targetUrl}`);
|
| 378 |
+
// 发送处理后的 M3U8 响应
|
| 379 |
+
res.status(200)
|
| 380 |
+
.setHeader('Content-Type', 'application/vnd.apple.mpegurl;charset=utf-8')
|
| 381 |
+
.setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`)
|
| 382 |
+
// 移除可能导致问题的原始响应头
|
| 383 |
+
.removeHeader('content-encoding') // 很重要!node-fetch 已解压
|
| 384 |
+
.removeHeader('content-length') // 长度已改变
|
| 385 |
+
.send(processedM3u8); // 发送 M3U8 文本
|
| 386 |
+
|
| 387 |
+
} else {
|
| 388 |
+
// --- 如果不是 M3U8,直接返回原始内容 ---
|
| 389 |
+
console.info(`直接返回非 M3U8 内容: ${targetUrl}, 类型: ${contentType}`);
|
| 390 |
+
|
| 391 |
+
// 设置原始响应头,但排除有问题的头和 CORS 头(已设置)
|
| 392 |
+
responseHeaders.forEach((value, key) => {
|
| 393 |
+
const lowerKey = key.toLowerCase();
|
| 394 |
+
if (!lowerKey.startsWith('access-control-') &&
|
| 395 |
+
lowerKey !== 'content-encoding' && // 很重要!
|
| 396 |
+
lowerKey !== 'content-length') { // 很重要!
|
| 397 |
+
res.setHeader(key, value); // 设置其他原始头
|
| 398 |
+
}
|
| 399 |
+
});
|
| 400 |
+
// 设置我们自己的缓存策略
|
| 401 |
+
res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`);
|
| 402 |
+
|
| 403 |
+
// 发送原始(已解压)内容
|
| 404 |
+
res.status(200).send(content);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// ---- 结束主处理逻辑的 try 块 ----
|
| 408 |
+
} catch (error) { // ---- 捕获处理过程中的任何错误 ----
|
| 409 |
+
// **检查这个错误是否是 "Assignment to constant variable"**
|
| 410 |
+
console.error(`[代理错误处理 V3] 捕获错误!目标: ${targetUrl || '解析失败'} | 错误类型: ${error.constructor.name} | 错误消息: ${error.message}`);
|
| 411 |
+
console.error(`[代理错误堆栈 V3] ${error.stack}`); // 记录完整的错误堆栈信息
|
| 412 |
+
|
| 413 |
+
// 特别标记 "Assignment to constant variable" 错误
|
| 414 |
+
if (error instanceof TypeError && error.message.includes("Assignment to constant variable")) {
|
| 415 |
+
console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
| 416 |
+
console.error("捕获到 'Assignment to constant variable' 错误!");
|
| 417 |
+
console.error("请再次检查函数代码及所有辅助函数中,是否有 const 声明的变量被重新赋值。");
|
| 418 |
+
console.error("错误堆栈指向:", error.stack);
|
| 419 |
+
console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// 尝试从错误对象获取状态码,否则默认为 500
|
| 423 |
+
const statusCode = error.status || 500;
|
| 424 |
+
|
| 425 |
+
// 确保在发送错误响应前没有发送过响应头
|
| 426 |
+
if (!res.headersSent) {
|
| 427 |
+
res.setHeader('Content-Type', 'application/json');
|
| 428 |
+
// CORS 头应该已经在前面设置好了
|
| 429 |
+
res.status(statusCode).json({
|
| 430 |
+
success: false,
|
| 431 |
+
error: `代理处理错误: ${error.message}`, // 返回错误消息给前端
|
| 432 |
+
targetUrl: targetUrl // 包含目标 URL 以便调试
|
| 433 |
+
});
|
| 434 |
+
} else {
|
| 435 |
+
// 如果响应头已发送,无法��发送 JSON 错误
|
| 436 |
+
console.error("[代理错误处理 V3] 响应头已发送,无法发送 JSON 错误响应。");
|
| 437 |
+
// 尝试结束响应
|
| 438 |
+
if (!res.writableEnded) {
|
| 439 |
+
res.end();
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
} finally {
|
| 443 |
+
// 记录请求处理结束
|
| 444 |
+
console.info('--- Vercel 代理请求结束 ---');
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// --- [确保所有辅助函数定义都在这里] ---
|
| 449 |
+
// getTargetUrlFromPath, getBaseUrl, resolveUrl, rewriteUrlToProxy, getRandomUserAgent,
|
| 450 |
+
// fetchContentWithType, isM3u8Content, processKeyLine, processMapLine,
|
| 451 |
+
// processMediaPlaylist, processM3u8Content, processMasterPlaylist
|