TV / functions /proxy /[[path]].js
samlax12's picture
Upload 30 files
7eff83b verified
// functions/proxy/[[path]].js
// --- 配置 (现在从 Cloudflare 环境变量读取) ---
// 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量:
// CACHE_TTL (例如 86400)
// MAX_RECURSION (例如 5)
// FILTER_DISCONTINUITY (不再需要,设为 false 或移除)
// USER_AGENTS_JSON (例如 ["UA1", "UA2"]) - JSON 字符串数组
// DEBUG (例如 false 或 true)
// --- 配置结束 ---
// --- 常量 (之前在 config.js 中,现在移到这里,因为它们与代理逻辑相关) ---
const MEDIA_FILE_EXTENSIONS = [
'.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts',
'.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus',
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic'
];
const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/'];
// --- 常量结束 ---
/**
* 主要的 Pages Function 处理函数
* 拦截发往 /proxy/* 的请求
*/
export async function onRequest(context) {
const { request, env, next, waitUntil } = context; // next 和 waitUntil 可能需要
const url = new URL(request.url);
// --- 从环境变量读取配置 ---
const DEBUG_ENABLED = (env.DEBUG === 'true');
const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); // 默认 24 小时
const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); // 默认 5 层
// 广告过滤已移至播放器处理,代理不再执行
let USER_AGENTS = [ // 提供一个基础的默认值
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
try {
// 尝试从环境变量解析 USER_AGENTS_JSON
const agentsJson = env.USER_AGENTS_JSON;
if (agentsJson) {
const parsedAgents = JSON.parse(agentsJson);
if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
USER_AGENTS = parsedAgents;
} else {
logDebug("环境变量 USER_AGENTS_JSON 格式无效或为空,使用默认值");
}
}
} catch (e) {
logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message},使用默认值`);
}
// --- 配置读取结束 ---
// --- 辅助函数 ---
// 输出调试日志 (需要设置 DEBUG: true 环境变量)
function logDebug(message) {
if (DEBUG_ENABLED) {
console.log(`[Proxy Func] ${message}`);
}
}
// 从请求路径中提取目标 URL
function getTargetUrlFromPath(pathname) {
// 路径格式: /proxy/经过编码的URL
// 例如: /proxy/https%3A%2F%2Fexample.com%2Fplaylist.m3u8
const encodedUrl = pathname.replace(/^\/proxy\//, '');
if (!encodedUrl) return null;
try {
// 解码
let decodedUrl = decodeURIComponent(encodedUrl);
// 简单检查解码后是否是有效的 http/https URL
if (!decodedUrl.match(/^https?:\/\//i)) {
// 也许原始路径就没有编码?如果看起来像URL就直接用
if (encodedUrl.match(/^https?:\/\//i)) {
decodedUrl = encodedUrl;
logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`);
} else {
logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`);
return null;
}
}
return decodedUrl;
} catch (e) {
logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`);
return null;
}
}
// 创建标准化的响应
function createResponse(body, status = 200, headers = {}) {
const responseHeaders = new Headers(headers);
// 关键:添加 CORS 跨域头,允许前端 JS 访问代理后的响应
responseHeaders.set("Access-Control-Allow-Origin", "*"); // 允许任何来源访问
responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); // 允许的方法
responseHeaders.set("Access-Control-Allow-Headers", "*"); // 允许所有请求头
// 处理 CORS 预检请求 (OPTIONS) - 放在这里确保所有响应都处理
if (request.method === "OPTIONS") {
// 使用下面的 onOptions 函数可以更规范,但在这里处理也可以
return new Response(null, {
status: 204, // No Content
headers: responseHeaders // 包含上面设置的 CORS 头
});
}
return new Response(body, { status, headers: responseHeaders });
}
// 创建 M3U8 类型的响应
function createM3u8Response(content) {
return createResponse(content, 200, {
"Content-Type": "application/vnd.apple.mpegurl", // M3U8 的标准 MIME 类型
"Cache-Control": `public, max-age=${CACHE_TTL}` // 允许浏览器和CDN缓存
});
}
// 获取随机 User-Agent
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
// 获取 URL 的基础路径 (用于解析相对路径)
function getBaseUrl(urlStr) {
try {
const parsedUrl = new URL(urlStr);
// 如果路径是根目录,或者没有斜杠,直接返回 origin + /
if (!parsedUrl.pathname || parsedUrl.pathname === '/') {
return `${parsedUrl.origin}/`;
}
const pathParts = parsedUrl.pathname.split('/');
pathParts.pop(); // 移除文件名或最后一个路径段
return `${parsedUrl.origin}${pathParts.join('/')}/`;
} catch (e) {
logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`);
// 备用方法:找到最后一个斜杠
const lastSlashIndex = urlStr.lastIndexOf('/');
// 确保不是协议部分的斜杠 (http://)
return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/';
}
}
// 将相对 URL 转换为绝对 URL
function resolveUrl(baseUrl, relativeUrl) {
// 如果已经是绝对 URL,直接返回
if (relativeUrl.match(/^https?:\/\//i)) {
return relativeUrl;
}
try {
// 使用 URL 对象来处理相对路径
return new URL(relativeUrl, baseUrl).toString();
} catch (e) {
logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`);
// 简单的备用方法
if (relativeUrl.startsWith('/')) {
// 处理根路径相对 URL
const urlObj = new URL(baseUrl);
return `${urlObj.origin}${relativeUrl}`;
}
// 处理同级目录相对 URL
return `${baseUrl.replace(/\/[^/]*$/, '/')}${relativeUrl}`; // 确保baseUrl以 / 结尾
}
}
// 将目标 URL 重写为内部代理路径 (/proxy/...)
function rewriteUrlToProxy(targetUrl) {
// 确保目标URL被正确编码,以便作为路径的一部分
return `/proxy/${encodeURIComponent(targetUrl)}`;
}
// 获取远程内容及其类型
async function fetchContentWithType(targetUrl) {
const headers = new Headers({
'User-Agent': getRandomUserAgent(),
'Accept': '*/*',
// 尝试传递一些原始请求的头信息
'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8',
// 尝试设置 Referer 为目标网站的域名,或者传递原始 Referer
'Referer': request.headers.get('Referer') || new URL(targetUrl).origin
});
try {
// 直接请求目标 URL
logDebug(`开始直接请求: ${targetUrl}`);
// Cloudflare Functions 的 fetch 默认支持重定向
const response = await fetch(targetUrl, { headers, redirect: 'follow' });
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);
throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`);
}
// 读取响应内容为文本
const content = await response.text();
const contentType = response.headers.get('Content-Type') || '';
logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);
return { content, contentType, responseHeaders: response.headers }; // 同时返回原始响应头
} catch (error) {
logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`);
// 抛出更详细的错误
throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`);
}
}
// 判断是否是 M3U8 内容
function isM3u8Content(content, contentType) {
// 检查 Content-Type
if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {
return true;
}
// 检查内容本身是否以 #EXTM3U 开头
return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
}
// 判断是否是媒体文件 (根据扩展名和 Content-Type) - 这部分在此代理中似乎未使用,但保留
function isMediaFile(url, contentType) {
if (contentType) {
for (const mediaType of MEDIA_CONTENT_TYPES) {
if (contentType.toLowerCase().startsWith(mediaType)) {
return true;
}
}
}
const urlLower = url.toLowerCase();
for (const ext of MEDIA_FILE_EXTENSIONS) {
if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) {
return true;
}
}
return false;
}
// 处理 M3U8 中的 #EXT-X-KEY 行 (加密密钥)
function processKeyLine(line, baseUrl) {
return line.replace(/URI="([^"]+)"/, (match, uri) => {
const absoluteUri = resolveUrl(baseUrl, uri);
logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);
return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径
});
}
// 处理 M3U8 中的 #EXT-X-MAP 行 (初始化片段)
function processMapLine(line, baseUrl) {
return line.replace(/URI="([^"]+)"/, (match, uri) => {
const absoluteUri = resolveUrl(baseUrl, uri);
logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);
return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径
});
}
// 处理媒体 M3U8 播放列表 (包含视频/音频片段)
function processMediaPlaylist(url, content) {
const baseUrl = getBaseUrl(url);
const lines = content.split('\n');
const output = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// 保留最后的空行
if (!line && i === lines.length - 1) {
output.push(line);
continue;
}
if (!line) continue; // 跳过中间的空行
if (line.startsWith('#EXT-X-KEY')) {
output.push(processKeyLine(line, baseUrl));
continue;
}
if (line.startsWith('#EXT-X-MAP')) {
output.push(processMapLine(line, baseUrl));
continue;
}
if (line.startsWith('#EXTINF')) {
output.push(line);
continue;
}
if (!line.startsWith('#')) {
const absoluteUrl = resolveUrl(baseUrl, line);
logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`);
output.push(rewriteUrlToProxy(absoluteUrl));
continue;
}
// 其他 M3U8 标签直接添加
output.push(line);
}
return output.join('\n');
}
// 递归处理 M3U8 内容
async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) {
if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {
logDebug(`检测到主播放列表: ${targetUrl}`);
return await processMasterPlaylist(targetUrl, content, recursionDepth, env);
}
logDebug(`检测到媒体播放列表: ${targetUrl}`);
return processMediaPlaylist(targetUrl, content);
}
// 处理主 M3U8 播放列表
async function processMasterPlaylist(url, content, recursionDepth, env) {
if (recursionDepth > MAX_RECURSION) {
throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`);
}
const baseUrl = getBaseUrl(url);
const lines = content.split('\n');
let highestBandwidth = -1;
let bestVariantUrl = '';
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/);
const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
let variantUriLine = '';
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j].trim();
if (line && !line.startsWith('#')) {
variantUriLine = line;
i = j;
break;
}
}
if (variantUriLine && currentBandwidth >= highestBandwidth) {
highestBandwidth = currentBandwidth;
bestVariantUrl = resolveUrl(baseUrl, variantUriLine);
}
}
}
if (!bestVariantUrl) {
logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF,尝试查找第一个子列表引用: ${url}`);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { // 修复:检查是否包含 .m3u8?
bestVariantUrl = resolveUrl(baseUrl, line);
logDebug(`备选方案:找到第一个子列表引用: ${bestVariantUrl}`);
break;
}
}
}
if (!bestVariantUrl) {
logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`);
return processMediaPlaylist(url, content);
}
// --- 获取并处理选中的子 M3U8 ---
const cacheKey = `m3u8_processed:${bestVariantUrl}`; // 使用处理后的缓存键
let kvNamespace = null;
try {
kvNamespace = env.LIBRETV_PROXY_KV; // 从环境获取 KV 命名空间 (变量名在 Cloudflare 设置)
if (!kvNamespace) throw new Error("KV 命名空间未绑定");
} catch (e) {
logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);
kvNamespace = null; // 确保设为 null
}
if (kvNamespace) {
try {
const cachedContent = await kvNamespace.get(cacheKey);
if (cachedContent) {
logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`);
return cachedContent;
} else {
logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`);
}
} catch (kvError) {
logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`);
// 出错则继续执行,不影响功能
}
}
logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);
const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl);
if (!isM3u8Content(variantContent, variantContentType)) {
logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件,返回原始内容。`);
// 如果不是M3U8,但看起来像媒体内容,直接返回代理后的内容
// 注意:这里可能需要决定是否直接代理这个非 M3U8 的 URL
// 为了简化,我们假设如果不是 M3U8,则流程中断或按原样处理
// 或者,尝试将其作为媒体列表处理?(当前行为)
// return createResponse(variantContent, 200, { 'Content-Type': variantContentType || 'application/octet-stream' });
// 尝试按媒体列表处理,以防万一
return processMediaPlaylist(bestVariantUrl, variantContent);
}
const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env);
if (kvNamespace) {
try {
// 使用 waitUntil 异步写入缓存,不阻塞响应返回
// 注意 KV 的写入限制 (免费版每天 1000 次)
waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL }));
logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`);
} catch (kvError) {
logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);
// 写入失败不影响返回结果
}
}
return processedVariant;
}
// --- 主要请求处理逻辑 ---
try {
const targetUrl = getTargetUrlFromPath(url.pathname);
if (!targetUrl) {
logDebug(`无效的代理请求路径: ${url.pathname}`);
return createResponse("无效的代理请求。路径应为 /proxy/<经过编码的URL>", 400);
}
logDebug(`收到代理请求: ${targetUrl}`);
// --- 缓存检查 (KV) ---
const cacheKey = `proxy_raw:${targetUrl}`; // 使用原始内容的缓存键
let kvNamespace = null;
try {
kvNamespace = env.LIBRETV_PROXY_KV;
if (!kvNamespace) throw new Error("KV 命名空间未绑定");
} catch (e) {
logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);
kvNamespace = null;
}
if (kvNamespace) {
try {
const cachedDataJson = await kvNamespace.get(cacheKey); // 直接获取字符串
if (cachedDataJson) {
logDebug(`[缓存命中] 原始内容: ${targetUrl}`);
const cachedData = JSON.parse(cachedDataJson); // 解析 JSON
const content = cachedData.body;
let headers = {};
try { headers = JSON.parse(cachedData.headers); } catch(e){} // 解析头部
const contentType = headers['content-type'] || headers['Content-Type'] || '';
if (isM3u8Content(content, contentType)) {
logDebug(`缓存内容是 M3U8,重新处理: ${targetUrl}`);
const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);
return createM3u8Response(processedM3u8);
} else {
logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`);
return createResponse(content, 200, new Headers(headers));
}
} else {
logDebug(`[缓存未命中] 原始内容: ${targetUrl}`);
}
} catch (kvError) {
logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`);
// 出错则继续执行,不影响功能
}
}
// --- 实际请求 ---
const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl);
// --- 写入缓存 (KV) ---
if (kvNamespace) {
try {
const headersToCache = {};
responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; });
const cacheValue = { body: content, headers: JSON.stringify(headersToCache) };
// 注意 KV 写入限制
waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL }));
logDebug(`已将原始内容写入缓存: ${targetUrl}`);
} catch (kvError) {
logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);
// 写入失败不影响返回结果
}
}
// --- 处理响应 ---
if (isM3u8Content(content, contentType)) {
logDebug(`内容是 M3U8,开始处理: ${targetUrl}`);
const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);
return createM3u8Response(processedM3u8);
} else {
logDebug(`内容不是 M3U8 (类型: ${contentType}),直接返回: ${targetUrl}`);
const finalHeaders = new Headers(responseHeaders);
finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`);
// 添加 CORS 头,确保非 M3U8 内容也能跨域访问(例如图片、字幕文件等)
finalHeaders.set("Access-Control-Allow-Origin", "*");
finalHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
finalHeaders.set("Access-Control-Allow-Headers", "*");
return createResponse(content, 200, finalHeaders);
}
} catch (error) {
logDebug(`处理代理请求时发生严重错误: ${error.message} \n ${error.stack}`);
return createResponse(`代理处理错误: ${error.message}`, 500);
}
}
// 处理 OPTIONS 预检请求的函数
export async function onOptions(context) {
// 直接返回允许跨域的头信息
return new Response(null, {
status: 204, // No Content
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "*", // 允许所有请求头
"Access-Control-Max-Age": "86400", // 预检请求结果缓存一天
},
});
}