├── .dockerignore ├── .github └── workflows │ ├── docker-build.yml │ └── sync.yml ├── Dockerfile ├── LICENSE ├── about.html ├── api └── proxy │ └── [...path].mjs ├── css └── styles.css ├── docker-compose.yml ├── docker-entrypoint.sh ├── functions ├── _middleware.js └── proxy │ └── [[path]].js ├── index.html ├── js ├── api.js ├── app.js ├── config.js ├── douban.js ├── password.js ├── sha256.js └── ui.js ├── middleware.js ├── netlify.toml ├── netlify └── functions │ └── proxy.mjs ├── nginx.conf ├── package-lock.json ├── package.json ├── player.html ├── privacy.html ├── proxy.lua ├── readme.md ├── robots.txt ├── sitemap.xml ├── vercel.json └── watch.html /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | readme.md -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build LibreTV image' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | name: 'Build LibreTV image' 12 | runs-on: ubuntu-latest 13 | # Modified to run on both repositories 14 | if: github.repository == 'LibreSpark/LibreTV' || github.repository == 'bestZwei/libretv' 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | 18 | steps: 19 | - name: 'Check out repository' 20 | uses: actions/checkout@v4.2.2 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: 'Set Docker image tag based on repository' 25 | id: set-tag 26 | run: | 27 | if [ "${{ github.repository }}" = "LibreSpark/LibreTV" ]; then 28 | echo "IMAGE_TAG=libretv:latest" >> $GITHUB_OUTPUT 29 | else 30 | echo "IMAGE_TAG=libretv-beta:latest" >> $GITHUB_OUTPUT 31 | fi 32 | 33 | - name: 'Set up Docker QEMU' 34 | uses: docker/setup-qemu-action@v3.5.0 35 | 36 | - name: 'Set up Docker Buildx' 37 | uses: docker/setup-buildx-action@v3.10.0 38 | 39 | - name: 'Login to DockerHub' 40 | uses: docker/login-action@v3.4.0 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | 45 | - name: 'Build and push LibreTV image' 46 | uses: docker/build-push-action@v6.14.0 47 | with: 48 | context: . 49 | file: Dockerfile 50 | push: true 51 | tags: "${{ env.DOCKER_USERNAME }}/${{ steps.set-tag.outputs.IMAGE_TAG }}" 52 | platforms: linux/amd64,linux/arm64/v8 -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: Upstream Sync 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | schedule: 8 | - cron: "0 4 * * *" # At 04:00, every day 9 | workflow_dispatch: 10 | 11 | jobs: 12 | sync_latest_from_upstream: 13 | name: Sync latest commits from upstream repo 14 | runs-on: ubuntu-latest 15 | if: ${{ github.event.repository.fork }} 16 | 17 | steps: 18 | # Step 1: run a standard checkout action 19 | - name: Checkout target repo 20 | uses: actions/checkout@v3 21 | 22 | # Step 2: run the sync action 23 | - name: Sync upstream changes 24 | id: sync 25 | uses: aormsby/Fork-Sync-With-Upstream-action@v3.4 26 | with: 27 | upstream_sync_repo: LibreSpark/LibreTV 28 | upstream_sync_branch: main 29 | target_sync_branch: main 30 | target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set 31 | 32 | # Set test_mode true to run tests instead of the true action!! 33 | test_mode: false 34 | 35 | - name: Sync check 36 | if: failure() 37 | run: | 38 | echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork." 39 | exit 1 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fabiocicerchia/nginx-lua:1.27.5-alpine3.21.3 2 | LABEL maintainer="LibreTV Team" 3 | LABEL description="LibreTV - 免费在线视频搜索与观看平台" 4 | 5 | # 复制应用文件 6 | COPY . /usr/share/nginx/html 7 | 8 | # 复制Nginx配置文件 9 | COPY nginx.conf /etc/nginx/conf.d/default.conf 10 | 11 | # 添加执行权限并设置为入口点脚本 12 | COPY docker-entrypoint.sh / 13 | RUN chmod +x /docker-entrypoint.sh 14 | 15 | # 暴露端口 16 | EXPOSE 80 17 | 18 | # 设置入口点 19 | ENTRYPOINT ["/docker-entrypoint.sh"] 20 | 21 | # 启动nginx 22 | CMD ["nginx", "-g", "daemon off;"] 23 | 24 | # 健康检查 25 | HEALTHCHECK --interval=30s --timeout=3s CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 LibreTV Team 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 关于我们 - LibreTV 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

关于我们

15 |
16 |
17 |

18 | 本项目代码托管在 GitHub 上,欢迎访问我们的仓库: 19 | https://github.com/LibreSpark/LibreTV 20 |

21 |

22 | LibreTV 是一个免费的在线视频搜索平台,提供视频搜索和播放服务,致力于为用户带来最佳体验。 23 |

24 | 25 | 26 |
27 |

版权声明与投诉机制

28 |

29 | LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。 30 |

31 |

32 | 若您是版权方或相关权利人,发现本站搜索结果中存在侵犯您合法权益的内容,请通过以下渠道向我们反馈: 33 |

34 |
35 |

投诉邮箱:troll@pissmail.com

36 |
37 |

38 | 请在投诉邮件中提供:您的身份证明、权利证明、侵权内容的具体链接及相关说明。我们将在收到投诉后尽快处理,对于确认侵权的内容,将立即断开相关链接,停止展示侵权内容,并将处理结果反馈给您。 39 |

40 |
41 |
42 | 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /api/proxy/[...path].mjs: -------------------------------------------------------------------------------- 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 452 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* 赛博影视主题配色方案 - 柔和版 */ 3 | --primary-color: #00ccff; /* 霓虹蓝主色调 */ 4 | --primary-light: #33d6ff; /* 浅霓虹蓝变体 */ 5 | --secondary-color: #0f1622; /* 深蓝黑背景色 */ 6 | --accent-color: #ff3c78; /* 霓虹粉强调色 */ 7 | --text-color: #e6f2ff; /* 柔和的蓝白色文本 */ 8 | --text-muted: #8599b2; /* 淡蓝灰色次级文本 */ 9 | --border-color: rgba(0, 204, 255, 0.15); 10 | --page-gradient-start: #0f1622; /* 深蓝黑起始色 */ 11 | --page-gradient-end: #192231; /* 深靛蓝结束色 */ 12 | --card-gradient-start: #121b29; /* 卡片起始色 */ 13 | --card-gradient-end: #1c2939; /* 卡片结束色 */ 14 | --card-accent: rgba(0, 204, 255, 0.12); /* 霓虹蓝卡片强调色 */ 15 | --card-hover-border: rgba(0, 204, 255, 0.5); /* 悬停边框颜色 */ 16 | } 17 | 18 | .page-bg { 19 | background: linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)); 20 | min-height: 100vh; 21 | /* 柔和赛博点状背景 */ 22 | background-image: 23 | linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)), 24 | radial-gradient(circle at 25px 25px, rgba(0, 204, 255, 0.04) 2px, transparent 3px), 25 | radial-gradient(circle at 75px 75px, rgba(255, 60, 120, 0.02) 1px, transparent 2px), 26 | radial-gradient(circle at 50px 50px, rgba(150, 255, 250, 0.015) 1px, transparent 2px); 27 | background-blend-mode: normal; 28 | background-size: cover, 100px 100px, 50px 50px, 75px 75px; 29 | } 30 | 31 | button, .card-hover { 32 | transition: all 0.3s ease; 33 | } 34 | 35 | /* 改进卡片适应不同内容长度 */ 36 | .card-hover { 37 | border: 1px solid var(--border-color); 38 | background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); 39 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 40 | position: relative; 41 | overflow: hidden; 42 | border-radius: 6px; 43 | display: flex; 44 | flex-direction: column; 45 | height: 100%; 46 | } 47 | 48 | /* 确保卡片内容区域高度一致性 */ 49 | .card-hover .flex-grow { 50 | min-height: 90px; 51 | display: flex; 52 | flex-direction: column; 53 | } 54 | 55 | /* 针对不同长度的标题优化显示 */ 56 | .card-hover h3 { 57 | min-height: 3rem; 58 | display: -webkit-box; 59 | -webkit-box-orient: vertical; 60 | overflow: hidden; 61 | text-overflow: ellipsis; 62 | -webkit-line-clamp: 2; 63 | max-height: 3rem; /* 确保最多显示两行 */ 64 | line-height: 1.5rem; /* 设定行高以确保两行文本的一致性 */ 65 | word-break: break-word; /* 允许在任何字符间断行 */ 66 | hyphens: auto; /* 允许断词 */ 67 | } 68 | 69 | .card-hover::before { 70 | content: ""; 71 | position: absolute; 72 | top: 0; 73 | left: -100%; 74 | width: 100%; 75 | height: 100%; 76 | background: linear-gradient(90deg, transparent, var(--card-accent), transparent); 77 | transition: left 0.6s ease; 78 | } 79 | 80 | .card-hover:hover { 81 | border-color: var(--card-hover-border); 82 | transform: translateY(-3px); 83 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); 84 | } 85 | 86 | .card-hover:hover::before { 87 | left: 100%; 88 | } 89 | 90 | .gradient-text { 91 | background: linear-gradient(to right, var(--primary-color), var(--accent-color)); 92 | -webkit-background-clip: text; 93 | -webkit-text-fill-color: transparent; 94 | } 95 | 96 | /* 改进设置面板样式 */ 97 | .settings-panel { 98 | scrollbar-width: thin; 99 | scrollbar-color: #444 #222; 100 | transform: translateX(100%); 101 | transition: transform 0.3s ease; 102 | background: linear-gradient(135deg, var(--page-gradient-end), var(--page-gradient-start)); 103 | border-left: 1px solid var(--primary-color); 104 | } 105 | 106 | .settings-panel.show { 107 | transform: translateX(0); 108 | } 109 | 110 | .settings-panel::-webkit-scrollbar { 111 | width: 6px; 112 | } 113 | 114 | .settings-panel::-webkit-scrollbar-track { 115 | background: transparent; 116 | } 117 | 118 | .settings-panel::-webkit-scrollbar-thumb { 119 | background-color: #444; 120 | border-radius: 4px; 121 | } 122 | 123 | /* 设置面板区块样式 */ 124 | .settings-panel .shadow-inner { 125 | box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); 126 | transition: all 0.2s ease-in-out; 127 | } 128 | 129 | .settings-panel .shadow-inner:hover { 130 | box-shadow: inset 0 2px 8px rgba(0,0,0,0.4); 131 | } 132 | 133 | .search-button { 134 | background: var(--primary-color); 135 | color: var(--text-color); 136 | } 137 | 138 | .search-button:hover { 139 | background: var(--primary-light); 140 | } 141 | 142 | ::-webkit-scrollbar { 143 | width: 8px; 144 | height: 8px; 145 | } 146 | 147 | ::-webkit-scrollbar-track { 148 | background: #111; 149 | border-radius: 4px; 150 | } 151 | 152 | ::-webkit-scrollbar-thumb { 153 | background: #333; 154 | border-radius: 4px; 155 | transition: all 0.3s ease; 156 | } 157 | 158 | ::-webkit-scrollbar-thumb:hover { 159 | background: #444; 160 | } 161 | 162 | * { 163 | scrollbar-width: thin; 164 | scrollbar-color: #333 #111; 165 | } 166 | 167 | .search-tag { 168 | background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); 169 | color: var(--text-color); 170 | padding: 0.5rem 1rem; 171 | border-radius: 0.5rem; 172 | font-size: 0.875rem; 173 | border: 1px solid var(--border-color); 174 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 175 | } 176 | 177 | .search-tag:hover { 178 | background: linear-gradient(135deg, var(--card-gradient-end), var(--card-gradient-start)); 179 | border-color: var(--primary-color); 180 | } 181 | 182 | .footer { 183 | width: 100%; 184 | transition: all 0.3s ease; 185 | margin-top: auto; 186 | background: linear-gradient(to bottom, transparent, var(--page-gradient-start)); 187 | border-top: 1px solid var(--border-color); 188 | } 189 | 190 | .footer a:hover { 191 | text-decoration: underline; 192 | } 193 | 194 | body { 195 | display: flex; 196 | flex-direction: column; 197 | min-height: 100vh; 198 | } 199 | 200 | .container { 201 | flex: 1; 202 | } 203 | 204 | @media screen and (min-height: 800px) { 205 | body { 206 | display: flex; 207 | flex-direction: column; 208 | min-height: 100vh; 209 | } 210 | 211 | .container { 212 | flex: 1; 213 | } 214 | 215 | .footer { 216 | margin-top: auto; 217 | } 218 | } 219 | 220 | @media screen and (max-width: 640px) { 221 | .footer { 222 | padding-bottom: 2rem; 223 | } 224 | } 225 | 226 | /* 移动端布局优化 */ 227 | @media screen and (max-width: 768px) { 228 | .card-hover h3 { 229 | min-height: 2.5rem; 230 | } 231 | 232 | .card-hover .flex-grow { 233 | min-height: 80px; 234 | } 235 | } 236 | 237 | @keyframes fadeIn { 238 | from { opacity: 0; } 239 | to { opacity: 1; } 240 | } 241 | 242 | @keyframes fadeOut { 243 | from { opacity: 1; } 244 | to { opacity: 0; } 245 | } 246 | 247 | #modal.show { 248 | animation: fadeIn 0.3s forwards; 249 | } 250 | 251 | #modal.hide { 252 | animation: fadeOut 0.3s forwards; 253 | } 254 | 255 | #modal > div { 256 | background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); 257 | border: 1px solid var(--primary-color); 258 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), 0 0 15px rgba(0, 204, 255, 0.1); 259 | border-radius: 8px; 260 | } 261 | 262 | #modalContent button { 263 | background: rgba(0, 204, 255, 0.08); 264 | border: 1px solid rgba(0, 204, 255, 0.2); 265 | transition: all 0.2s ease; 266 | } 267 | 268 | #modalContent button:hover { 269 | background: rgba(0, 204, 255, 0.15); 270 | border-color: var(--primary-color); 271 | box-shadow: 0 0 8px rgba(0, 204, 255, 0.3); 272 | } 273 | 274 | #yellowFilterToggle:checked + .toggle-bg { 275 | background-color: var(--primary-color); 276 | } 277 | 278 | #yellowFilterToggle:checked ~ .toggle-dot { 279 | transform: translateX(1.5rem); 280 | } 281 | 282 | #yellowFilterToggle:focus + .toggle-bg, 283 | #yellowFilterToggle:hover + .toggle-bg { 284 | box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3); 285 | } 286 | 287 | /* 添加广告过滤开关的CSS */ 288 | #adFilterToggle:checked + .toggle-bg { 289 | background-color: var(--primary-color); 290 | } 291 | 292 | #adFilterToggle:checked ~ .toggle-dot { 293 | transform: translateX(1.5rem); 294 | } 295 | 296 | #adFilterToggle:focus + .toggle-bg, 297 | #adFilterToggle:hover + .toggle-bg { 298 | box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3); 299 | } 300 | 301 | .toggle-dot { 302 | transition: transform 0.3s ease-in-out; 303 | box-shadow: 0 2px 4px rgba(0,0,0,0.2); 304 | } 305 | 306 | .toggle-bg { 307 | transition: background-color 0.3s ease-in-out; 308 | } 309 | 310 | #yellowFilterToggle:checked ~ .toggle-dot { 311 | box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3); 312 | } 313 | 314 | #adFilterToggle:checked ~ .toggle-dot { 315 | box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3); 316 | } 317 | 318 | /* 添加API复选框样式 */ 319 | .form-checkbox { 320 | appearance: none; 321 | -webkit-appearance: none; 322 | -moz-appearance: none; 323 | height: 14px; 324 | width: 14px; 325 | background-color: #222; 326 | border: 1px solid #333; 327 | border-radius: 3px; 328 | cursor: pointer; 329 | position: relative; 330 | outline: none; 331 | } 332 | 333 | .form-checkbox:checked { 334 | background-color: var(--primary-color); 335 | border-color: var(--primary-color); 336 | } 337 | 338 | .form-checkbox:checked::after { 339 | content: ''; 340 | position: absolute; 341 | left: 4px; 342 | top: 1px; 343 | width: 4px; 344 | height: 8px; 345 | border: solid white; 346 | border-width: 0 2px 2px 0; 347 | transform: rotate(45deg); 348 | } 349 | 350 | /* API滚动区域美化 */ 351 | #apiCheckboxes { 352 | scrollbar-width: thin; 353 | scrollbar-color: #444 #222; 354 | } 355 | 356 | #apiCheckboxes::-webkit-scrollbar { 357 | width: 6px; 358 | } 359 | 360 | #apiCheckboxes::-webkit-scrollbar-track { 361 | background: #222; 362 | border-radius: 4px; 363 | } 364 | 365 | #apiCheckboxes::-webkit-scrollbar-thumb { 366 | background-color: #444; 367 | border-radius: 4px; 368 | } 369 | 370 | /* 自定义API列表样式 */ 371 | #customApisList { 372 | scrollbar-width: thin; 373 | scrollbar-color: #444 #222; 374 | } 375 | 376 | #customApisList::-webkit-scrollbar { 377 | width: 6px; 378 | } 379 | 380 | #customApisList::-webkit-scrollbar-track { 381 | background: transparent; 382 | } 383 | 384 | #customApisList::-webkit-scrollbar-thumb { 385 | background-color: #444; 386 | border-radius: 4px; 387 | } 388 | 389 | /* 设置面板滚动样式 */ 390 | .settings-panel { 391 | scrollbar-width: thin; 392 | scrollbar-color: #444 #222; 393 | } 394 | 395 | .settings-panel::-webkit-scrollbar { 396 | width: 6px; 397 | } 398 | 399 | .settings-panel::-webkit-scrollbar-track { 400 | background: transparent; 401 | } 402 | 403 | .settings-panel::-webkit-scrollbar-thumb { 404 | background-color: #444; 405 | border-radius: 4px; 406 | } 407 | 408 | /* 添加自定义API表单动画 */ 409 | #addCustomApiForm { 410 | transition: all 0.3s ease; 411 | max-height: 0; 412 | opacity: 0; 413 | overflow: hidden; 414 | } 415 | 416 | #addCustomApiForm.hidden { 417 | max-height: 0; 418 | padding: 0; 419 | opacity: 0; 420 | } 421 | 422 | #addCustomApiForm:not(.hidden) { 423 | max-height: 230px; 424 | opacity: 1; 425 | } 426 | 427 | /* 成人内容API标记样式 */ 428 | .api-adult + label { 429 | color: #ff6b8b !important; 430 | } 431 | 432 | /* 添加警告图标和标签样式 */ 433 | .adult-warning { 434 | display: inline-flex; 435 | align-items: center; 436 | margin-left: 0.25rem; 437 | color: #ff6b8b; 438 | } 439 | 440 | .adult-warning svg { 441 | width: 12px; 442 | height: 12px; 443 | margin-right: 4px; 444 | } 445 | 446 | /* 过滤器禁用样式 */ 447 | .filter-disabled { 448 | opacity: 0.5; 449 | pointer-events: none; 450 | cursor: not-allowed; 451 | } 452 | 453 | /* API组标题样式 */ 454 | .api-group-title { 455 | grid-column: span 2; 456 | padding: 0.25rem 0; 457 | margin-top: 0.5rem; 458 | border-top: 1px solid #333; 459 | color: #8599b2; 460 | font-size: 0.75rem; 461 | text-transform: uppercase; 462 | letter-spacing: 0.05em; 463 | } 464 | 465 | .api-group-title.adult { 466 | color: #ff6b8b; 467 | } 468 | 469 | /* 过滤器禁用样式 - 改进版本 */ 470 | .filter-disabled { 471 | position: relative; 472 | } 473 | 474 | .filter-disabled::after { 475 | content: ''; 476 | position: absolute; 477 | top: 0; 478 | left: 0; 479 | width: 100%; 480 | height: 100%; 481 | background-color: rgba(0,0,0,0.4); 482 | border-radius: 0.5rem; 483 | z-index: 5; 484 | } 485 | 486 | .filter-disabled > * { 487 | opacity: 0.7; 488 | } 489 | 490 | .filter-disabled .toggle-bg { 491 | background-color: #333 !important; 492 | } 493 | 494 | .filter-disabled .toggle-dot { 495 | transform: translateX(0) !important; 496 | background-color: #666 !important; 497 | } 498 | 499 | /* 改进过滤器禁用样式 */ 500 | .filter-disabled .filter-description { 501 | color: #ff6b8b !important; 502 | font-style: italic; 503 | font-weight: 500; 504 | } 505 | 506 | /* 修改过滤器禁用样式,确保文字清晰可见 */ 507 | .filter-disabled { 508 | position: relative; 509 | } 510 | 511 | .filter-disabled::after { 512 | content: ''; 513 | position: absolute; 514 | top: 0; 515 | left: 0; 516 | width: 100%; 517 | height: 100%; 518 | background-color: rgba(0,0,0,0.3); 519 | border-radius: 0.5rem; 520 | z-index: 5; 521 | } 522 | 523 | .filter-disabled > * { 524 | opacity: 1; /* 提高子元素不透明度,保证可见性 */ 525 | z-index: 6; /* 确保内容在遮罩上方 */ 526 | position: relative; 527 | } 528 | 529 | /* 改进过滤器禁用状态下的描述样式 */ 530 | .filter-disabled .filter-description { 531 | color: #ff7b9d !important; /* 更亮的粉色 */ 532 | font-style: italic; 533 | font-weight: 500; 534 | text-shadow: 0 0 2px rgba(0,0,0,0.8); /* 添加文字阴影提高对比度 */ 535 | } 536 | 537 | /* 开关的禁用样式 */ 538 | .filter-disabled .toggle-bg { 539 | background-color: #444 !important; 540 | opacity: 0.8; 541 | } 542 | 543 | .filter-disabled .toggle-dot { 544 | transform: translateX(0) ; 545 | background-color: #777 ; 546 | opacity: 0.9; 547 | } 548 | 549 | /* 警告提示样式改进 */ 550 | .filter-tooltip { 551 | background-color: rgba(255, 61, 87, 0.1); 552 | border: 1px solid rgba(255, 61, 87, 0.2); 553 | border-radius: 0.25rem; 554 | padding: 0.5rem; 555 | margin-top: 0.5rem; 556 | display: flex; 557 | align-items: center; 558 | font-size: 0.75rem; 559 | line-height: 1.25; 560 | position: relative; 561 | z-index: 10; 562 | } 563 | 564 | .filter-tooltip svg { 565 | flex-shrink: 0; 566 | width: 14px; 567 | height: 14px; 568 | margin-right: 0.35rem; 569 | } 570 | 571 | /* 编辑按钮样式 */ 572 | .custom-api-edit { 573 | color: #3b82f6; 574 | transition: color 0.2s ease; 575 | } 576 | 577 | .custom-api-edit:hover { 578 | color: #2563eb; 579 | } 580 | 581 | /* 自定义API条目样式改进 */ 582 | #customApisList .api-item { 583 | display: flex; 584 | align-items: center; 585 | justify-content: space-between; 586 | padding: 0.25rem 0.5rem; 587 | margin-bottom: 0.25rem; 588 | background-color: #222; 589 | border-radius: 0.25rem; 590 | transition: background-color 0.2s ease; 591 | } 592 | 593 | #customApisList .api-item:hover { 594 | background-color: #2a2a2a; 595 | } 596 | 597 | /* 成人内容标签样式 */ 598 | .adult-tag { 599 | display: inline-flex; 600 | align-items: center; 601 | color: #ff6b8b; 602 | font-size: 0.7rem; 603 | font-weight: 500; 604 | margin-right: 0.35rem; 605 | } 606 | 607 | /* 历史记录面板样式 */ 608 | .history-panel { 609 | box-shadow: 2px 0 10px rgba(0,0,0,0.5); 610 | transition: transform 0.3s ease-in-out; 611 | overflow-y: scroll; /* 始终显示滚动条,防止宽度变化 */ 612 | overflow-x: hidden; /* 防止水平滚动 */ 613 | width: 320px; /* 固定宽度 */ 614 | box-sizing: border-box; /* 确保padding不影响总宽度 */ 615 | scrollbar-gutter: stable; /* 现代浏览器:为滚动条预留空间 */ 616 | } 617 | 618 | .history-panel.show { 619 | transform: translateX(0); 620 | } 621 | 622 | #historyList { 623 | padding-right: 6px; /* 为滚动条预留空间,确保内容不被挤压 */ 624 | } 625 | 626 | /* 历史记录项样式优化 */ 627 | .history-item { 628 | background: #1a1a1a; 629 | border-radius: 6px; /* 减小圆角 */ 630 | border: 1px solid #333; 631 | overflow: hidden; 632 | transition: all 0.2s ease; 633 | padding: 10px 14px; 634 | position: relative; 635 | margin-bottom: 8px; /* 减小底部间距 */ 636 | width: 100%; /* 确保宽度一致 */ 637 | } 638 | 639 | .history-item:hover { 640 | transform: translateY(-2px); 641 | border-color: #444; 642 | box-shadow: 0 4px 8px rgba(0,0,0,0.2); 643 | } 644 | 645 | /* 添加组悬停效果,使删除按钮在悬停时显示 */ 646 | .history-item .delete-btn { 647 | opacity: 0; 648 | transition: opacity 0.2s ease; 649 | } 650 | 651 | .history-item:hover .delete-btn { 652 | opacity: 1; 653 | } 654 | 655 | .history-info { 656 | padding: 0; /* 移除额外的内边距 */ 657 | min-height: 70px; 658 | } 659 | 660 | .history-title { 661 | font-weight: 500; 662 | font-size: 0.95rem; /* 减小字体大小 */ 663 | margin-bottom: 2px; /* 减小底部边距 */ 664 | overflow: hidden; 665 | text-overflow: ellipsis; 666 | white-space: nowrap; 667 | } 668 | 669 | .history-meta { 670 | color: #bbb; 671 | font-size: 0.75rem; /* 减小字体大小 */ 672 | display: flex; 673 | flex-wrap: wrap; 674 | margin-bottom: 1px; /* 减小边距 */ 675 | } 676 | 677 | .history-episode { 678 | color: #3b82f6; 679 | } 680 | 681 | .history-source { 682 | color: #10b981; 683 | } 684 | 685 | .history-time { 686 | color: #888; 687 | font-size: 0.7rem; /* 减小字体大小 */ 688 | margin-top: 1px; /* 减小顶部边距 */ 689 | } 690 | 691 | .history-separator { 692 | color: #666; 693 | } 694 | 695 | .history-thumbnail { 696 | width: 100%; 697 | height: 90px; 698 | background-color: #222; 699 | overflow: hidden; 700 | } 701 | 702 | .history-thumbnail img { 703 | width: 100%; 704 | height: 100%; 705 | object-fit: cover; 706 | } 707 | 708 | .history-info { 709 | padding: 10px; 710 | } 711 | 712 | .history-time { 713 | color: #888; 714 | font-size: 0.8rem; 715 | margin-top: 4px; 716 | } 717 | 718 | .history-title { 719 | font-weight: 500; 720 | white-space: nowrap; 721 | overflow: hidden; 722 | text-overflow: ellipsis; 723 | } 724 | 725 | /* 添加播放进度条样式 */ 726 | .history-progress { 727 | margin: 5px 0; 728 | } 729 | 730 | .progress-bar { 731 | height: 3px; 732 | background-color: rgba(255, 255, 255, 0.1); 733 | border-radius: 2px; 734 | overflow: hidden; 735 | margin-bottom: 2px; 736 | } 737 | 738 | .progress-filled { 739 | height: 100%; 740 | background: linear-gradient(to right, #00ccff, #3b82f6); 741 | border-radius: 2px; 742 | } 743 | 744 | .progress-text { 745 | font-size: 10px; 746 | color: #888; 747 | text-align: right; 748 | } 749 | 750 | /* 添加恢复播放提示样式 */ 751 | .position-restore-hint { 752 | position: absolute; 753 | bottom: 60px; 754 | left: 50%; 755 | transform: translateX(-50%) translateY(20px); 756 | background-color: rgba(0, 0, 0, 0.7); 757 | color: white; 758 | padding: 8px 16px; 759 | border-radius: 4px; 760 | font-size: 14px; 761 | z-index: 100; 762 | opacity: 0; 763 | transition: all 0.3s ease; 764 | } 765 | 766 | .position-restore-hint.show { 767 | opacity: 1; 768 | transform: translateX(-50%) translateY(0); 769 | } 770 | 771 | /* 锁定控制时屏蔽交互 */ 772 | .player-container.controls-locked .dplayer-controller, 773 | .player-container.controls-locked .dplayer-mask, 774 | .player-container.controls-locked .dplayer-bar-wrap, 775 | .player-container.controls-locked .dplayer-statusbar, 776 | .player-container.controls-locked .shortcut-hint { 777 | opacity: 0 !important; 778 | pointer-events: none !important; 779 | } 780 | /* 保留锁按钮可见可点 */ 781 | .player-container.controls-locked #lockToggle { 782 | opacity: 1 !important; 783 | pointer-events: auto !important; 784 | } 785 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | libretv: 4 | image: bestzwei/libretv:latest 5 | container_name: libretv 6 | ports: 7 | - "8899:80" 8 | environment: 9 | - PASSWORD=111111 10 | restart: unless-stopped -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Function to hash a string with SHA-256 5 | hash_password() { 6 | if [ -z "$1" ]; then 7 | echo "" 8 | else 9 | echo -n "$1" | sha256sum | cut -d ' ' -f 1 10 | fi 11 | } 12 | 13 | # Function to replace environment variables in HTML files 14 | replace_env_vars() { 15 | # Hash the password if it exists 16 | local password_hash="" 17 | if [ -n "$PASSWORD" ]; then 18 | password_hash=$(hash_password "$PASSWORD") 19 | fi 20 | 21 | # Replace the password placeholder in all HTML files with the hashed password 22 | find /usr/share/nginx/html -type f -name "*.html" -exec sed -i "s/window.__ENV__.PASSWORD = \"{{PASSWORD}}\";/window.__ENV__.PASSWORD = \"${password_hash}\";/g" {} \; 23 | 24 | echo "Environment variables have been injected into HTML files." 25 | } 26 | 27 | # Replace environment variables in HTML files 28 | replace_env_vars 29 | 30 | # Execute the command provided as arguments 31 | exec "$@" -------------------------------------------------------------------------------- /functions/_middleware.js: -------------------------------------------------------------------------------- 1 | import { sha256 } from '../js/sha256.js'; // 需新建或引入SHA-256实现 2 | 3 | // Cloudflare Pages Middleware to inject environment variables 4 | export async function onRequest(context) { 5 | const { request, env, next } = context; 6 | 7 | // Proceed to the next middleware or route handler 8 | const response = await next(); 9 | 10 | // Check if the response is HTML 11 | const contentType = response.headers.get("content-type") || ""; 12 | 13 | if (contentType.includes("text/html")) { 14 | // Get the original HTML content 15 | let html = await response.text(); 16 | 17 | // Replace the placeholder with actual environment variable value 18 | // If PASSWORD is not set, replace with empty string 19 | const password = env.PASSWORD || ""; 20 | let passwordHash = ""; 21 | if (password) { 22 | passwordHash = await sha256(password); 23 | } 24 | html = html.replace('window.__ENV__.PASSWORD = "{{PASSWORD}}";', 25 | `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`); 26 | 27 | // Create a new response with the modified HTML 28 | return new Response(html, { 29 | headers: response.headers, 30 | status: response.status, 31 | statusText: response.statusText, 32 | }); 33 | } 34 | 35 | // Return the original response for non-HTML content 36 | return response; 37 | } -------------------------------------------------------------------------------- /functions/proxy/[[path]].js: -------------------------------------------------------------------------------- 1 | // functions/proxy/[[path]].js 2 | 3 | // --- 配置 (现在从 Cloudflare 环境变量读取) --- 4 | // 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量: 5 | // CACHE_TTL (例如 86400) 6 | // MAX_RECURSION (例如 5) 7 | // FILTER_DISCONTINUITY (不再需要,设为 false 或移除) 8 | // USER_AGENTS_JSON (例如 ["UA1", "UA2"]) - JSON 字符串数组 9 | // DEBUG (例如 false 或 true) 10 | // --- 配置结束 --- 11 | 12 | // --- 常量 (之前在 config.js 中,现在移到这里,因为它们与代理逻辑相关) --- 13 | const MEDIA_FILE_EXTENSIONS = [ 14 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 15 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 16 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 17 | ]; 18 | const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/']; 19 | // --- 常量结束 --- 20 | 21 | 22 | /** 23 | * 主要的 Pages Function 处理函数 24 | * 拦截发往 /proxy/* 的请求 25 | */ 26 | export async function onRequest(context) { 27 | const { request, env, next, waitUntil } = context; // next 和 waitUntil 可能需要 28 | const url = new URL(request.url); 29 | 30 | // --- 从环境变量读取配置 --- 31 | const DEBUG_ENABLED = (env.DEBUG === 'true'); 32 | const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); // 默认 24 小时 33 | const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); // 默认 5 层 34 | // 广告过滤已移至播放器处理,代理不再执行 35 | let USER_AGENTS = [ // 提供一个基础的默认值 36 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' 37 | ]; 38 | try { 39 | // 尝试从环境变量解析 USER_AGENTS_JSON 40 | const agentsJson = env.USER_AGENTS_JSON; 41 | if (agentsJson) { 42 | const parsedAgents = JSON.parse(agentsJson); 43 | if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { 44 | USER_AGENTS = parsedAgents; 45 | } else { 46 | logDebug("环境变量 USER_AGENTS_JSON 格式无效或为空,使用默认值"); 47 | } 48 | } 49 | } catch (e) { 50 | logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message},使用默认值`); 51 | } 52 | // --- 配置读取结束 --- 53 | 54 | 55 | // --- 辅助函数 --- 56 | 57 | // 输出调试日志 (需要设置 DEBUG: true 环境变量) 58 | function logDebug(message) { 59 | if (DEBUG_ENABLED) { 60 | console.log(`[Proxy Func] ${message}`); 61 | } 62 | } 63 | 64 | // 从请求路径中提取目标 URL 65 | function getTargetUrlFromPath(pathname) { 66 | // 路径格式: /proxy/经过编码的URL 67 | // 例如: /proxy/https%3A%2F%2Fexample.com%2Fplaylist.m3u8 68 | const encodedUrl = pathname.replace(/^\/proxy\//, ''); 69 | if (!encodedUrl) return null; 70 | try { 71 | // 解码 72 | let decodedUrl = decodeURIComponent(encodedUrl); 73 | 74 | // 简单检查解码后是否是有效的 http/https URL 75 | if (!decodedUrl.match(/^https?:\/\//i)) { 76 | // 也许原始路径就没有编码?如果看起来像URL就直接用 77 | if (encodedUrl.match(/^https?:\/\//i)) { 78 | decodedUrl = encodedUrl; 79 | logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`); 80 | } else { 81 | logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`); 82 | return null; 83 | } 84 | } 85 | return decodedUrl; 86 | 87 | } catch (e) { 88 | logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`); 89 | return null; 90 | } 91 | } 92 | 93 | // 创建标准化的响应 94 | function createResponse(body, status = 200, headers = {}) { 95 | const responseHeaders = new Headers(headers); 96 | // 关键:添加 CORS 跨域头,允许前端 JS 访问代理后的响应 97 | responseHeaders.set("Access-Control-Allow-Origin", "*"); // 允许任何来源访问 98 | responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); // 允许的方法 99 | responseHeaders.set("Access-Control-Allow-Headers", "*"); // 允许所有请求头 100 | 101 | // 处理 CORS 预检请求 (OPTIONS) - 放在这里确保所有响应都处理 102 | if (request.method === "OPTIONS") { 103 | // 使用下面的 onOptions 函数可以更规范,但在这里处理也可以 104 | return new Response(null, { 105 | status: 204, // No Content 106 | headers: responseHeaders // 包含上面设置的 CORS 头 107 | }); 108 | } 109 | 110 | return new Response(body, { status, headers: responseHeaders }); 111 | } 112 | 113 | // 创建 M3U8 类型的响应 114 | function createM3u8Response(content) { 115 | return createResponse(content, 200, { 116 | "Content-Type": "application/vnd.apple.mpegurl", // M3U8 的标准 MIME 类型 117 | "Cache-Control": `public, max-age=${CACHE_TTL}` // 允许浏览器和CDN缓存 118 | }); 119 | } 120 | 121 | // 获取随机 User-Agent 122 | function getRandomUserAgent() { 123 | return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; 124 | } 125 | 126 | // 获取 URL 的基础路径 (用于解析相对路径) 127 | function getBaseUrl(urlStr) { 128 | try { 129 | const parsedUrl = new URL(urlStr); 130 | // 如果路径是根目录,或者没有斜杠,直接返回 origin + / 131 | if (!parsedUrl.pathname || parsedUrl.pathname === '/') { 132 | return `${parsedUrl.origin}/`; 133 | } 134 | const pathParts = parsedUrl.pathname.split('/'); 135 | pathParts.pop(); // 移除文件名或最后一个路径段 136 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 137 | } catch (e) { 138 | logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`); 139 | // 备用方法:找到最后一个斜杠 140 | const lastSlashIndex = urlStr.lastIndexOf('/'); 141 | // 确保不是协议部分的斜杠 (http://) 142 | return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/'; 143 | } 144 | } 145 | 146 | 147 | // 将相对 URL 转换为绝对 URL 148 | function resolveUrl(baseUrl, relativeUrl) { 149 | // 如果已经是绝对 URL,直接返回 150 | if (relativeUrl.match(/^https?:\/\//i)) { 151 | return relativeUrl; 152 | } 153 | try { 154 | // 使用 URL 对象来处理相对路径 155 | return new URL(relativeUrl, baseUrl).toString(); 156 | } catch (e) { 157 | logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`); 158 | // 简单的备用方法 159 | if (relativeUrl.startsWith('/')) { 160 | // 处理根路径相对 URL 161 | const urlObj = new URL(baseUrl); 162 | return `${urlObj.origin}${relativeUrl}`; 163 | } 164 | // 处理同级目录相对 URL 165 | return `${baseUrl.replace(/\/[^/]*$/, '/')}${relativeUrl}`; // 确保baseUrl以 / 结尾 166 | } 167 | } 168 | 169 | // 将目标 URL 重写为内部代理路径 (/proxy/...) 170 | function rewriteUrlToProxy(targetUrl) { 171 | // 确保目标URL被正确编码,以便作为路径的一部分 172 | return `/proxy/${encodeURIComponent(targetUrl)}`; 173 | } 174 | 175 | // 获取远程内容及其类型 176 | async function fetchContentWithType(targetUrl) { 177 | const headers = new Headers({ 178 | 'User-Agent': getRandomUserAgent(), 179 | 'Accept': '*/*', 180 | // 尝试传递一些原始请求的头信息 181 | 'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8', 182 | // 尝试设置 Referer 为目标网站的域名,或者传递原始 Referer 183 | 'Referer': request.headers.get('Referer') || new URL(targetUrl).origin 184 | }); 185 | 186 | try { 187 | // 直接请求目标 URL 188 | logDebug(`开始直接请求: ${targetUrl}`); 189 | // Cloudflare Functions 的 fetch 默认支持重定向 190 | const response = await fetch(targetUrl, { headers, redirect: 'follow' }); 191 | 192 | if (!response.ok) { 193 | const errorBody = await response.text().catch(() => ''); 194 | logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`); 195 | throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`); 196 | } 197 | 198 | // 读取响应内容为文本 199 | const content = await response.text(); 200 | const contentType = response.headers.get('Content-Type') || ''; 201 | logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`); 202 | return { content, contentType, responseHeaders: response.headers }; // 同时返回原始响应头 203 | 204 | } catch (error) { 205 | logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`); 206 | // 抛出更详细的错误 207 | throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`); 208 | } 209 | } 210 | 211 | // 判断是否是 M3U8 内容 212 | function isM3u8Content(content, contentType) { 213 | // 检查 Content-Type 214 | if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { 215 | return true; 216 | } 217 | // 检查内容本身是否以 #EXTM3U 开头 218 | return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); 219 | } 220 | 221 | // 判断是否是媒体文件 (根据扩展名和 Content-Type) - 这部分在此代理中似乎未使用,但保留 222 | function isMediaFile(url, contentType) { 223 | if (contentType) { 224 | for (const mediaType of MEDIA_CONTENT_TYPES) { 225 | if (contentType.toLowerCase().startsWith(mediaType)) { 226 | return true; 227 | } 228 | } 229 | } 230 | const urlLower = url.toLowerCase(); 231 | for (const ext of MEDIA_FILE_EXTENSIONS) { 232 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 233 | return true; 234 | } 235 | } 236 | return false; 237 | } 238 | 239 | // 处理 M3U8 中的 #EXT-X-KEY 行 (加密密钥) 240 | function processKeyLine(line, baseUrl) { 241 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 242 | const absoluteUri = resolveUrl(baseUrl, uri); 243 | logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`); 244 | return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径 245 | }); 246 | } 247 | 248 | // 处理 M3U8 中的 #EXT-X-MAP 行 (初始化片段) 249 | function processMapLine(line, baseUrl) { 250 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 251 | const absoluteUri = resolveUrl(baseUrl, uri); 252 | logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`); 253 | return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径 254 | }); 255 | } 256 | 257 | // 处理媒体 M3U8 播放列表 (包含视频/音频片段) 258 | function processMediaPlaylist(url, content) { 259 | const baseUrl = getBaseUrl(url); 260 | const lines = content.split('\n'); 261 | const output = []; 262 | 263 | for (let i = 0; i < lines.length; i++) { 264 | const line = lines[i].trim(); 265 | // 保留最后的空行 266 | if (!line && i === lines.length - 1) { 267 | output.push(line); 268 | continue; 269 | } 270 | if (!line) continue; // 跳过中间的空行 271 | 272 | if (line.startsWith('#EXT-X-KEY')) { 273 | output.push(processKeyLine(line, baseUrl)); 274 | continue; 275 | } 276 | if (line.startsWith('#EXT-X-MAP')) { 277 | output.push(processMapLine(line, baseUrl)); 278 | continue; 279 | } 280 | if (line.startsWith('#EXTINF')) { 281 | output.push(line); 282 | continue; 283 | } 284 | if (!line.startsWith('#')) { 285 | const absoluteUrl = resolveUrl(baseUrl, line); 286 | logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`); 287 | output.push(rewriteUrlToProxy(absoluteUrl)); 288 | continue; 289 | } 290 | // 其他 M3U8 标签直接添加 291 | output.push(line); 292 | } 293 | return output.join('\n'); 294 | } 295 | 296 | // 递归处理 M3U8 内容 297 | async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) { 298 | if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { 299 | logDebug(`检测到主播放列表: ${targetUrl}`); 300 | return await processMasterPlaylist(targetUrl, content, recursionDepth, env); 301 | } 302 | logDebug(`检测到媒体播放列表: ${targetUrl}`); 303 | return processMediaPlaylist(targetUrl, content); 304 | } 305 | 306 | // 处理主 M3U8 播放列表 307 | async function processMasterPlaylist(url, content, recursionDepth, env) { 308 | if (recursionDepth > MAX_RECURSION) { 309 | throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`); 310 | } 311 | 312 | const baseUrl = getBaseUrl(url); 313 | const lines = content.split('\n'); 314 | let highestBandwidth = -1; 315 | let bestVariantUrl = ''; 316 | 317 | for (let i = 0; i < lines.length; i++) { 318 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 319 | const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/); 320 | const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; 321 | 322 | let variantUriLine = ''; 323 | for (let j = i + 1; j < lines.length; j++) { 324 | const line = lines[j].trim(); 325 | if (line && !line.startsWith('#')) { 326 | variantUriLine = line; 327 | i = j; 328 | break; 329 | } 330 | } 331 | 332 | if (variantUriLine && currentBandwidth >= highestBandwidth) { 333 | highestBandwidth = currentBandwidth; 334 | bestVariantUrl = resolveUrl(baseUrl, variantUriLine); 335 | } 336 | } 337 | } 338 | 339 | if (!bestVariantUrl) { 340 | logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF,尝试查找第一个子列表引用: ${url}`); 341 | for (let i = 0; i < lines.length; i++) { 342 | const line = lines[i].trim(); 343 | if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { // 修复:检查是否包含 .m3u8? 344 | bestVariantUrl = resolveUrl(baseUrl, line); 345 | logDebug(`备选方案:找到第一个子列表引用: ${bestVariantUrl}`); 346 | break; 347 | } 348 | } 349 | } 350 | 351 | if (!bestVariantUrl) { 352 | logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`); 353 | return processMediaPlaylist(url, content); 354 | } 355 | 356 | // --- 获取并处理选中的子 M3U8 --- 357 | 358 | const cacheKey = `m3u8_processed:${bestVariantUrl}`; // 使用处理后的缓存键 359 | 360 | let kvNamespace = null; 361 | try { 362 | kvNamespace = env.LIBRETV_PROXY_KV; // 从环境获取 KV 命名空间 (变量名在 Cloudflare 设置) 363 | if (!kvNamespace) throw new Error("KV 命名空间未绑定"); 364 | } catch (e) { 365 | logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); 366 | kvNamespace = null; // 确保设为 null 367 | } 368 | 369 | if (kvNamespace) { 370 | try { 371 | const cachedContent = await kvNamespace.get(cacheKey); 372 | if (cachedContent) { 373 | logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`); 374 | return cachedContent; 375 | } else { 376 | logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`); 377 | } 378 | } catch (kvError) { 379 | logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`); 380 | // 出错则继续执行,不影响功能 381 | } 382 | } 383 | 384 | logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`); 385 | const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl); 386 | 387 | if (!isM3u8Content(variantContent, variantContentType)) { 388 | logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件,返回原始内容。`); 389 | // 如果不是M3U8,但看起来像媒体内容,直接返回代理后的内容 390 | // 注意:这里可能需要决定是否直接代理这个非 M3U8 的 URL 391 | // 为了简化,我们假设如果不是 M3U8,则流程中断或按原样处理 392 | // 或者,尝试将其作为媒体列表处理?(当前行为) 393 | // return createResponse(variantContent, 200, { 'Content-Type': variantContentType || 'application/octet-stream' }); 394 | // 尝试按媒体列表处理,以防万一 395 | return processMediaPlaylist(bestVariantUrl, variantContent); 396 | 397 | } 398 | 399 | const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env); 400 | 401 | if (kvNamespace) { 402 | try { 403 | // 使用 waitUntil 异步写入缓存,不阻塞响应返回 404 | // 注意 KV 的写入限制 (免费版每天 1000 次) 405 | waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL })); 406 | logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`); 407 | } catch (kvError) { 408 | logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); 409 | // 写入失败不影响返回结果 410 | } 411 | } 412 | 413 | return processedVariant; 414 | } 415 | 416 | // --- 主要请求处理逻辑 --- 417 | 418 | try { 419 | const targetUrl = getTargetUrlFromPath(url.pathname); 420 | 421 | if (!targetUrl) { 422 | logDebug(`无效的代理请求路径: ${url.pathname}`); 423 | return createResponse("无效的代理请求。路径应为 /proxy/<经过编码的URL>", 400); 424 | } 425 | 426 | logDebug(`收到代理请求: ${targetUrl}`); 427 | 428 | // --- 缓存检查 (KV) --- 429 | const cacheKey = `proxy_raw:${targetUrl}`; // 使用原始内容的缓存键 430 | let kvNamespace = null; 431 | try { 432 | kvNamespace = env.LIBRETV_PROXY_KV; 433 | if (!kvNamespace) throw new Error("KV 命名空间未绑定"); 434 | } catch (e) { 435 | logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); 436 | kvNamespace = null; 437 | } 438 | 439 | if (kvNamespace) { 440 | try { 441 | const cachedDataJson = await kvNamespace.get(cacheKey); // 直接获取字符串 442 | if (cachedDataJson) { 443 | logDebug(`[缓存命中] 原始内容: ${targetUrl}`); 444 | const cachedData = JSON.parse(cachedDataJson); // 解析 JSON 445 | const content = cachedData.body; 446 | let headers = {}; 447 | try { headers = JSON.parse(cachedData.headers); } catch(e){} // 解析头部 448 | const contentType = headers['content-type'] || headers['Content-Type'] || ''; 449 | 450 | if (isM3u8Content(content, contentType)) { 451 | logDebug(`缓存内容是 M3U8,重新处理: ${targetUrl}`); 452 | const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); 453 | return createM3u8Response(processedM3u8); 454 | } else { 455 | logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`); 456 | return createResponse(content, 200, new Headers(headers)); 457 | } 458 | } else { 459 | logDebug(`[缓存未命中] 原始内容: ${targetUrl}`); 460 | } 461 | } catch (kvError) { 462 | logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`); 463 | // 出错则继续执行,不影响功能 464 | } 465 | } 466 | 467 | // --- 实际请求 --- 468 | const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl); 469 | 470 | // --- 写入缓存 (KV) --- 471 | if (kvNamespace) { 472 | try { 473 | const headersToCache = {}; 474 | responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; }); 475 | const cacheValue = { body: content, headers: JSON.stringify(headersToCache) }; 476 | // 注意 KV 写入限制 477 | waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL })); 478 | logDebug(`已将原始内容写入缓存: ${targetUrl}`); 479 | } catch (kvError) { 480 | logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); 481 | // 写入失败不影响返回结果 482 | } 483 | } 484 | 485 | // --- 处理响应 --- 486 | if (isM3u8Content(content, contentType)) { 487 | logDebug(`内容是 M3U8,开始处理: ${targetUrl}`); 488 | const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); 489 | return createM3u8Response(processedM3u8); 490 | } else { 491 | logDebug(`内容不是 M3U8 (类型: ${contentType}),直接返回: ${targetUrl}`); 492 | const finalHeaders = new Headers(responseHeaders); 493 | finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`); 494 | // 添加 CORS 头,确保非 M3U8 内容也能跨域访问(例如图片、字幕文件等) 495 | finalHeaders.set("Access-Control-Allow-Origin", "*"); 496 | finalHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); 497 | finalHeaders.set("Access-Control-Allow-Headers", "*"); 498 | return createResponse(content, 200, finalHeaders); 499 | } 500 | 501 | } catch (error) { 502 | logDebug(`处理代理请求时发生严重错误: ${error.message} \n ${error.stack}`); 503 | return createResponse(`代理处理错误: ${error.message}`, 500); 504 | } 505 | } 506 | 507 | // 处理 OPTIONS 预检请求的函数 508 | export async function onOptions(context) { 509 | // 直接返回允许跨域的头信息 510 | return new Response(null, { 511 | status: 204, // No Content 512 | headers: { 513 | "Access-Control-Allow-Origin": "*", 514 | "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", 515 | "Access-Control-Allow-Headers": "*", // 允许所有请求头 516 | "Access-Control-Max-Age": "86400", // 预检请求结果缓存一天 517 | }, 518 | }); 519 | } 520 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LibreTV - 免费在线视频搜索与观看平台 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 44 |
45 | 46 | 47 |
48 | 54 |
55 | 56 | 57 | 73 | 74 | 75 | 180 | 181 |
182 |
183 | 184 |
185 | 193 |

自由观影,畅享精彩

194 |
195 | 196 | 197 |
198 |
199 |
200 | 201 | 206 | 207 | 213 | 214 | 219 |
220 | 221 | 222 |
223 | 224 |
225 |
226 |
227 | 228 | 229 | 260 | 261 | 262 | 274 |
275 |
276 | 277 | 278 | 304 | 305 | 306 | 318 | 319 | 320 | 336 | 337 | 338 | 362 | 363 | 364 |
365 |

366 |
367 | 368 | 369 | 375 | 376 | 377 | 391 | 392 | 393 | 394 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 414 | 415 | 416 | 437 | 438 | -------------------------------------------------------------------------------- /js/api.js: -------------------------------------------------------------------------------- 1 | // 改进的API请求处理函数 2 | async function handleApiRequest(url) { 3 | const customApi = url.searchParams.get('customApi') || ''; 4 | const source = url.searchParams.get('source') || 'heimuer'; 5 | 6 | try { 7 | if (url.pathname === '/api/search') { 8 | const searchQuery = url.searchParams.get('wd'); 9 | if (!searchQuery) { 10 | throw new Error('缺少搜索参数'); 11 | } 12 | 13 | // 验证API和source的有效性 14 | if (source === 'custom' && !customApi) { 15 | throw new Error('使用自定义API时必须提供API地址'); 16 | } 17 | 18 | if (!API_SITES[source] && source !== 'custom') { 19 | throw new Error('无效的API来源'); 20 | } 21 | 22 | const apiUrl = customApi 23 | ? `${customApi}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}` 24 | : `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; 25 | 26 | // 添加超时处理 27 | const controller = new AbortController(); 28 | const timeoutId = setTimeout(() => controller.abort(), 10000); 29 | 30 | try { 31 | const response = await fetch(PROXY_URL + encodeURIComponent(apiUrl), { 32 | headers: API_CONFIG.search.headers, 33 | signal: controller.signal 34 | }); 35 | 36 | clearTimeout(timeoutId); 37 | 38 | if (!response.ok) { 39 | throw new Error(`API请求失败: ${response.status}`); 40 | } 41 | 42 | const data = await response.json(); 43 | 44 | // 检查JSON格式的有效性 45 | if (!data || !Array.isArray(data.list)) { 46 | throw new Error('API返回的数据格式无效'); 47 | } 48 | 49 | // 添加源信息到每个结果 50 | data.list.forEach(item => { 51 | item.source_name = source === 'custom' ? '自定义源' : API_SITES[source].name; 52 | item.source_code = source; 53 | // 对于自定义源,添加API URL信息 54 | if (source === 'custom') { 55 | item.api_url = customApi; 56 | } 57 | }); 58 | 59 | return JSON.stringify({ 60 | code: 200, 61 | list: data.list || [], 62 | }); 63 | } catch (fetchError) { 64 | clearTimeout(timeoutId); 65 | throw fetchError; 66 | } 67 | } 68 | 69 | // 详情处理 70 | if (url.pathname === '/api/detail') { 71 | const id = url.searchParams.get('id'); 72 | const sourceCode = url.searchParams.get('source') || 'heimuer'; // 获取源代码 73 | 74 | if (!id) { 75 | throw new Error('缺少视频ID参数'); 76 | } 77 | 78 | // 验证ID格式 - 只允许数字和有限的特殊字符 79 | if (!/^[\w-]+$/.test(id)) { 80 | throw new Error('无效的视频ID格式'); 81 | } 82 | 83 | // 验证API和source的有效性 84 | if (sourceCode === 'custom' && !customApi) { 85 | throw new Error('使用自定义API时必须提供API地址'); 86 | } 87 | 88 | if (!API_SITES[sourceCode] && sourceCode !== 'custom') { 89 | throw new Error('无效的API来源'); 90 | } 91 | 92 | // 对于特殊源,使用特殊处理方式 93 | if ((sourceCode === 'ffzy' || sourceCode === 'jisu' || sourceCode === 'huangcang') && API_SITES[sourceCode].detail) { 94 | return await handleSpecialSourceDetail(id, sourceCode); 95 | } 96 | 97 | // 如果是自定义API,并且传递了detail参数,尝试特殊处理 98 | if (sourceCode === 'custom' && url.searchParams.get('useDetail') === 'true') { 99 | return await handleCustomApiSpecialDetail(id, customApi); 100 | } 101 | 102 | const detailUrl = customApi 103 | ? `${customApi}${API_CONFIG.detail.path}${id}` 104 | : `${API_SITES[sourceCode].api}${API_CONFIG.detail.path}${id}`; 105 | 106 | // 添加超时处理 107 | const controller = new AbortController(); 108 | const timeoutId = setTimeout(() => controller.abort(), 10000); 109 | 110 | try { 111 | const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), { 112 | headers: API_CONFIG.detail.headers, 113 | signal: controller.signal 114 | }); 115 | 116 | clearTimeout(timeoutId); 117 | 118 | if (!response.ok) { 119 | throw new Error(`详情请求失败: ${response.status}`); 120 | } 121 | 122 | // 解析JSON 123 | const data = await response.json(); 124 | 125 | // 检查返回的数据是否有效 126 | if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) { 127 | throw new Error('获取到的详情内容无效'); 128 | } 129 | 130 | // 获取第一个匹配的视频详情 131 | const videoDetail = data.list[0]; 132 | 133 | // 提取播放地址 134 | let episodes = []; 135 | 136 | if (videoDetail.vod_play_url) { 137 | // 分割不同播放源 138 | const playSources = videoDetail.vod_play_url.split('$$$'); 139 | 140 | // 提取第一个播放源的集数(通常为主要源) 141 | if (playSources.length > 0) { 142 | const mainSource = playSources[0]; 143 | const episodeList = mainSource.split('#'); 144 | 145 | // 从每个集数中提取URL 146 | episodes = episodeList.map(ep => { 147 | const parts = ep.split('$'); 148 | // 返回URL部分(通常是第二部分,如果有的话) 149 | return parts.length > 1 ? parts[1] : ''; 150 | }).filter(url => url && (url.startsWith('http://') || url.startsWith('https://'))); 151 | } 152 | } 153 | 154 | // 如果没有找到播放地址,尝试使用正则表达式查找m3u8链接 155 | if (episodes.length === 0 && videoDetail.vod_content) { 156 | const matches = videoDetail.vod_content.match(M3U8_PATTERN) || []; 157 | episodes = matches.map(link => link.replace(/^\$/, '')); 158 | } 159 | 160 | return JSON.stringify({ 161 | code: 200, 162 | episodes: episodes, 163 | detailUrl: detailUrl, 164 | videoInfo: { 165 | title: videoDetail.vod_name, 166 | cover: videoDetail.vod_pic, 167 | desc: videoDetail.vod_content, 168 | type: videoDetail.type_name, 169 | year: videoDetail.vod_year, 170 | area: videoDetail.vod_area, 171 | director: videoDetail.vod_director, 172 | actor: videoDetail.vod_actor, 173 | remarks: videoDetail.vod_remarks, 174 | // 添加源信息 175 | source_name: sourceCode === 'custom' ? '自定义源' : API_SITES[sourceCode].name, 176 | source_code: sourceCode 177 | } 178 | }); 179 | } catch (fetchError) { 180 | clearTimeout(timeoutId); 181 | throw fetchError; 182 | } 183 | } 184 | 185 | throw new Error('未知的API路径'); 186 | } catch (error) { 187 | console.error('API处理错误:', error); 188 | return JSON.stringify({ 189 | code: 400, 190 | msg: error.message || '请求处理失败', 191 | list: [], 192 | episodes: [], 193 | }); 194 | } 195 | } 196 | 197 | // 处理自定义API的特殊详情页 198 | async function handleCustomApiSpecialDetail(id, customApi) { 199 | try { 200 | // 构建详情页URL 201 | const detailUrl = `${customApi}/index.php/vod/detail/id/${id}.html`; 202 | 203 | // 添加超时处理 204 | const controller = new AbortController(); 205 | const timeoutId = setTimeout(() => controller.abort(), 10000); 206 | 207 | // 获取详情页HTML 208 | const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), { 209 | headers: { 210 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 211 | }, 212 | signal: controller.signal 213 | }); 214 | 215 | clearTimeout(timeoutId); 216 | 217 | if (!response.ok) { 218 | throw new Error(`自定义API详情页请求失败: ${response.status}`); 219 | } 220 | 221 | // 获取HTML内容 222 | const html = await response.text(); 223 | 224 | // 使用通用模式提取m3u8链接 225 | const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; 226 | let matches = html.match(generalPattern) || []; 227 | 228 | // 处理链接 229 | matches = matches.map(link => { 230 | link = link.substring(1, link.length); 231 | const parenIndex = link.indexOf('('); 232 | return parenIndex > 0 ? link.substring(0, parenIndex) : link; 233 | }); 234 | 235 | // 提取基本信息 236 | const titleMatch = html.match(/]*>([^<]+)<\/h1>/); 237 | const titleText = titleMatch ? titleMatch[1].trim() : ''; 238 | 239 | const descMatch = html.match(/]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/); 240 | const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : ''; 241 | 242 | return JSON.stringify({ 243 | code: 200, 244 | episodes: matches, 245 | detailUrl: detailUrl, 246 | videoInfo: { 247 | title: titleText, 248 | desc: descText, 249 | source_name: '自定义源', 250 | source_code: 'custom' 251 | } 252 | }); 253 | } catch (error) { 254 | console.error(`自定义API详情获取失败:`, error); 255 | throw error; 256 | } 257 | } 258 | 259 | // 处理极速资源详情的特殊函数 260 | async function handleJisuDetail(id, sourceCode) { 261 | // 直接复用通用的特殊源处理函数,传入相应参数 262 | return await handleSpecialSourceDetail(id, sourceCode); 263 | } 264 | 265 | // 处理非凡影视详情的特殊函数 266 | async function handleFFZYDetail(id, sourceCode) { 267 | // 直接复用通用的特殊源处理函数,传入相应参数 268 | return await handleSpecialSourceDetail(id, sourceCode); 269 | } 270 | 271 | // 通用特殊源详情处理函数 272 | async function handleSpecialSourceDetail(id, sourceCode) { 273 | try { 274 | // 构建详情页URL(使用配置中的detail URL而不是api URL) 275 | const detailUrl = `${API_SITES[sourceCode].detail}/index.php/vod/detail/id/${id}.html`; 276 | 277 | // 添加超时处理 278 | const controller = new AbortController(); 279 | const timeoutId = setTimeout(() => controller.abort(), 10000); 280 | 281 | // 获取详情页HTML 282 | const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), { 283 | headers: { 284 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 285 | }, 286 | signal: controller.signal 287 | }); 288 | 289 | clearTimeout(timeoutId); 290 | 291 | if (!response.ok) { 292 | throw new Error(`详情页请求失败: ${response.status}`); 293 | } 294 | 295 | // 获取HTML内容 296 | const html = await response.text(); 297 | 298 | // 根据不同源类型使用不同的正则表达式 299 | let matches = []; 300 | 301 | if (sourceCode === 'ffzy') { 302 | // 非凡影视使用特定的正则表达式 303 | const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g; 304 | matches = html.match(ffzyPattern) || []; 305 | } 306 | 307 | // 如果没有找到链接或者是其他源类型,尝试一个更通用的模式 308 | if (matches.length === 0) { 309 | const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; 310 | matches = html.match(generalPattern) || []; 311 | } 312 | // 去重处理,避免一个播放源多集显示 313 | matches = [...new Set(matches)]; 314 | // 处理链接 315 | matches = matches.map(link => { 316 | link = link.substring(1, link.length); 317 | const parenIndex = link.indexOf('('); 318 | return parenIndex > 0 ? link.substring(0, parenIndex) : link; 319 | }); 320 | 321 | // 提取可能存在的标题、简介等基本信息 322 | const titleMatch = html.match(/]*>([^<]+)<\/h1>/); 323 | const titleText = titleMatch ? titleMatch[1].trim() : ''; 324 | 325 | const descMatch = html.match(/]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/); 326 | const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : ''; 327 | 328 | return JSON.stringify({ 329 | code: 200, 330 | episodes: matches, 331 | detailUrl: detailUrl, 332 | videoInfo: { 333 | title: titleText, 334 | desc: descText, 335 | source_name: API_SITES[sourceCode].name, 336 | source_code: sourceCode 337 | } 338 | }); 339 | } catch (error) { 340 | console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error); 341 | throw error; 342 | } 343 | } 344 | 345 | // 处理聚合搜索 346 | async function handleAggregatedSearch(searchQuery) { 347 | // 获取可用的API源列表(排除aggregated和custom) 348 | const availableSources = Object.keys(API_SITES).filter(key => 349 | key !== 'aggregated' && key !== 'custom' 350 | ); 351 | 352 | if (availableSources.length === 0) { 353 | throw new Error('没有可用的API源'); 354 | } 355 | 356 | // 创建所有API源的搜索请求 357 | const searchPromises = availableSources.map(async (source) => { 358 | try { 359 | const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; 360 | 361 | // 使用Promise.race添加超时处理 362 | const timeoutPromise = new Promise((_, reject) => 363 | setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000) 364 | ); 365 | 366 | const fetchPromise = fetch(PROXY_URL + encodeURIComponent(apiUrl), { 367 | headers: API_CONFIG.search.headers 368 | }); 369 | 370 | const response = await Promise.race([fetchPromise, timeoutPromise]); 371 | 372 | if (!response.ok) { 373 | throw new Error(`${source}源请求失败: ${response.status}`); 374 | } 375 | 376 | const data = await response.json(); 377 | 378 | if (!data || !Array.isArray(data.list)) { 379 | throw new Error(`${source}源返回的数据格式无效`); 380 | } 381 | 382 | // 为搜索结果添加源信息 383 | const results = data.list.map(item => ({ 384 | ...item, 385 | source_name: API_SITES[source].name, 386 | source_code: source 387 | })); 388 | 389 | return results; 390 | } catch (error) { 391 | console.warn(`${source}源搜索失败:`, error); 392 | return []; // 返回空数组表示该源搜索失败 393 | } 394 | }); 395 | 396 | try { 397 | // 并行执行所有搜索请求 398 | const resultsArray = await Promise.all(searchPromises); 399 | 400 | // 合并所有结果 401 | let allResults = []; 402 | resultsArray.forEach(results => { 403 | if (Array.isArray(results) && results.length > 0) { 404 | allResults = allResults.concat(results); 405 | } 406 | }); 407 | 408 | // 如果没有搜索结果,返回空结果 409 | if (allResults.length === 0) { 410 | return JSON.stringify({ 411 | code: 200, 412 | list: [], 413 | msg: '所有源均无搜索结果' 414 | }); 415 | } 416 | 417 | // 去重(根据vod_id和source_code组合) 418 | const uniqueResults = []; 419 | const seen = new Set(); 420 | 421 | allResults.forEach(item => { 422 | const key = `${item.source_code}_${item.vod_id}`; 423 | if (!seen.has(key)) { 424 | seen.add(key); 425 | uniqueResults.push(item); 426 | } 427 | }); 428 | 429 | // 按照视频名称和来源排序 430 | uniqueResults.sort((a, b) => { 431 | // 首先按照视频名称排序 432 | const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || ''); 433 | if (nameCompare !== 0) return nameCompare; 434 | 435 | // 如果名称相同,则按照来源排序 436 | return (a.source_name || '').localeCompare(b.source_name || ''); 437 | }); 438 | 439 | return JSON.stringify({ 440 | code: 200, 441 | list: uniqueResults, 442 | }); 443 | } catch (error) { 444 | console.error('聚合搜索处理错误:', error); 445 | return JSON.stringify({ 446 | code: 400, 447 | msg: '聚合搜索处理失败: ' + error.message, 448 | list: [] 449 | }); 450 | } 451 | } 452 | 453 | // 处理多个自定义API源的聚合搜索 454 | async function handleMultipleCustomSearch(searchQuery, customApiUrls) { 455 | // 解析自定义API列表 456 | const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator) 457 | .map(url => url.trim()) 458 | .filter(url => url.length > 0 && /^https?:\/\//.test(url)) 459 | .slice(0, CUSTOM_API_CONFIG.maxSources); 460 | 461 | if (apiUrls.length === 0) { 462 | throw new Error('没有提供有效的自定义API地址'); 463 | } 464 | 465 | // 为每个API创建搜索请求 466 | const searchPromises = apiUrls.map(async (apiUrl, index) => { 467 | try { 468 | const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; 469 | 470 | // 使用Promise.race添加超时处理 471 | const timeoutPromise = new Promise((_, reject) => 472 | setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000) 473 | ); 474 | 475 | const fetchPromise = fetch(PROXY_URL + encodeURIComponent(fullUrl), { 476 | headers: API_CONFIG.search.headers 477 | }); 478 | 479 | const response = await Promise.race([fetchPromise, timeoutPromise]); 480 | 481 | if (!response.ok) { 482 | throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`); 483 | } 484 | 485 | const data = await response.json(); 486 | 487 | if (!data || !Array.isArray(data.list)) { 488 | throw new Error(`自定义API ${index+1} 返回的数据格式无效`); 489 | } 490 | 491 | // 为搜索结果添加源信息 492 | const results = data.list.map(item => ({ 493 | ...item, 494 | source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`, 495 | source_code: 'custom', 496 | api_url: apiUrl // 保存API URL以便详情获取 497 | })); 498 | 499 | return results; 500 | } catch (error) { 501 | console.warn(`自定义API ${index+1} 搜索失败:`, error); 502 | return []; // 返回空数组表示该源搜索失败 503 | } 504 | }); 505 | 506 | try { 507 | // 并行执行所有搜索请求 508 | const resultsArray = await Promise.all(searchPromises); 509 | 510 | // 合并所有结果 511 | let allResults = []; 512 | resultsArray.forEach(results => { 513 | if (Array.isArray(results) && results.length > 0) { 514 | allResults = allResults.concat(results); 515 | } 516 | }); 517 | 518 | // 如果没有搜索结果,返回空结果 519 | if (allResults.length === 0) { 520 | return JSON.stringify({ 521 | code: 200, 522 | list: [], 523 | msg: '所有自定义API源均无搜索结果' 524 | }); 525 | } 526 | 527 | // 去重(根据vod_id和api_url组合) 528 | const uniqueResults = []; 529 | const seen = new Set(); 530 | 531 | allResults.forEach(item => { 532 | const key = `${item.api_url || ''}_${item.vod_id}`; 533 | if (!seen.has(key)) { 534 | seen.add(key); 535 | uniqueResults.push(item); 536 | } 537 | }); 538 | 539 | return JSON.stringify({ 540 | code: 200, 541 | list: uniqueResults, 542 | }); 543 | } catch (error) { 544 | console.error('自定义API聚合搜索处理错误:', error); 545 | return JSON.stringify({ 546 | code: 400, 547 | msg: '自定义API聚合搜索处理失败: ' + error.message, 548 | list: [] 549 | }); 550 | } 551 | } 552 | 553 | // 拦截API请求 554 | (function() { 555 | const originalFetch = window.fetch; 556 | 557 | window.fetch = async function(input, init) { 558 | const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url; 559 | 560 | if (requestUrl.pathname.startsWith('/api/')) { 561 | if (window.isPasswordProtected && window.isPasswordVerified) { 562 | if (window.isPasswordProtected() && !window.isPasswordVerified()) { 563 | return; 564 | } 565 | } 566 | try { 567 | const data = await handleApiRequest(requestUrl); 568 | return new Response(data, { 569 | headers: { 570 | 'Content-Type': 'application/json', 571 | 'Access-Control-Allow-Origin': '*', 572 | }, 573 | }); 574 | } catch (error) { 575 | return new Response(JSON.stringify({ 576 | code: 500, 577 | msg: '服务器内部错误', 578 | }), { 579 | status: 500, 580 | headers: { 581 | 'Content-Type': 'application/json', 582 | }, 583 | }); 584 | } 585 | } 586 | 587 | // 非API请求使用原始fetch 588 | return originalFetch.apply(this, arguments); 589 | }; 590 | })(); 591 | 592 | async function testSiteAvailability(apiUrl) { 593 | try { 594 | // 使用更简单的测试查询 595 | const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), { 596 | // 添加超时 597 | signal: AbortSignal.timeout(5000) 598 | }); 599 | 600 | // 检查响应状态 601 | if (!response.ok) { 602 | return false; 603 | } 604 | 605 | const data = await response.json(); 606 | 607 | // 检查API响应的有效性 608 | return data && data.code !== 400 && Array.isArray(data.list); 609 | } catch (error) { 610 | console.error('站点可用性测试失败:', error); 611 | return false; 612 | } 613 | } 614 | -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | // 全局常量配置 2 | const PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写) 3 | // const HOPLAYER_URL = 'https://hoplayer.com/index.html'; 4 | const SEARCH_HISTORY_KEY = 'videoSearchHistory'; 5 | const MAX_HISTORY_ITEMS = 5; 6 | 7 | // 密码保护配置 8 | const PASSWORD_CONFIG = { 9 | localStorageKey: 'passwordVerified', // 存储验证状态的键名 10 | verificationTTL: 90 * 24 * 60 * 60 * 1000, // 验证有效期(90天,约3个月) 11 | }; 12 | 13 | // 网站信息配置 14 | const SITE_CONFIG = { 15 | name: 'LibreTV', 16 | url: 'https://libretv.is-an.org', 17 | description: '免费在线视频搜索与观看平台', 18 | logo: 'https://images.icon-icons.com/38/PNG/512/retrotv_5520.png', 19 | version: '1.0.3' 20 | }; 21 | 22 | // API站点配置 23 | const API_SITES = { 24 | heimuer: { 25 | api: 'https://json.heimuer.xyz', 26 | name: '黑木耳', 27 | detail: 'https://heimuer.tv' 28 | }, 29 | ffzy: { 30 | api: 'http://ffzy5.tv', 31 | name: '非凡影视', 32 | detail: 'http://ffzy5.tv' 33 | }, 34 | tyyszy: { 35 | api: 'https://tyyszy.com', 36 | name: '天涯资源', 37 | }, 38 | ckzy: { 39 | api: 'https://www.ckzy1.com', 40 | name: 'CK资源', 41 | adult: true 42 | }, 43 | zy360: { 44 | api: 'https://360zy.com', 45 | name: '360资源', 46 | }, 47 | wolong: { 48 | api: 'https://wolongzyw.com', 49 | name: '卧龙资源', 50 | }, 51 | cjhw: { 52 | api: 'https://cjhwba.com', 53 | name: '新华为', 54 | }, 55 | hwba: { 56 | api: 'https://cjwba.com', 57 | name: '华为吧资源', 58 | }, 59 | jisu: { 60 | api: 'https://jszyapi.com', 61 | name: '极速资源', 62 | detail: 'https://jszyapi.com' 63 | }, 64 | dbzy: { 65 | api: 'https://dbzy.com', 66 | name: '豆瓣资源', 67 | }, 68 | bfzy: { 69 | api: 'https://bfzyapi.com', 70 | name: '暴风资源', 71 | }, 72 | mozhua: { 73 | api: 'https://mozhuazy.com', 74 | name: '魔爪资源', 75 | }, 76 | mdzy: { 77 | api: 'https://www.mdzyapi.com', 78 | name: '魔都资源', 79 | }, 80 | ruyi: { 81 | api: 'https://cj.rycjapi.com', 82 | name: '如意资源', 83 | }, 84 | jkun: { 85 | api: 'https://jkunzyapi.com', 86 | name: 'jkun资源', 87 | adult: true 88 | }, 89 | bwzy: { 90 | api: 'https://api.bwzym3u8.com', 91 | name: '百万资源', 92 | adult: true 93 | }, 94 | souav: { 95 | api: 'https://api.souavzy.vip', 96 | name: 'souav资源', 97 | adult: true 98 | }, 99 | r155: { 100 | api: 'https://155api.com', 101 | name: '155资源', 102 | adult: true 103 | }, 104 | lsb: { 105 | api: 'https://apilsbzy1.com', 106 | name: 'lsb资源', 107 | adult: true 108 | }, 109 | huangcang: { 110 | api: 'https://hsckzy.vip', 111 | name: '黄色仓库', 112 | adult: true, 113 | detail: 'https://hsckzy.vip' 114 | }, 115 | zuid: { 116 | api: 'https://api.zuidapi.com', 117 | name: '最大资源' 118 | }, 119 | yutu: { 120 | api: 'https://yutuzy10.com', 121 | name: '玉兔资源', 122 | adult: true 123 | } 124 | // 您可以按需添加更多源 125 | }; 126 | 127 | // 添加聚合搜索的配置选项 128 | const AGGREGATED_SEARCH_CONFIG = { 129 | enabled: true, // 是否启用聚合搜索 130 | timeout: 8000, // 单个源超时时间(毫秒) 131 | maxResults: 10000, // 最大结果数量 132 | parallelRequests: true, // 是否并行请求所有源 133 | showSourceBadges: true // 是否显示来源徽章 134 | }; 135 | 136 | // 抽象API请求配置 137 | const API_CONFIG = { 138 | search: { 139 | // 修改搜索接口为返回更多详细数据(包括视频封面、简介和播放列表) 140 | path: '/api.php/provide/vod/?ac=videolist&wd=', 141 | headers: { 142 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 143 | 'Accept': 'application/json' 144 | } 145 | }, 146 | detail: { 147 | // 修改详情接口也使用videolist接口,但是通过ID查询,减少请求次数 148 | path: '/api.php/provide/vod/?ac=videolist&ids=', 149 | headers: { 150 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 151 | 'Accept': 'application/json' 152 | } 153 | } 154 | }; 155 | 156 | // 优化后的正则表达式模式 157 | const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g; 158 | 159 | // 添加自定义播放器URL 160 | const CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html 161 | 162 | // 增加视频播放相关配置 163 | const PLAYER_CONFIG = { 164 | autoplay: true, 165 | allowFullscreen: true, 166 | width: '100%', 167 | height: '600', 168 | timeout: 15000, // 播放器加载超时时间 169 | filterAds: true, // 是否启用广告过滤 170 | autoPlayNext: true, // 默认启用自动连播功能 171 | adFilteringEnabled: true, // 默认开启分片广告过滤 172 | adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名 173 | }; 174 | 175 | // 增加错误信息本地化 176 | const ERROR_MESSAGES = { 177 | NETWORK_ERROR: '网络连接错误,请检查网络设置', 178 | TIMEOUT_ERROR: '请求超时,服务器响应时间过长', 179 | API_ERROR: 'API接口返回错误,请尝试更换数据源', 180 | PLAYER_ERROR: '播放器加载失败,请尝试其他视频源', 181 | UNKNOWN_ERROR: '发生未知错误,请刷新页面重试' 182 | }; 183 | 184 | // 添加进一步安全设置 185 | const SECURITY_CONFIG = { 186 | enableXSSProtection: true, // 是否启用XSS保护 187 | sanitizeUrls: true, // 是否清理URL 188 | maxQueryLength: 100, // 最大搜索长度 189 | // allowedApiDomains 不再需要,因为所有请求都通过内部代理 190 | }; 191 | 192 | // 添加多个自定义API源的配置 193 | const CUSTOM_API_CONFIG = { 194 | separator: ',', // 分隔符 195 | maxSources: 5, // 最大允许的自定义源数量 196 | testTimeout: 5000, // 测试超时时间(毫秒) 197 | namePrefix: 'Custom-', // 自定义源名称前缀 198 | validateUrl: true, // 验证URL格式 199 | cacheResults: true, // 缓存测试结果 200 | cacheExpiry: 5184000000, // 缓存过期时间(2个月) 201 | adultPropName: 'isAdult' // 用于标记成人内容的属性名 202 | }; 203 | 204 | // 新增隐藏内置黄色采集站API的变量,默认为true 205 | const HIDE_BUILTIN_ADULT_APIS = true; 206 | -------------------------------------------------------------------------------- /js/douban.js: -------------------------------------------------------------------------------- 1 | // 豆瓣热门电影电视剧推荐功能 2 | 3 | // 豆瓣标签列表 4 | let movieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '爱情', '科幻', '悬疑', '恐怖', '治愈']; 5 | let tvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片'] 6 | let doubanMovieTvCurrentSwitch = 'movie'; 7 | let doubanCurrentTag = '热门'; 8 | let doubanPageStart = 0; 9 | const doubanPageSize = 16; // 一次显示的项目数量 10 | 11 | // 初始化豆瓣功能 12 | function initDouban() { 13 | // 设置豆瓣开关的初始状态 14 | const doubanToggle = document.getElementById('doubanToggle'); 15 | if (doubanToggle) { 16 | const isEnabled = localStorage.getItem('doubanEnabled') === 'true'; 17 | doubanToggle.checked = isEnabled; 18 | 19 | // 设置开关外观 20 | const toggleBg = doubanToggle.nextElementSibling; 21 | const toggleDot = toggleBg.nextElementSibling; 22 | if (isEnabled) { 23 | toggleBg.classList.add('bg-pink-600'); 24 | toggleDot.classList.add('translate-x-6'); 25 | } 26 | 27 | // 添加事件监听 28 | doubanToggle.addEventListener('change', function(e) { 29 | const isChecked = e.target.checked; 30 | localStorage.setItem('doubanEnabled', isChecked); 31 | 32 | // 更新开关外观 33 | if (isChecked) { 34 | toggleBg.classList.add('bg-pink-600'); 35 | toggleDot.classList.add('translate-x-6'); 36 | } else { 37 | toggleBg.classList.remove('bg-pink-600'); 38 | toggleDot.classList.remove('translate-x-6'); 39 | } 40 | 41 | // 更新显示状态 42 | updateDoubanVisibility(); 43 | }); 44 | 45 | // 初始更新显示状态 46 | updateDoubanVisibility(); 47 | } 48 | 49 | // 获取豆瓣热门标签 50 | fetchDoubanTags(); 51 | 52 | // 渲染电影/电视剧切换 53 | renderDoubanMovieTvSwitch(); 54 | 55 | // 渲染豆瓣标签 56 | renderDoubanTags(); 57 | 58 | // 换一批按钮事件监听 59 | setupDoubanRefreshBtn(); 60 | 61 | // 初始加载热门内容 62 | if (localStorage.getItem('doubanEnabled') === 'true') { 63 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 64 | } 65 | } 66 | 67 | // 根据设置更新豆瓣区域的显示状态 68 | function updateDoubanVisibility() { 69 | const doubanArea = document.getElementById('doubanArea'); 70 | if (!doubanArea) return; 71 | 72 | const isEnabled = localStorage.getItem('doubanEnabled') === 'true'; 73 | const isSearching = document.getElementById('resultsArea') && 74 | !document.getElementById('resultsArea').classList.contains('hidden'); 75 | 76 | // 只有在启用且没有搜索结果显示时才显示豆瓣区域 77 | if (isEnabled && !isSearching) { 78 | doubanArea.classList.remove('hidden'); 79 | // 如果豆瓣结果为空,重新加载 80 | if (document.getElementById('douban-results').children.length === 0) { 81 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 82 | } 83 | } else { 84 | doubanArea.classList.add('hidden'); 85 | } 86 | } 87 | 88 | // 只填充搜索框,不执行搜索,让用户自主决定搜索时机 89 | function fillSearchInput(title) { 90 | if (!title) return; 91 | 92 | // 安全处理标题,防止XSS 93 | const safeTitle = title 94 | .replace(//g, '>') 96 | .replace(/"/g, '"'); 97 | 98 | const input = document.getElementById('searchInput'); 99 | if (input) { 100 | input.value = safeTitle; 101 | 102 | // 聚焦搜索框,便于用户立即使用键盘操作 103 | input.focus(); 104 | 105 | // 显示一个提示,告知用户点击搜索按钮进行搜索 106 | showToast('已填充搜索内容,点击搜索按钮开始搜索', 'info'); 107 | } 108 | } 109 | 110 | // 填充搜索框并执行搜索 111 | function fillAndSearch(title) { 112 | if (!title) return; 113 | 114 | // 安全处理标题,防止XSS 115 | const safeTitle = title 116 | .replace(//g, '>') 118 | .replace(/"/g, '"'); 119 | 120 | const input = document.getElementById('searchInput'); 121 | if (input) { 122 | input.value = safeTitle; 123 | search(); // 使用已有的search函数执行搜索 124 | } 125 | } 126 | 127 | // 填充搜索框,确保豆瓣资源API被选中,然后执行搜索 128 | function fillAndSearchWithDouban(title) { 129 | if (!title) return; 130 | 131 | // 安全处理标题,防止XSS 132 | const safeTitle = title 133 | .replace(//g, '>') 135 | .replace(/"/g, '"'); 136 | 137 | // 确保豆瓣资源API被选中 138 | if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) { 139 | // 在设置中勾选豆瓣资源API复选框 140 | const doubanCheckbox = document.querySelector('input[id="api_dbzy"]'); 141 | if (doubanCheckbox) { 142 | doubanCheckbox.checked = true; 143 | 144 | // 触发updateSelectedAPIs函数以更新状态 145 | if (typeof updateSelectedAPIs === 'function') { 146 | updateSelectedAPIs(); 147 | } else { 148 | // 如果函数不可用,则手动添加到selectedAPIs 149 | selectedAPIs.push('dbzy'); 150 | localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); 151 | 152 | // 更新选中API计数(如果有这个元素) 153 | const countEl = document.getElementById('selectedAPICount'); 154 | if (countEl) { 155 | countEl.textContent = selectedAPIs.length; 156 | } 157 | } 158 | 159 | showToast('已自动选择豆瓣资源API', 'info'); 160 | } 161 | } 162 | 163 | // 填充搜索框并执行搜索 164 | const input = document.getElementById('searchInput'); 165 | if (input) { 166 | input.value = safeTitle; 167 | search(); // 使用已有的search函数执行搜索 168 | } 169 | } 170 | 171 | // 渲染电影/电视剧切换器 172 | function renderDoubanMovieTvSwitch() { 173 | // 获取切换按钮元素 174 | const movieToggle = document.getElementById('douban-movie-toggle'); 175 | const tvToggle = document.getElementById('douban-tv-toggle'); 176 | 177 | if (!movieToggle ||!tvToggle) return; 178 | 179 | movieToggle.addEventListener('click', function() { 180 | if (doubanMovieTvCurrentSwitch !== 'movie') { 181 | // 更新按钮样式 182 | movieToggle.classList.add('bg-pink-600', 'text-white'); 183 | movieToggle.classList.remove('text-gray-300'); 184 | 185 | tvToggle.classList.remove('bg-pink-600', 'text-white'); 186 | tvToggle.classList.add('text-gray-300'); 187 | 188 | doubanMovieTvCurrentSwitch = 'movie'; 189 | doubanCurrentTag = '热门'; 190 | 191 | // 重新加载豆瓣内容 192 | renderDoubanTags(movieTags); 193 | 194 | // 换一批按钮事件监听 195 | setupDoubanRefreshBtn(); 196 | 197 | // 初始加载热门内容 198 | if (localStorage.getItem('doubanEnabled') === 'true') { 199 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 200 | } 201 | } 202 | }); 203 | 204 | // 电视剧按钮点击事件 205 | tvToggle.addEventListener('click', function() { 206 | if (doubanMovieTvCurrentSwitch !== 'tv') { 207 | // 更新按钮样式 208 | tvToggle.classList.add('bg-pink-600', 'text-white'); 209 | tvToggle.classList.remove('text-gray-300'); 210 | 211 | movieToggle.classList.remove('bg-pink-600', 'text-white'); 212 | movieToggle.classList.add('text-gray-300'); 213 | 214 | doubanMovieTvCurrentSwitch = 'tv'; 215 | doubanCurrentTag = '热门'; 216 | 217 | // 重新加载豆瓣内容 218 | renderDoubanTags(tvTags); 219 | 220 | // 换一批按钮事件监听 221 | setupDoubanRefreshBtn(); 222 | 223 | // 初始加载热门内容 224 | if (localStorage.getItem('doubanEnabled') === 'true') { 225 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 226 | } 227 | } 228 | }); 229 | } 230 | 231 | // 渲染豆瓣标签选择器 232 | function renderDoubanTags(tags = movieTags) { 233 | const tagContainer = document.getElementById('douban-tags'); 234 | if (!tagContainer) return; 235 | 236 | tagContainer.innerHTML = ''; 237 | 238 | tags.forEach(tag => { 239 | const btn = document.createElement('button'); 240 | // 更新标签样式:统一高度,添加过渡效果,改进颜色对比度 241 | btn.className = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 ' + 242 | (tag === doubanCurrentTag ? 243 | 'bg-pink-600 text-white shadow-md' : 244 | 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white'); 245 | 246 | btn.textContent = tag; 247 | 248 | btn.onclick = function() { 249 | if (doubanCurrentTag !== tag) { 250 | doubanCurrentTag = tag; 251 | doubanPageStart = 0; 252 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 253 | renderDoubanTags(tags); 254 | } 255 | }; 256 | 257 | tagContainer.appendChild(btn); 258 | }); 259 | } 260 | 261 | // 设置换一批按钮事件 262 | function setupDoubanRefreshBtn() { 263 | // 修复ID,使用正确的ID douban-refresh 而不是 douban-refresh-btn 264 | const btn = document.getElementById('douban-refresh'); 265 | if (!btn) return; 266 | 267 | btn.onclick = function() { 268 | doubanPageStart += doubanPageSize; 269 | if (doubanPageStart > 9 * doubanPageSize) { 270 | doubanPageStart = 0; 271 | } 272 | 273 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); 274 | }; 275 | } 276 | 277 | function fetchDoubanTags() { 278 | const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie` 279 | fetchDoubanData(movieTagsTarget) 280 | .then(data => { 281 | movieTags = data.tags; 282 | if (doubanMovieTvCurrentSwitch === 'movie') { 283 | renderDoubanTags(movieTags); 284 | } 285 | }) 286 | .catch(error => { 287 | console.error("获取豆瓣热门电影标签失败:", error); 288 | }); 289 | const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv` 290 | fetchDoubanData(tvTagsTarget) 291 | .then(data => { 292 | tvTags = data.tags; 293 | if (doubanMovieTvCurrentSwitch === 'tv') { 294 | renderDoubanTags(tvTags); 295 | } 296 | }) 297 | .catch(error => { 298 | console.error("获取豆瓣热门电视剧标签失败:", error); 299 | }); 300 | } 301 | 302 | // 渲染热门推荐内容 303 | function renderRecommend(tag, pageLimit, pageStart) { 304 | const container = document.getElementById("douban-results"); 305 | if (!container) return; 306 | 307 | // 显示加载状态 308 | container.innerHTML = ` 309 |
310 |
311 | 加载中... 312 |
313 | `; 314 | 315 | const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`; 316 | 317 | // 使用通用请求函数 318 | fetchDoubanData(target) 319 | .then(data => { 320 | renderDoubanCards(data, container); 321 | }) 322 | .catch(error => { 323 | console.error("获取豆瓣数据失败:", error); 324 | container.innerHTML = ` 325 |
326 |
❌ 获取豆瓣数据失败,请稍后重试
327 |
提示:使用VPN可能有助于解决此问题
328 |
329 | `; 330 | }); 331 | } 332 | 333 | async function fetchDoubanData(url) { 334 | // 添加超时控制 335 | const controller = new AbortController(); 336 | const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 337 | 338 | // 设置请求选项,包括信号和头部 339 | const fetchOptions = { 340 | signal: controller.signal, 341 | headers: { 342 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', 343 | 'Referer': 'https://movie.douban.com/', 344 | 'Accept': 'application/json, text/plain, */*', 345 | } 346 | }; 347 | 348 | try { 349 | // 尝试直接访问(豆瓣API可能允许部分CORS请求) 350 | const response = await fetch(PROXY_URL + encodeURIComponent(url), fetchOptions); 351 | clearTimeout(timeoutId); 352 | 353 | if (!response.ok) { 354 | throw new Error(`HTTP error! Status: ${response.status}`); 355 | } 356 | 357 | return await response.json(); 358 | } catch (err) { 359 | console.error("豆瓣 API 请求失败(直接代理):", err); 360 | 361 | // 失败后尝试备用方法:作为备选 362 | const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; 363 | 364 | try { 365 | const fallbackResponse = await fetch(fallbackUrl); 366 | 367 | if (!fallbackResponse.ok) { 368 | throw new Error(`备用API请求失败! 状态: ${fallbackResponse.status}`); 369 | } 370 | 371 | const data = await fallbackResponse.json(); 372 | 373 | // 解析原始内容 374 | if (data && data.contents) { 375 | return JSON.parse(data.contents); 376 | } else { 377 | throw new Error("无法获取有效数据"); 378 | } 379 | } catch (fallbackErr) { 380 | console.error("豆瓣 API 备用请求也失败:", fallbackErr); 381 | throw fallbackErr; // 向上抛出错误,让调用者处理 382 | } 383 | } 384 | } 385 | 386 | // 抽取渲染豆瓣卡片的逻辑到单独函数 387 | function renderDoubanCards(data, container) { 388 | // 创建文档片段以提高性能 389 | const fragment = document.createDocumentFragment(); 390 | 391 | // 如果没有数据 392 | if (!data.subjects || data.subjects.length === 0) { 393 | const emptyEl = document.createElement("div"); 394 | emptyEl.className = "col-span-full text-center py-8"; 395 | emptyEl.innerHTML = ` 396 |
❌ 暂无数据,请尝试其他分类或刷新
397 | `; 398 | fragment.appendChild(emptyEl); 399 | } else { 400 | // 循环创建每个影视卡片 401 | data.subjects.forEach(item => { 402 | const card = document.createElement("div"); 403 | card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg"; 404 | 405 | // 生成卡片内容,确保安全显示(防止XSS) 406 | const safeTitle = item.title 407 | .replace(//g, '>') 409 | .replace(/"/g, '"'); 410 | 411 | const safeRate = (item.rate || "暂无") 412 | .replace(//g, '>'); 414 | 415 | // 处理图片URL 416 | // 1. 直接使用豆瓣图片URL (添加no-referrer属性) 417 | const originalCoverUrl = item.cover; 418 | 419 | // 2. 也准备代理URL作为备选 420 | const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl); 421 | 422 | // 为不同设备优化卡片布局 423 | card.innerHTML = ` 424 |
425 | ${safeTitle} 429 |
430 |
431 | ${safeRate} 432 |
433 |
434 | 435 | 🔗 436 | 437 |
438 |
439 |
440 | 445 |
446 | `; 447 | 448 | fragment.appendChild(card); 449 | }); 450 | } 451 | 452 | // 清空并添加所有新元素 453 | container.innerHTML = ""; 454 | container.appendChild(fragment); 455 | } 456 | 457 | // 重置到首页 458 | function resetToHome() { 459 | resetSearchArea(); 460 | updateDoubanVisibility(); 461 | } 462 | 463 | // 加载豆瓣首页内容 464 | document.addEventListener('DOMContentLoaded', initDouban); -------------------------------------------------------------------------------- /js/password.js: -------------------------------------------------------------------------------- 1 | // 密码保护功能 2 | 3 | /** 4 | * 检查是否设置了密码保护 5 | * 通过读取页面上嵌入的环境变量来检查 6 | */ 7 | function isPasswordProtected() { 8 | // 检查页面上嵌入的环境变量 9 | const pwd = window.__ENV__ && window.__ENV__.PASSWORD; 10 | // 只有当密码 hash 存在且为64位(SHA-256十六进制长度)才认为启用密码保护 11 | return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd); 12 | } 13 | 14 | /** 15 | * 检查用户是否已通过密码验证 16 | * 检查localStorage中的验证状态和时间戳是否有效,并确认密码哈希未更改 17 | */ 18 | function isPasswordVerified() { 19 | try { 20 | // 如果没有设置密码保护,则视为已验证 21 | if (!isPasswordProtected()) { 22 | return true; 23 | } 24 | 25 | const verificationData = JSON.parse(localStorage.getItem(PASSWORD_CONFIG.localStorageKey) || '{}'); 26 | const { verified, timestamp, passwordHash } = verificationData; 27 | 28 | // 获取当前环境中的密码哈希 29 | const currentHash = window.__ENV__ && window.__ENV__.PASSWORD; 30 | 31 | // 验证是否已验证、未过期,且密码哈希未更改 32 | if (verified && timestamp && passwordHash === currentHash) { 33 | const now = Date.now(); 34 | const expiry = timestamp + PASSWORD_CONFIG.verificationTTL; 35 | return now < expiry; 36 | } 37 | 38 | return false; 39 | } catch (error) { 40 | console.error('验证密码状态时出错:', error); 41 | return false; 42 | } 43 | } 44 | 45 | window.isPasswordProtected = isPasswordProtected; 46 | window.isPasswordVerified = isPasswordVerified; 47 | 48 | /** 49 | * 验证用户输入的密码是否正确(异步,使用SHA-256哈希) 50 | */ 51 | async function verifyPassword(password) { 52 | const correctHash = window.__ENV__ && window.__ENV__.PASSWORD; 53 | if (!correctHash) return false; 54 | const inputHash = await sha256(password); 55 | const isValid = inputHash === correctHash; 56 | if (isValid) { 57 | const verificationData = { 58 | verified: true, 59 | timestamp: Date.now(), 60 | passwordHash: correctHash // 保存当前密码的哈希值 61 | }; 62 | localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify(verificationData)); 63 | } 64 | return isValid; 65 | } 66 | 67 | // SHA-256实现,可用Web Crypto API 68 | async function sha256(message) { 69 | if (window.crypto && crypto.subtle && crypto.subtle.digest) { 70 | const msgBuffer = new TextEncoder().encode(message); 71 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); 72 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 73 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 74 | } 75 | // HTTP 下调用原始 js‑sha256 76 | if (typeof window._jsSha256 === 'function') { 77 | return window._jsSha256(message); 78 | } 79 | throw new Error('No SHA-256 implementation available.'); 80 | } 81 | 82 | /** 83 | * 显示密码验证弹窗 84 | */ 85 | function showPasswordModal() { 86 | const passwordModal = document.getElementById('passwordModal'); 87 | if (passwordModal) { 88 | passwordModal.style.display = 'flex'; 89 | 90 | // 确保输入框获取焦点 91 | setTimeout(() => { 92 | const passwordInput = document.getElementById('passwordInput'); 93 | if (passwordInput) { 94 | passwordInput.focus(); 95 | } 96 | }, 100); 97 | } 98 | } 99 | 100 | /** 101 | * 隐藏密码验证弹窗 102 | */ 103 | function hidePasswordModal() { 104 | const passwordModal = document.getElementById('passwordModal'); 105 | if (passwordModal) { 106 | passwordModal.style.display = 'none'; 107 | } 108 | } 109 | 110 | /** 111 | * 显示密码错误信息 112 | */ 113 | function showPasswordError() { 114 | const errorElement = document.getElementById('passwordError'); 115 | if (errorElement) { 116 | errorElement.classList.remove('hidden'); 117 | } 118 | } 119 | 120 | /** 121 | * 隐藏密码错误信息 122 | */ 123 | function hidePasswordError() { 124 | const errorElement = document.getElementById('passwordError'); 125 | if (errorElement) { 126 | errorElement.classList.add('hidden'); 127 | } 128 | } 129 | 130 | /** 131 | * 处理密码提交事件(异步) 132 | */ 133 | async function handlePasswordSubmit() { 134 | const passwordInput = document.getElementById('passwordInput'); 135 | const password = passwordInput ? passwordInput.value.trim() : ''; 136 | if (await verifyPassword(password)) { 137 | hidePasswordError(); 138 | hidePasswordModal(); 139 | } else { 140 | showPasswordError(); 141 | if (passwordInput) { 142 | passwordInput.value = ''; 143 | passwordInput.focus(); 144 | } 145 | } 146 | } 147 | 148 | /** 149 | * 初始化密码验证系统(需适配异步事件) 150 | */ 151 | function initPasswordProtection() { 152 | if (!isPasswordProtected()) { 153 | return; // 如果未设置密码保护,则不进行任何操作 154 | } 155 | 156 | // 如果未验证密码,则显示密码验证弹窗 157 | if (!isPasswordVerified()) { 158 | showPasswordModal(); 159 | 160 | // 设置密码提交按钮事件监听 161 | const submitButton = document.getElementById('passwordSubmitBtn'); 162 | if (submitButton) { 163 | submitButton.addEventListener('click', handlePasswordSubmit); 164 | } 165 | 166 | // 设置密码输入框回车键监听 167 | const passwordInput = document.getElementById('passwordInput'); 168 | if (passwordInput) { 169 | passwordInput.addEventListener('keypress', function(e) { 170 | if (e.key === 'Enter') { 171 | handlePasswordSubmit(); 172 | } 173 | }); 174 | } 175 | } 176 | } 177 | 178 | // 在页面加载完成后初始化密码保护 179 | document.addEventListener('DOMContentLoaded', initPasswordProtection); -------------------------------------------------------------------------------- /js/sha256.js: -------------------------------------------------------------------------------- 1 | export async function sha256(message) { 2 | const msgBuffer = new TextEncoder().encode(message); 3 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); 4 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 5 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 6 | } 7 | -------------------------------------------------------------------------------- /js/ui.js: -------------------------------------------------------------------------------- 1 | // UI相关函数 2 | function toggleSettings(e) { 3 | // 密码保护校验 4 | if (window.isPasswordProtected && window.isPasswordVerified) { 5 | if (window.isPasswordProtected() && !window.isPasswordVerified()) { 6 | showPasswordModal && showPasswordModal(); 7 | return; 8 | } 9 | } 10 | // 阻止事件冒泡,防止触发document的点击事件 11 | e && e.stopPropagation(); 12 | const panel = document.getElementById('settingsPanel'); 13 | panel.classList.toggle('show'); 14 | } 15 | 16 | // 改进的Toast显示函数 - 支持队列显示多个Toast 17 | const toastQueue = []; 18 | let isShowingToast = false; 19 | 20 | function showToast(message, type = 'error') { 21 | // 将新的toast添加到队列 22 | toastQueue.push({ message, type }); 23 | 24 | // 如果当前没有显示中的toast,则开始显示 25 | if (!isShowingToast) { 26 | showNextToast(); 27 | } 28 | } 29 | 30 | function showNextToast() { 31 | if (toastQueue.length === 0) { 32 | isShowingToast = false; 33 | return; 34 | } 35 | 36 | isShowingToast = true; 37 | const { message, type } = toastQueue.shift(); 38 | 39 | const toast = document.getElementById('toast'); 40 | const toastMessage = document.getElementById('toastMessage'); 41 | 42 | const bgColors = { 43 | 'error': 'bg-red-500', 44 | 'success': 'bg-green-500', 45 | 'info': 'bg-blue-500', 46 | 'warning': 'bg-yellow-500' 47 | }; 48 | 49 | const bgColor = bgColors[type] || bgColors.error; 50 | toast.className = `fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 ${bgColor} text-white`; 51 | toastMessage.textContent = message; 52 | 53 | // 显示提示 54 | toast.style.opacity = '1'; 55 | toast.style.transform = 'translateX(-50%) translateY(0)'; 56 | 57 | // 3秒后自动隐藏 58 | setTimeout(() => { 59 | toast.style.opacity = '0'; 60 | toast.style.transform = 'translateX(-50%) translateY(-100%)'; 61 | 62 | // 等待动画完成后显示下一个toast 63 | setTimeout(() => { 64 | showNextToast(); 65 | }, 300); 66 | }, 3000); 67 | } 68 | 69 | // 添加显示/隐藏 loading 的函数 70 | let loadingTimeoutId = null; 71 | 72 | function showLoading(message = '加载中...') { 73 | // 清除任何现有的超时 74 | if (loadingTimeoutId) { 75 | clearTimeout(loadingTimeoutId); 76 | } 77 | 78 | const loading = document.getElementById('loading'); 79 | const messageEl = loading.querySelector('p'); 80 | messageEl.textContent = message; 81 | loading.style.display = 'flex'; 82 | 83 | // 设置30秒后自动关闭loading,防止无限loading 84 | loadingTimeoutId = setTimeout(() => { 85 | hideLoading(); 86 | showToast('操作超时,请稍后重试', 'warning'); 87 | }, 30000); 88 | } 89 | 90 | function hideLoading() { 91 | // 清除超时 92 | if (loadingTimeoutId) { 93 | clearTimeout(loadingTimeoutId); 94 | loadingTimeoutId = null; 95 | } 96 | 97 | const loading = document.getElementById('loading'); 98 | loading.style.display = 'none'; 99 | } 100 | 101 | function updateSiteStatus(isAvailable) { 102 | const statusEl = document.getElementById('siteStatus'); 103 | if (isAvailable) { 104 | statusEl.innerHTML = ' 可用'; 105 | } else { 106 | statusEl.innerHTML = ' 不可用'; 107 | } 108 | } 109 | 110 | function closeModal() { 111 | document.getElementById('modal').classList.add('hidden'); 112 | // 清除 iframe 内容 113 | document.getElementById('modalContent').innerHTML = ''; 114 | } 115 | 116 | // 获取搜索历史的增强版本 - 支持新旧格式 117 | function getSearchHistory() { 118 | try { 119 | const data = localStorage.getItem(SEARCH_HISTORY_KEY); 120 | if (!data) return []; 121 | 122 | const parsed = JSON.parse(data); 123 | 124 | // 检查是否是数组 125 | if (!Array.isArray(parsed)) return []; 126 | 127 | // 支持旧格式(字符串数组)和新格式(对象数组) 128 | return parsed.map(item => { 129 | if (typeof item === 'string') { 130 | return { text: item, timestamp: 0 }; 131 | } 132 | return item; 133 | }).filter(item => item && item.text); 134 | } catch (e) { 135 | console.error('获取搜索历史出错:', e); 136 | return []; 137 | } 138 | } 139 | 140 | // 保存搜索历史的增强版本 - 添加时间戳和最大数量限制,现在缓存2个月 141 | function saveSearchHistory(query) { 142 | if (!query || !query.trim()) return; 143 | 144 | // 清理输入,防止XSS 145 | query = query.trim().substring(0, 50).replace(//g, '>'); 146 | 147 | let history = getSearchHistory(); 148 | 149 | // 获取当前时间 150 | const now = Date.now(); 151 | 152 | // 过滤掉超过2个月的记录(约60天,60*24*60*60*1000 = 5184000000毫秒) 153 | history = history.filter(item => 154 | typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000) 155 | ); 156 | 157 | // 删除已存在的相同项 158 | history = history.filter(item => 159 | typeof item === 'object' ? item.text !== query : item !== query 160 | ); 161 | 162 | // 新项添加到开头,包含时间戳 163 | history.unshift({ 164 | text: query, 165 | timestamp: now 166 | }); 167 | 168 | // 限制历史记录数量 169 | if (history.length > MAX_HISTORY_ITEMS) { 170 | history = history.slice(0, MAX_HISTORY_ITEMS); 171 | } 172 | 173 | try { 174 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)); 175 | } catch (e) { 176 | console.error('保存搜索历史失败:', e); 177 | // 如果存储失败(可能是localStorage已满),尝试清理旧数据 178 | try { 179 | localStorage.removeItem(SEARCH_HISTORY_KEY); 180 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3))); 181 | } catch (e2) { 182 | console.error('再次保存搜索历史失败:', e2); 183 | } 184 | } 185 | 186 | renderSearchHistory(); 187 | } 188 | 189 | // 渲染最近搜索历史的增强版本 190 | function renderSearchHistory() { 191 | const historyContainer = document.getElementById('recentSearches'); 192 | if (!historyContainer) return; 193 | 194 | const history = getSearchHistory(); 195 | 196 | if (history.length === 0) { 197 | historyContainer.innerHTML = ''; 198 | return; 199 | } 200 | 201 | // 创建一个包含标题和清除按钮的行 202 | historyContainer.innerHTML = ` 203 |
204 |
最近搜索:
205 | 209 |
210 | `; 211 | 212 | history.forEach(item => { 213 | const tag = document.createElement('button'); 214 | tag.className = 'search-tag'; 215 | tag.textContent = item.text; 216 | 217 | // 添加时间提示(如果有时间戳) 218 | if (item.timestamp) { 219 | const date = new Date(item.timestamp); 220 | tag.title = `搜索于: ${date.toLocaleString()}`; 221 | } 222 | 223 | tag.onclick = function() { 224 | document.getElementById('searchInput').value = item.text; 225 | search(); 226 | }; 227 | historyContainer.appendChild(tag); 228 | }); 229 | } 230 | 231 | // 增加清除搜索历史功能 232 | function clearSearchHistory() { 233 | // 密码保护校验 234 | if (window.isPasswordProtected && window.isPasswordVerified) { 235 | if (window.isPasswordProtected() && !window.isPasswordVerified()) { 236 | showPasswordModal && showPasswordModal(); 237 | return; 238 | } 239 | } 240 | try { 241 | localStorage.removeItem(SEARCH_HISTORY_KEY); 242 | renderSearchHistory(); 243 | showToast('搜索历史已清除', 'success'); 244 | } catch (e) { 245 | console.error('清除搜索历史失败:', e); 246 | showToast('清除搜索历史失败:', 'error'); 247 | } 248 | } 249 | 250 | // 历史面板相关函数 251 | function toggleHistory(e) { 252 | // 密码保护校验 253 | if (window.isPasswordProtected && window.isPasswordVerified) { 254 | if (window.isPasswordProtected() && !window.isPasswordVerified()) { 255 | showPasswordModal && showPasswordModal(); 256 | return; 257 | } 258 | } 259 | if (e) e.stopPropagation(); 260 | 261 | const panel = document.getElementById('historyPanel'); 262 | if (panel) { 263 | panel.classList.toggle('show'); 264 | 265 | // 如果打开了历史记录面板,则加载历史数据 266 | if (panel.classList.contains('show')) { 267 | loadViewingHistory(); 268 | } 269 | 270 | // 如果设置面板是打开的,则关闭它 271 | const settingsPanel = document.getElementById('settingsPanel'); 272 | if (settingsPanel && settingsPanel.classList.contains('show')) { 273 | settingsPanel.classList.remove('show'); 274 | } 275 | } 276 | } 277 | 278 | // 格式化时间戳为友好的日期时间格式 279 | function formatTimestamp(timestamp) { 280 | const date = new Date(timestamp); 281 | const now = new Date(); 282 | const diff = now - date; 283 | 284 | // 小于1小时,显示"X分钟前" 285 | if (diff < 3600000) { 286 | const minutes = Math.floor(diff / 60000); 287 | return minutes <= 0 ? '刚刚' : `${minutes}分钟前`; 288 | } 289 | 290 | // 小于24小时,显示"X小时前" 291 | if (diff < 86400000) { 292 | const hours = Math.floor(diff / 3600000); 293 | return `${hours}小时前`; 294 | } 295 | 296 | // 小于7天,显示"X天前" 297 | if (diff < 604800000) { 298 | const days = Math.floor(diff / 86400000); 299 | return `${days}天前`; 300 | } 301 | 302 | // 其他情况,显示完整日期 303 | const year = date.getFullYear(); 304 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); 305 | const day = date.getDate().toString().padStart(2, '0'); 306 | const hour = date.getHours().toString().padStart(2, '0'); 307 | const minute = date.getMinutes().toString().padStart(2, '0'); 308 | 309 | return `${year}-${month}-${day} ${hour}:${minute}`; 310 | } 311 | 312 | // 获取观看历史记录 313 | function getViewingHistory() { 314 | try { 315 | const data = localStorage.getItem('viewingHistory'); 316 | return data ? JSON.parse(data) : []; 317 | } catch (e) { 318 | console.error('获取观看历史失败:', e); 319 | return []; 320 | } 321 | } 322 | 323 | // 加载观看历史并渲染 324 | function loadViewingHistory() { 325 | const historyList = document.getElementById('historyList'); 326 | if (!historyList) return; 327 | 328 | const history = getViewingHistory(); 329 | 330 | if (history.length === 0) { 331 | historyList.innerHTML = `
暂无观看记录
`; 332 | return; 333 | } 334 | 335 | // 渲染历史记录 336 | historyList.innerHTML = history.map(item => { 337 | // 防止XSS 338 | const safeTitle = item.title 339 | .replace(//g, '>') 341 | .replace(/"/g, '"'); 342 | 343 | const safeSource = item.sourceName ? 344 | item.sourceName.replace(//g, '>').replace(/"/g, '"') : 345 | '未知来源'; 346 | 347 | const episodeText = item.episodeIndex !== undefined ? 348 | `第${item.episodeIndex + 1}集` : ''; 349 | 350 | // 格式化进度信息 351 | let progressHtml = ''; 352 | if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) { 353 | const percent = Math.round((item.playbackPosition / item.duration) * 100); 354 | const formattedTime = formatPlaybackTime(item.playbackPosition); 355 | const formattedDuration = formatPlaybackTime(item.duration); 356 | 357 | progressHtml = ` 358 |
359 |
360 |
361 |
362 |
${formattedTime} / ${formattedDuration}
363 |
364 | `; 365 | } 366 | 367 | // 为防止XSS,使用encodeURIComponent编码URL 368 | const safeURL = encodeURIComponent(item.url); 369 | 370 | // 构建历史记录项HTML,添加删除按钮,需要放在position:relative的容器中 371 | return ` 372 |
373 | 380 |
381 |
${safeTitle}
382 |
383 | ${episodeText} 384 | ${episodeText ? '·' : ''} 385 | ${safeSource} 386 |
387 | ${progressHtml} 388 |
${formatTimestamp(item.timestamp)}
389 |
390 |
391 | `; 392 | }).join(''); 393 | 394 | // 检查是否存在较多历史记录,添加底部边距确保底部按钮不会挡住内容 395 | if (history.length > 5) { 396 | historyList.classList.add('pb-4'); 397 | } 398 | } 399 | 400 | // 格式化播放时间为 mm:ss 格式 401 | function formatPlaybackTime(seconds) { 402 | if (!seconds || isNaN(seconds)) return '00:00'; 403 | 404 | const minutes = Math.floor(seconds / 60); 405 | const remainingSeconds = Math.floor(seconds % 60); 406 | 407 | return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; 408 | } 409 | 410 | // 删除单个历史记录项 411 | function deleteHistoryItem(encodedUrl) { 412 | try { 413 | // 解码URL 414 | const url = decodeURIComponent(encodedUrl); 415 | 416 | // 获取当前历史记录 417 | const history = getViewingHistory(); 418 | 419 | // 过滤掉要删除的项 420 | const newHistory = history.filter(item => item.url !== url); 421 | 422 | // 保存回localStorage 423 | localStorage.setItem('viewingHistory', JSON.stringify(newHistory)); 424 | 425 | // 重新加载历史记录显示 426 | loadViewingHistory(); 427 | 428 | // 显示成功提示 429 | showToast('已删除该记录', 'success'); 430 | } catch (e) { 431 | console.error('删除历史记录项失败:', e); 432 | showToast('删除记录失败', 'error'); 433 | } 434 | } 435 | 436 | // 从历史记录播放 437 | function playFromHistory(url, title, episodeIndex, playbackPosition = 0) { 438 | try { 439 | // 尝试从localStorage获取当前视频的集数信息 440 | let episodesList = []; 441 | 442 | // 检查viewingHistory,查找匹配的项以获取其集数数据 443 | const historyRaw = localStorage.getItem('viewingHistory'); 444 | if (historyRaw) { 445 | const history = JSON.parse(historyRaw); 446 | // 根据标题查找匹配的历史记录 447 | const historyItem = history.find(item => item.title === title); 448 | 449 | // 如果找到了匹配的历史记录,尝试获取该条目的集数数据 450 | if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) { 451 | episodesList = historyItem.episodes; 452 | console.log(`从历史记录找到视频 ${title} 的集数数据:`, episodesList.length); 453 | } 454 | } 455 | 456 | // 如果在历史记录中没找到,尝试使用上一个会话的集数数据 457 | if (episodesList.length === 0) { 458 | try { 459 | const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]'); 460 | if (storedEpisodes.length > 0) { 461 | episodesList = storedEpisodes; 462 | console.log(`使用localStorage中的集数数据:`, episodesList.length); 463 | } 464 | } catch (e) { 465 | console.error('解析currentEpisodes失败:', e); 466 | } 467 | } 468 | 469 | // 将剧集列表保存到localStorage,避免过长的URL 470 | if (episodesList.length > 0) { 471 | localStorage.setItem('currentEpisodes', JSON.stringify(episodesList)); 472 | console.log(`已将剧集列表保存到localStorage,共 ${episodesList.length} 集`); 473 | } 474 | // 构造带播放进度参数的URL 475 | const positionParam = playbackPosition > 10 ? `&position=${Math.floor(playbackPosition)}` : ''; 476 | 477 | if (url.includes('?')) { 478 | // URL已有参数,添加索引和位置参数 479 | let playUrl = url; 480 | if (!url.includes('index=') && episodeIndex > 0) { 481 | playUrl += `&index=${episodeIndex}`; 482 | } 483 | if (playbackPosition > 10) { 484 | playUrl += positionParam; 485 | } 486 | window.open(playUrl, '_blank'); 487 | } else { 488 | // 原始URL,构造player页面链接 489 | const playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}${positionParam}`; 490 | window.open(playerUrl, '_blank'); 491 | } 492 | } catch (e) { 493 | console.error('从历史记录播放失败:', e); 494 | // 回退到原始简单URL 495 | const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`; 496 | window.open(simpleUrl, '_blank'); 497 | } 498 | } 499 | 500 | // 添加观看历史 - 确保每个视频标题只有一条记录 501 | function addToViewingHistory(videoInfo) { 502 | // 密码保护校验 503 | if (window.isPasswordProtected && window.isPasswordVerified) { 504 | if (window.isPasswordProtected() && !window.isPasswordVerified()) { 505 | showPasswordModal && showPasswordModal(); 506 | return; 507 | } 508 | } 509 | try { 510 | const history = getViewingHistory(); 511 | 512 | // 检查是否已经存在相同标题的记录(同一视频的不同集数) 513 | const existingIndex = history.findIndex(item => item.title === videoInfo.title); 514 | if (existingIndex !== -1) { 515 | // 存在则更新现有记录的集数和时间戳 516 | const existingItem = history[existingIndex]; 517 | existingItem.episodeIndex = videoInfo.episodeIndex; 518 | existingItem.timestamp = Date.now(); 519 | 520 | // 确保来源信息保留 521 | if (videoInfo.sourceName && !existingItem.sourceName) { 522 | existingItem.sourceName = videoInfo.sourceName; 523 | } 524 | 525 | // 更新播放进度信息,仅当新进度有效且大于10秒时 526 | if (videoInfo.playbackPosition && videoInfo.playbackPosition > 10) { 527 | existingItem.playbackPosition = videoInfo.playbackPosition; 528 | existingItem.duration = videoInfo.duration || existingItem.duration; 529 | } 530 | 531 | // 更新URL,确保能够跳转到正确的集数 532 | existingItem.url = videoInfo.url; 533 | 534 | // 重要:确保episodes数据与当前视频匹配 535 | // 只有当videoInfo中包含有效的episodes数据时才更新 536 | if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) { 537 | // 如果传入的集数数据与当前保存的不同,则更新 538 | if (!existingItem.episodes || 539 | !Array.isArray(existingItem.episodes) || 540 | existingItem.episodes.length !== videoInfo.episodes.length) { 541 | console.log(`更新 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`); 542 | existingItem.episodes = [...videoInfo.episodes]; // 使用深拷贝 543 | } 544 | } 545 | 546 | // 移到最前面 547 | history.splice(existingIndex, 1); 548 | history.unshift(existingItem); 549 | } else { 550 | // 添加新记录到最前面,确保包含剧集数据 551 | const newItem = { 552 | ...videoInfo, 553 | timestamp: Date.now() 554 | }; 555 | 556 | // 确保episodes字段是一个数组 557 | if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) { 558 | newItem.episodes = [...videoInfo.episodes]; // 使用深拷贝 559 | console.log(`保存新视频 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`); 560 | } else { 561 | // 如果没有提供episodes,初始化为空数组 562 | newItem.episodes = []; 563 | } 564 | 565 | history.unshift(newItem); 566 | } 567 | 568 | // 限制历史记录数量为50条 569 | const maxHistoryItems = 50; 570 | if (history.length > maxHistoryItems) { 571 | history.splice(maxHistoryItems); 572 | } 573 | 574 | // 保存到本地存储 575 | localStorage.setItem('viewingHistory', JSON.stringify(history)); 576 | } catch (e) { 577 | console.error('保存观看历史失败:', e); 578 | } 579 | } 580 | 581 | // 清空观看历史 582 | function clearViewingHistory() { 583 | try { 584 | localStorage.removeItem('viewingHistory'); 585 | loadViewingHistory(); // 重新加载空的历史记录 586 | showToast('观看历史已清空', 'success'); 587 | } catch (e) { 588 | console.error('清除观看历史失败:', e); 589 | showToast('清除观看历史失败', 'error'); 590 | } 591 | } 592 | 593 | // 更新toggleSettings函数以处理历史面板互动 594 | const originalToggleSettings = toggleSettings; 595 | toggleSettings = function(e) { 596 | if (e) e.stopPropagation(); 597 | 598 | // 原始设置面板切换逻辑 599 | originalToggleSettings(e); 600 | 601 | // 如果历史记录面板是打开的,则关闭它 602 | const historyPanel = document.getElementById('historyPanel'); 603 | if (historyPanel && historyPanel.classList.contains('show')) { 604 | historyPanel.classList.remove('show'); 605 | } 606 | }; 607 | 608 | // 点击外部关闭历史面板 609 | document.addEventListener('DOMContentLoaded', function() { 610 | document.addEventListener('click', function(e) { 611 | const historyPanel = document.getElementById('historyPanel'); 612 | const historyButton = document.querySelector('button[onclick="toggleHistory(event)"]'); 613 | 614 | if (historyPanel && historyButton && 615 | !historyPanel.contains(e.target) && 616 | !historyButton.contains(e.target) && 617 | historyPanel.classList.contains('show')) { 618 | historyPanel.classList.remove('show'); 619 | } 620 | }); 621 | }); 622 | -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- 1 | import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现 2 | 3 | // Vercel Middleware to inject environment variables 4 | export default async function middleware(request) { 5 | // Get the URL from the request 6 | const url = new URL(request.url); 7 | 8 | // Only process HTML pages 9 | const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/'); 10 | if (!isHtmlPage) { 11 | return; // Let the request pass through unchanged 12 | } 13 | 14 | // Fetch the original response 15 | const response = await fetch(request); 16 | 17 | // Check if it's an HTML response 18 | const contentType = response.headers.get('content-type') || ''; 19 | if (!contentType.includes('text/html')) { 20 | return response; // Return the original response if not HTML 21 | } 22 | 23 | // Get the HTML content 24 | const originalHtml = await response.text(); 25 | 26 | // Replace the placeholder with actual environment variable 27 | // If PASSWORD is not set, replace with empty string 28 | const password = process.env.PASSWORD || ''; 29 | let passwordHash = ''; 30 | if (password) { 31 | passwordHash = await sha256(password); 32 | } 33 | const modifiedHtml = originalHtml.replace( 34 | 'window.__ENV__.PASSWORD = "{{PASSWORD}}";', 35 | `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash` 36 | ); 37 | 38 | // Create a new response with the modified HTML 39 | return new Response(modifiedHtml, { 40 | status: response.status, 41 | statusText: response.statusText, 42 | headers: response.headers 43 | }); 44 | } 45 | 46 | export const config = { 47 | matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'], 48 | }; -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # netlify.toml 2 | 3 | [build] 4 | # 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish 5 | # publish = "." # 假设你的 HTML/CSS/JS 文件在根目录 6 | functions = "netlify/functions" # 指定 Netlify 函数目录 7 | 8 | # 配置重写规则,将 /proxy/* 的请求路由到 proxy 函数 9 | # 这样前端的 PROXY_URL 仍然可以是 '/proxy/' 10 | [[redirects]] 11 | from = "/proxy/*" 12 | to = "/.netlify/functions/proxy/:splat" # 将路径参数传递给函数 13 | status = 200 # 重要:这是代理,不是重定向 14 | 15 | # (可选)为其他静态文件设置缓存头等 16 | # [[headers]] 17 | # for = "/*" 18 | # [headers.values] 19 | # # Add any global headers here 20 | -------------------------------------------------------------------------------- /netlify/functions/proxy.mjs: -------------------------------------------------------------------------------- 1 | // /netlify/functions/proxy.mjs - Netlify Function (ES Module) 2 | 3 | import fetch from 'node-fetch'; 4 | import { URL } from 'url'; // Use Node.js built-in URL 5 | 6 | // --- Configuration (Read from Environment Variables) --- 7 | const DEBUG_ENABLED = process.env.DEBUG === 'true'; 8 | const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours 9 | const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels 10 | 11 | // --- User Agent Handling --- 12 | let USER_AGENTS = [ 13 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 14 | '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' 15 | ]; 16 | try { 17 | const agentsJsonString = process.env.USER_AGENTS_JSON; 18 | if (agentsJsonString) { 19 | const parsedAgents = JSON.parse(agentsJsonString); 20 | if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { 21 | USER_AGENTS = parsedAgents; 22 | console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`); 23 | } else { 24 | console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default."); 25 | } 26 | } else { 27 | console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents."); 28 | } 29 | } catch (e) { 30 | console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`); 31 | } 32 | const FILTER_DISCONTINUITY = false; // Ad filtering disabled 33 | 34 | // --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) --- 35 | 36 | function logDebug(message) { 37 | if (DEBUG_ENABLED) { 38 | console.log(`[Proxy Log Netlify] ${message}`); 39 | } 40 | } 41 | 42 | function getTargetUrlFromPath(encodedPath) { 43 | if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; } 44 | try { 45 | const decodedUrl = decodeURIComponent(encodedPath); 46 | if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; } 47 | else { 48 | logDebug(`Invalid decoded URL format: ${decodedUrl}`); 49 | if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; } 50 | return null; 51 | } 52 | } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; } 53 | } 54 | 55 | function getBaseUrl(urlStr) { 56 | if (!urlStr) return ''; 57 | try { 58 | const parsedUrl = new URL(urlStr); 59 | const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); 60 | if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; } 61 | pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`; 62 | } catch (e) { 63 | logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`); 64 | const lastSlashIndex = urlStr.lastIndexOf('/'); 65 | if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); } 66 | return urlStr + '/'; 67 | } 68 | } 69 | 70 | function resolveUrl(baseUrl, relativeUrl) { 71 | if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl; 72 | try { return new URL(relativeUrl, baseUrl).toString(); } 73 | catch (e) { 74 | logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`); 75 | if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } } 76 | else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; } 77 | } 78 | } 79 | 80 | // ** MODIFIED for Netlify redirect ** 81 | function rewriteUrlToProxy(targetUrl) { 82 | if (!targetUrl || typeof targetUrl !== 'string') return ''; 83 | // Use the path defined in netlify.toml 'from' field 84 | return `/proxy/${encodeURIComponent(targetUrl)}`; 85 | } 86 | 87 | function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } 88 | 89 | async function fetchContentWithType(targetUrl, requestHeaders) { 90 | const headers = { 91 | 'User-Agent': getRandomUserAgent(), 92 | 'Accept': requestHeaders['accept'] || '*/*', 93 | 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8', 94 | 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin, 95 | }; 96 | Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {}); 97 | logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`); 98 | try { 99 | const response = await fetch(targetUrl, { headers, redirect: 'follow' }); 100 | if (!response.ok) { 101 | const errorBody = await response.text().catch(() => ''); 102 | logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`); 103 | const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`); 104 | err.status = response.status; throw err; 105 | } 106 | const content = await response.text(); 107 | const contentType = response.headers.get('content-type') || ''; 108 | logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`); 109 | return { content, contentType, responseHeaders: response.headers }; 110 | } catch (error) { 111 | logDebug(`Fetch exception for ${targetUrl}: ${error.message}`); 112 | throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`); 113 | } 114 | } 115 | 116 | function isM3u8Content(content, contentType) { 117 | if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; } 118 | return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); 119 | } 120 | 121 | function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } 122 | function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } 123 | function processMediaPlaylist(url, content) { 124 | const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); } 125 | const lines = content.split('\n'); const output = []; 126 | for (let i = 0; i < lines.length; i++) { 127 | const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue; 128 | if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; } 129 | if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; } 130 | if (line.startsWith('#EXTINF')) { output.push(line); continue; } 131 | if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; } 132 | output.push(line); 133 | } return output.join('\n'); 134 | } 135 | async function processM3u8Content(targetUrl, content, recursionDepth = 0) { 136 | if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); } 137 | logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content); 138 | } 139 | async function processMasterPlaylist(url, content, recursionDepth) { 140 | if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); } 141 | const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = ''; 142 | 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); } } } 143 | if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } } 144 | if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); } 145 | logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`); 146 | const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {}); 147 | if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); } 148 | return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1); 149 | } 150 | 151 | 152 | // --- Netlify Handler --- 153 | export const handler = async (event, context) => { 154 | console.log('--- Netlify Proxy Request ---'); 155 | console.log('Time:', new Date().toISOString()); 156 | console.log('Method:', event.httpMethod); 157 | console.log('Path:', event.path); 158 | // Note: event.queryStringParameters contains query params if any 159 | // Note: event.headers contains incoming headers 160 | 161 | // --- CORS Headers (for all responses) --- 162 | const corsHeaders = { 163 | 'Access-Control-Allow-Origin': '*', 164 | 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 165 | 'Access-Control-Allow-Headers': '*', // Allow all headers client might send 166 | }; 167 | 168 | // --- Handle OPTIONS Preflight Request --- 169 | if (event.httpMethod === 'OPTIONS') { 170 | logDebug("Handling OPTIONS request"); 171 | return { 172 | statusCode: 204, 173 | headers: { 174 | ...corsHeaders, 175 | 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours 176 | }, 177 | body: '', 178 | }; 179 | } 180 | 181 | // --- Extract Target URL --- 182 | // Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat" 183 | // The :splat part should be available in event.path after the base path 184 | let encodedUrlPath = ''; 185 | const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml 186 | if (event.path && event.path.startsWith(proxyPrefix)) { 187 | encodedUrlPath = event.path.substring(proxyPrefix.length); 188 | logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`); 189 | } else { 190 | logDebug(`Could not extract encoded path from event.path: ${event.path}`); 191 | // Potentially handle direct calls too? Less likely needed. 192 | // const functionPath = '/.netlify/functions/proxy/'; 193 | // if (event.path && event.path.startsWith(functionPath)) { 194 | // encodedUrlPath = event.path.substring(functionPath.length); 195 | // } 196 | } 197 | 198 | const targetUrl = getTargetUrlFromPath(encodedUrlPath); 199 | logDebug(`Resolved target URL: ${targetUrl || 'null'}`); 200 | 201 | if (!targetUrl) { 202 | logDebug('Error: Invalid proxy request path.'); 203 | return { 204 | statusCode: 400, 205 | headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 206 | body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }), 207 | }; 208 | } 209 | 210 | logDebug(`Processing proxy request for target: ${targetUrl}`); 211 | 212 | try { 213 | // Fetch Original Content (Pass Netlify event headers) 214 | const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers); 215 | 216 | // --- Process if M3U8 --- 217 | if (isM3u8Content(content, contentType)) { 218 | logDebug(`Processing M3U8 content: ${targetUrl}`); 219 | const processedM3u8 = await processM3u8Content(targetUrl, content); 220 | 221 | logDebug(`Successfully processed M3U8 for ${targetUrl}`); 222 | return { 223 | statusCode: 200, 224 | headers: { 225 | ...corsHeaders, // Include CORS headers 226 | 'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8', 227 | 'Cache-Control': `public, max-age=${CACHE_TTL}`, 228 | // Note: Do NOT include content-encoding or content-length from original response 229 | // as node-fetch likely decompressed it and length changed. 230 | }, 231 | body: processedM3u8, // Netlify expects body as string 232 | }; 233 | } else { 234 | // --- Return Original Content (Non-M3U8) --- 235 | logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`); 236 | 237 | // Prepare headers for Netlify response object 238 | const netlifyHeaders = { ...corsHeaders }; 239 | responseHeaders.forEach((value, key) => { 240 | const lowerKey = key.toLowerCase(); 241 | // Exclude problematic headers and CORS headers (already added) 242 | if (!lowerKey.startsWith('access-control-') && 243 | lowerKey !== 'content-encoding' && 244 | lowerKey !== 'content-length') { 245 | netlifyHeaders[key] = value; // Add other original headers 246 | } 247 | }); 248 | netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy 249 | 250 | return { 251 | statusCode: 200, 252 | headers: netlifyHeaders, 253 | body: content, // Body as string 254 | // isBase64Encoded: false, // Set true only if returning binary data as base64 255 | }; 256 | } 257 | 258 | } catch (error) { 259 | logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`); 260 | console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack 261 | 262 | const statusCode = error.status || 500; // Get status from error if available 263 | 264 | return { 265 | statusCode: statusCode, 266 | headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 267 | body: JSON.stringify({ 268 | success: false, 269 | error: `Proxy processing error: ${error.message}`, 270 | targetUrl: targetUrl 271 | }), 272 | }; 273 | } 274 | }; 275 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | #access_log /var/log/nginx/host.access.log main; 6 | 7 | resolver 114.114.114.114 8.8.8.8 valid=300s; 8 | resolver_timeout 5s; 9 | 10 | # 创建代理路由 11 | location /proxy/ { 12 | # 设置CORS头部 13 | add_header 'Access-Control-Allow-Origin' '*'; 14 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 15 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; 16 | add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; 17 | 18 | # OPTIONS请求处理 19 | if ($request_method = 'OPTIONS') { 20 | add_header 'Access-Control-Max-Age' 1728000; 21 | add_header 'Content-Type' 'text/plain charset=UTF-8'; 22 | add_header 'Content-Length' 0; 23 | return 204; 24 | } 25 | 26 | set $target_url ''; 27 | 28 | # 执行Lua脚本解析URL 29 | rewrite_by_lua_file /usr/share/nginx/html/proxy.lua; 30 | 31 | proxy_ssl_server_name on; 32 | proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; 33 | 34 | # 设置代理头信息 35 | # 不设置Host,让Nginx自动根据目标URL设置 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_set_header X-Forwarded-Proto $scheme; 39 | # 处理可能的重定向 40 | proxy_redirect off; 41 | proxy_buffering off; 42 | # 代理超时设置 43 | proxy_connect_timeout 60s; 44 | proxy_send_timeout 60s; 45 | proxy_read_timeout 60s; 46 | 47 | proxy_pass $target_url; 48 | } 49 | 50 | location / { 51 | root /usr/share/nginx/html; 52 | index index.html index.htm; 53 | } 54 | 55 | #error_page 404 /404.html; 56 | 57 | # redirect server error pages to the static page /50x.html 58 | # 59 | error_page 500 502 503 504 /50x.html; 60 | location = /50x.html { 61 | root /usr/share/nginx/html; 62 | } 63 | 64 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80 65 | # 66 | #location ~ \.php$ { 67 | # proxy_pass http://127.0.0.1; 68 | #} 69 | 70 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 71 | # 72 | #location ~ \.php$ { 73 | # root html; 74 | # fastcgi_pass 127.0.0.1:9000; 75 | # fastcgi_index index.php; 76 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; 77 | # include fastcgi_params; 78 | #} 79 | 80 | # deny access to .htaccess files, if Apache's document root 81 | # concurs with nginx's one 82 | # 83 | #location ~ /\.ht { 84 | # deny all; 85 | #} 86 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "libretv", 3 | "version": "1.1.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "libretv", 9 | "version": "1.1.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "node-fetch": "^3.3.2" 13 | } 14 | }, 15 | "node_modules/data-uri-to-buffer": { 16 | "version": "4.0.1", 17 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", 18 | "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", 19 | "license": "MIT", 20 | "engines": { 21 | "node": ">= 12" 22 | } 23 | }, 24 | "node_modules/fetch-blob": { 25 | "version": "3.2.0", 26 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", 27 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 28 | "funding": [ 29 | { 30 | "type": "github", 31 | "url": "https://github.com/sponsors/jimmywarting" 32 | }, 33 | { 34 | "type": "paypal", 35 | "url": "https://paypal.me/jimmywarting" 36 | } 37 | ], 38 | "license": "MIT", 39 | "dependencies": { 40 | "node-domexception": "^1.0.0", 41 | "web-streams-polyfill": "^3.0.3" 42 | }, 43 | "engines": { 44 | "node": "^12.20 || >= 14.13" 45 | } 46 | }, 47 | "node_modules/formdata-polyfill": { 48 | "version": "4.0.10", 49 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", 50 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", 51 | "license": "MIT", 52 | "dependencies": { 53 | "fetch-blob": "^3.1.2" 54 | }, 55 | "engines": { 56 | "node": ">=12.20.0" 57 | } 58 | }, 59 | "node_modules/node-domexception": { 60 | "version": "1.0.0", 61 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 62 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 63 | "funding": [ 64 | { 65 | "type": "github", 66 | "url": "https://github.com/sponsors/jimmywarting" 67 | }, 68 | { 69 | "type": "github", 70 | "url": "https://paypal.me/jimmywarting" 71 | } 72 | ], 73 | "license": "MIT", 74 | "engines": { 75 | "node": ">=10.5.0" 76 | } 77 | }, 78 | "node_modules/node-fetch": { 79 | "version": "3.3.2", 80 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", 81 | "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", 82 | "license": "MIT", 83 | "dependencies": { 84 | "data-uri-to-buffer": "^4.0.0", 85 | "fetch-blob": "^3.1.4", 86 | "formdata-polyfill": "^4.0.10" 87 | }, 88 | "engines": { 89 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 90 | }, 91 | "funding": { 92 | "type": "opencollective", 93 | "url": "https://opencollective.com/node-fetch" 94 | } 95 | }, 96 | "node_modules/web-streams-polyfill": { 97 | "version": "3.3.3", 98 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", 99 | "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", 100 | "license": "MIT", 101 | "engines": { 102 | "node": ">= 8" 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "libretv", 3 | "version": "1.1.0", 4 | "description": "免费在线视频搜索与观看平台", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "node-fetch": "^3.3.2" 12 | }, 13 | "author": "bestZwei", 14 | "license": "MIT" 15 | } -------------------------------------------------------------------------------- /privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 隐私政策 - LibreTV 7 | 8 | 9 | 10 | 11 |
12 |
13 |

隐私政策

14 |
15 |
16 |

17 | 我们尊重并保护您的隐私。LibreTV 不收集任何个人数据,且不会限制访问或使用本网站。 18 |

19 |

20 | 本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供,我们不会存储或追踪用户信息。 21 |

22 |
23 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /proxy.lua: -------------------------------------------------------------------------------- 1 | -- 解码URL函数 2 | local function decode_uri(uri) 3 | local decoded = ngx.unescape_uri(uri) 4 | return decoded 5 | end 6 | 7 | -- 直接从请求URI获取完整URL 8 | local request_uri = ngx.var.request_uri 9 | ngx.log(ngx.DEBUG, "完整请求URI: ", request_uri) 10 | 11 | -- 提取/proxy/后面的部分 12 | local _, _, target_path = string.find(request_uri, "^/proxy/(.*)") 13 | ngx.log(ngx.DEBUG, "提取的目标路径: ", target_path or "nil") 14 | 15 | if not target_path or target_path == "" then 16 | ngx.status = 400 17 | ngx.say("错误: 未提供目标URL") 18 | return ngx.exit(400) 19 | end 20 | 21 | -- 解码URL 22 | local target_url = decode_uri(target_path) 23 | ngx.log(ngx.DEBUG, "解码后的目标URL: ", target_url) 24 | 25 | if not target_url or target_url == "" then 26 | ngx.status = 400 27 | ngx.say("错误: 无法解析目标URL") 28 | return ngx.exit(400) 29 | end 30 | 31 | -- 记录日志 32 | ngx.log(ngx.STDERR, "代理请求: ", target_url) 33 | 34 | -- 设置目标URL变量供Nginx使用 35 | ngx.var.target_url = target_url 36 | 37 | -- 继续执行Nginx配置的其余部分 38 | return -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # LibreTV - 免费在线视频搜索与观看平台 2 | 3 |
4 | LibreTV Logo 5 |
6 |

自由观影,畅享精彩

7 |
8 | 9 | ## 📺 项目简介 10 | 11 | LibreTV 是一个轻量级、免费的在线视频搜索与观看平台,提供来自多个视频源的内容搜索与播放服务。无需注册,即开即用,支持多种设备访问。项目结合了前端技术和后端代理功能,可部署在支持服务端功能的各类网站托管服务上。 12 | 13 | 本项目基于 [bestK/tv](https://github.com/bestK/tv) 进行重构与增强。 14 | 15 |
16 | 点击查看项目截图 17 | 项目截图 18 |
19 | 20 | ## 🥇 感谢赞助 21 | 22 | - **[YXVM](https://yxvm.com)** 23 | - **[VTEXS](https://vtexs.com)** 24 | 25 | ## 🚀 快速部署 26 | 27 | 选择以下任一平台,点击一键部署按钮,即可快速创建自己的 LibreTV 实例: 28 | 29 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FLibreSpark%2FLibreTV) [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/LibreSpark/LibreTV) 30 | 31 | ## 📋 详细部署指南 32 | 33 | ### Cloudflare Pages 34 | 35 | 1. Fork 或克隆本仓库到您的 GitHub 账户 36 | 2. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 Pages 服务 37 | 3. 点击"创建项目",连接您的 GitHub 仓库 38 | 4. 使用以下设置: 39 | - 构建命令:留空(无需构建) 40 | - 输出目录:留空(默认为根目录) 41 | 5. 点击"保存并部署" 42 | 6. 可选:在"设置" > "环境变量"中配置密码保护 43 | 44 | ### Vercel 45 | 46 | 1. Fork 或克隆本仓库到您的 GitHub/GitLab 账户 47 | 2. 登录 [Vercel](https://vercel.com/),点击"New Project" 48 | 3. 导入您的仓库,使用默认设置 49 | 4. 点击"Deploy" 50 | 5. 可选:在"Settings" > "Environment Variables"中配置密码保护 51 | 52 | ### Netlify 53 | 54 | 1. Fork 或克隆本仓库到您的 GitHub 账户 55 | 2. 登录 [Netlify](https://app.netlify.com/) 56 | 3. 点击"New site from Git",选择您的仓库 57 | 4. 构建设置保持默认 58 | 5. 点击"Deploy site" 59 | 6. 可选:在"Site settings" > "Build & deploy" > "Environment"中配置密码保护 60 | 61 | ### Docker 62 | 63 | 使用 Docker 运行 LibreTV: 64 | 65 | ```bash 66 | docker run -d \ 67 | --name libretv \ 68 | -p 8899:80 \ 69 | -e PASSWORD=your_password_here \ 70 | bestzwei/libretv:latest 71 | ``` 72 | 73 | 访问 `http://localhost:8899` 即可使用。 74 | 75 | ### Docker Compose 76 | 77 | `docker-compose.yml` 文件: 78 | 79 | ```yaml 80 | version: '3' 81 | services: 82 | libretv: 83 | image: bestzwei/libretv:latest 84 | container_name: libretv 85 | ports: 86 | - "8899:80" 87 | environment: 88 | - PASSWORD=111111 89 | restart: unless-stopped 90 | ``` 91 | 92 | ### 本地开发环境 93 | 94 | 项目包含后端代理功能,需要支持服务器端功能的环境: 95 | 96 | ```bash 97 | # 安装依赖 98 | npm install 99 | 100 | # 启动开发服务器 101 | npm run dev 102 | ``` 103 | 104 | > ⚠️ 注意:使用简单静态服务器(如 `python -m http.server` 或 `npx http-server`)时,视频代理功能将不可用,视频无法正常播放。完整功能测试请使用 Node.js 开发服务器。 105 | 106 | ## 🔧 自定义配置 107 | 108 | ### 密码保护 109 | 110 | 要为您的 LibreTV 实例添加密码保护,可以在部署平台上设置环境变量: 111 | 112 | **环境变量名**: `PASSWORD` 113 | **值**: 您想设置的密码 114 | 115 | 各平台设置方法: 116 | 117 | - **Cloudflare Pages**: Dashboard > 您的项目 > 设置 > 环境变量 118 | - **Vercel**: Dashboard > 您的项目 > Settings > Environment Variables 119 | - **Netlify**: Dashboard > 您的项目 > Site settings > Build & deploy > Environment 120 | - **Docker**: 使用 `-e PASSWORD=your_password` 参数 121 | 122 | ### API兼容性 123 | 124 | LibreTV 支持标准的苹果 CMS V10 API 格式。添加自定义 API 时需遵循以下格式: 125 | - 搜索接口: `https://example.com/api.php/provide/vod/?ac=videolist&wd=关键词` 126 | - 详情接口: `https://example.com/api.php/provide/vod/?ac=detail&ids=视频ID` 127 | 128 | **添加 CMS 源**: 129 | 1. 在设置面板中选择"自定义接口" 130 | 2. 接口地址只需填写到域名部分: `https://example.com`(不要包含`/api.php/provide/vod`部分) 131 | 132 | ## ⌨️ 键盘快捷键 133 | 134 | 播放器支持以下键盘快捷键: 135 | 136 | - **空格键**: 播放/暂停 137 | - **左右箭头**: 快退/快进 138 | - **上下箭头**: 音量增加/减小 139 | - **M 键**: 静音/取消静音 140 | - **F 键**: 全屏/退出全屏 141 | - **Esc 键**: 退出全屏 142 | 143 | ## 🛠️ 技术栈 144 | 145 | - HTML5 + CSS3 + JavaScript (ES6+) 146 | - Tailwind CSS (通过 CDN 引入) 147 | - HLS.js 用于 HLS 流处理 148 | - DPlayer 视频播放器核心 149 | - Cloudflare/Vercel/Netlify Serverless Functions 150 | - 服务端 HLS 代理和处理技术 151 | - localStorage 本地存储 152 | 153 | ## 🔄 更新日志 154 | 155 |
156 | 点击查看更新日志 157 | 158 | - **1.1.2** (2025-04-22): 新增豆瓣热门内容显示,设置中可开关 159 | - **1.1.1** (2025-04-19): 160 | - 修复 docker 部署时无法搜索的问题 161 | - 修复播放页面进度保存与恢复的兼容性问题 162 | - **1.1.0** (2025-04-17): 添加服务端代理功能,支持 HLS 流处理和解析,支持环境变量设置访问密码 163 | - **1.0.3** (2025-04-13): 性能优化、UI优化、更新设置功能 164 | - **1.0.2** (2025-04-08): 分离播放页面,优化视频源 API 兼容性 165 | - **1.0.1** (2025-04-07): 添加广告过滤功能,优化播放器性能 166 | - **1.0.0** (2025-04-06): 初始版本发布 167 | 168 |
169 | 170 | ## ⚠️ 免责声明 171 | 172 | LibreTV 仅作为视频搜索工具,不存储、上传或分发任何视频内容。所有视频均来自第三方 API 接口提供的搜索结果。如有侵权内容,请联系相应的内容提供方。 173 | 174 | 本项目开发者不对使用本项目产生的任何后果负责。使用本项目时,您必须遵守当地的法律法规。 175 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Disallow: /js/ 4 | Disallow: /css/ 5 | 6 | Sitemap: https://libretv.is-an.org/sitemap.xml 7 | -------------------------------------------------------------------------------- /sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://libretv.is-an.org/ 5 | 2025-04-06 6 | weekly 7 | 1.0 8 | 9 | 10 | https://libretv.is-an.org/about.html 11 | 2025-04-06 12 | monthly 13 | 0.8 14 | 15 | 16 | https://libretv.is-an.org/privacy.html 17 | 2025-04-06 18 | monthly 19 | 0.5 20 | 21 | 22 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/proxy/:path*", 5 | "destination": "/api/proxy/:path*" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /watch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 重定向到播放器 7 | 8 | 9 |

如果您没有被自动重定向,请点击这里前往播放页面。

10 | 11 | 12 | --------------------------------------------------------------------------------