├── .github └── workflows │ └── deploy.yml ├── LICENSE ├── README.md ├── deno-cache.ts ├── deno.ts ├── edgeone.js ├── m3u8filter-curl.php ├── m3u8filter.php ├── node.js ├── worker-cache.js └── worker.js /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: main 5 | pull_request: 6 | branches: main 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | id-token: write # Needed for auth with Deno Deploy 15 | contents: read # Needed to clone the repository 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Install Deno 22 | uses: denoland/setup-deno@v2 23 | with: 24 | deno-version: v2.x 25 | 26 | - name: Install Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: lts/* 30 | 31 | - name: Install step 32 | run: "deno install -gArf jsr:@deno/deployctl" 33 | 34 | - name: Build step 35 | run: "deployctl deploy --project=fproxy --prod deno.ts" 36 | 37 | - name: Upload to Deno Deploy 38 | uses: denoland/deployctl@v1 39 | with: 40 | project: "fproxy" 41 | entrypoint: "deno.ts" 42 | root: "" 43 | 44 | 45 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # M3U8 Proxy Filter Script 2 | # 项目说明文档 3 | 4 | # 代理地址说明 5 | 脚本内示例的代理地址`https://proxy.mengze.vip/proxy/`已失效,请自行部署cf代理或其他代理进行替换,cf代理部署参考[cloudflare-safeproxy](https://github.com/eraycc/cloudflare-safeproxy),部署后,将代理地址替换为API代理地址,URL编码配置为false(也可自行修改cf代理脚本代码,使其支持URL编码)。 6 | ## 项目概述 7 | 8 | M3U8 Proxy Filter Script 是一个多语言实现的 HLS (HTTP Live Streaming) 代理过滤脚本,支持Nodejs、Cloudflare Worker(cf Pages)、Deno 和 PHP 环境。脚本提供 M3U8 播放链接的去广告、代理加速功能,并支持多种 HLS 协议特性。 9 | 10 | ## 功能特性 11 | 12 | ### 核心功能 13 | - **代理重写**:使用代理获取 M3U8 文件并重写其中的 TS/fMP4 分片 URL 14 | - **EXT-X-MAP 支持**:完整支持初始化段代理 15 | - **加密流处理**:支持 EXT-X-KEY 加密流处理 16 | - **discontinuity 标记过滤(建议弃用吧)**:可配置是否过滤 discontinuity 标记 17 | - **基于正则表达式和统计学算法进行过滤**:可参考最新上传的php过滤脚本自行修改,js语法可参考下面的函数: 18 | ``` 19 | /** 20 | * 超级M3U8广告算法过滤器 21 | * @param {string} m3u8Content - 原始M3U8内容 22 | * @param {string|null} regexFilter - 可选的正则过滤规则 23 | * @return {string} 过滤后的完整M3U8内容 24 | */ 25 | function SuperFilterAdsFromM3U8(m3u8Content, regexFilter = null) { 26 | if (!m3u8Content) return ''; 27 | 28 | // ==================== 第一阶段:预处理 ==================== 29 | // 1. 正则过滤 30 | let processedContent = regexFilter 31 | ? applyRegexFilter(m3u8Content, regexFilter) 32 | : m3u8Content; 33 | 34 | // 2. 解析M3U8结构 35 | const { segments, headers } = parseM3U8Structure(processedContent); 36 | if (segments.length === 0) return processedContent; 37 | 38 | // ==================== 第二阶段:科学分析 ==================== 39 | // 1. 计算基础统计量 40 | const stats = calculateSegmentStats(segments); 41 | 42 | // 2. 多维度广告检测 43 | const analyzedSegments = analyzeSegments(segments, stats); 44 | 45 | // 3. 智能过滤决策 46 | const filteredSegments = applyFilterDecision(analyzedSegments, stats); 47 | 48 | // ==================== 第三阶段:重建M3U8 ==================== 49 | return rebuildM3U8(headers, filteredSegments, processedContent); 50 | } 51 | 52 | // ==================== 辅助函数 ==================== 53 | 54 | /** 55 | * 应用正则过滤 56 | */ 57 | function applyRegexFilter(content, regexFilter) { 58 | try { 59 | const regex = new RegExp(regexFilter, 'gi'); 60 | return content.replace(regex, ''); 61 | } catch (e) { 62 | console.warn('正则过滤失败:', e); 63 | return content; 64 | } 65 | } 66 | 67 | /** 68 | * 深度解析M3U8结构 69 | */ 70 | function parseM3U8Structure(content) { 71 | const lines = content.split('\n'); 72 | const segments = []; 73 | const headers = { 74 | main: [], 75 | other: [] 76 | }; 77 | let currentDiscontinuity = false; 78 | let currentMap = null; 79 | let segmentIndex = 0; 80 | 81 | for (let i = 0; i < lines.length; i++) { 82 | const line = lines[i].trim(); 83 | 84 | // 收集头部信息 85 | if (i < 10 && line.startsWith('#EXT')) { 86 | headers.main.push(line); 87 | continue; 88 | } 89 | 90 | // 处理关键标签 91 | if (line.startsWith('#EXT-X-MAP:')) { 92 | currentMap = line; 93 | continue; 94 | } 95 | 96 | if (line.includes('#EXT-X-DISCONTINUITY')) { 97 | currentDiscontinuity = true; 98 | continue; 99 | } 100 | 101 | // 解析片段 102 | if (line.startsWith('#EXTINF:')) { 103 | const durationMatch = line.match(/#EXTINF:([\d.]+)/); 104 | if (durationMatch && lines[i + 1] && !lines[i + 1].startsWith('#')) { 105 | const duration = parseFloat(durationMatch[1]); 106 | const url = lines[i + 1].trim(); 107 | 108 | segments.push({ 109 | index: segmentIndex++, 110 | startLine: i, 111 | endLine: i + 1, 112 | duration, 113 | url, 114 | hasDiscontinuity: currentDiscontinuity, 115 | hasMap: currentMap !== null, 116 | content: currentMap 117 | ? [currentMap, line, lines[i + 1]].join('\n') 118 | : [line, lines[i + 1]].join('\n'), 119 | isAd: false, // 初始标记 120 | adScore: 0 // 广告概率得分 121 | }); 122 | 123 | currentDiscontinuity = false; 124 | currentMap = null; 125 | i++; // 跳过URL行 126 | } 127 | } else if (line.startsWith('#')) { 128 | headers.other.push(line); 129 | } 130 | } 131 | 132 | return { segments, headers }; 133 | } 134 | 135 | /** 136 | * 计算高级统计量 137 | */ 138 | function calculateSegmentStats(segments) { 139 | const durations = segments.map(s => s.duration); 140 | const totalDuration = durations.reduce((sum, d) => sum + d, 0); 141 | const avgDuration = totalDuration / durations.length; 142 | 143 | // 计算标准差和百分位数 144 | const squaredDiffs = durations.map(d => Math.pow(d - avgDuration, 2)); 145 | const stdDev = Math.sqrt(squaredDiffs.reduce((sum, sd) => sum + sd, 0) / durations.length); 146 | 147 | // 排序后的时长数组用于百分位计算 148 | const sortedDurations = [...durations].sort((a, b) => a - b); 149 | const p10 = sortedDurations[Math.floor(durations.length * 0.1)]; 150 | const p90 = sortedDurations[Math.floor(durations.length * 0.9)]; 151 | 152 | return { 153 | avgDuration, 154 | stdDev, 155 | p10, 156 | p90, 157 | totalDuration, 158 | segmentCount: segments.length, 159 | durationRange: [sortedDurations[0], sortedDurations[sortedDurations.length - 1]] 160 | }; 161 | } 162 | 163 | /** 164 | * 多维度片段分析 165 | */ 166 | function analyzeSegments(segments, stats) { 167 | const { avgDuration, stdDev, p10, p90 } = stats; 168 | 169 | return segments.map(segment => { 170 | const deviation = Math.abs(segment.duration - avgDuration); 171 | const zScore = deviation / stdDev; 172 | 173 | // 1. 时长异常检测 174 | const durationAbnormality = Math.min(1, zScore / 3); // 0-1范围 175 | 176 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 177 | let positionFactor = 0; 178 | if (segment.index < 3 && segment.duration < p10) { 179 | positionFactor = 0.8; // 开头的短片段很可疑 180 | } else if (segment.index > segments.length - 3 && segment.duration < p10) { 181 | positionFactor = 0.5; // 结尾的短片段中等可疑 182 | } 183 | 184 | // 3. 不连续标记检测 185 | const discontinuityFactor = segment.hasDiscontinuity ? 0.3 : 0; 186 | 187 | // 综合广告概率 188 | const adScore = Math.min(1, 189 | (durationAbnormality * 0.6) + 190 | (positionFactor * 0.3) + 191 | (discontinuityFactor * 0.1) 192 | ); 193 | 194 | return { 195 | ...segment, 196 | adScore, 197 | isAd: adScore > 0.65, // 阈值可调整 198 | stats: { deviation, zScore } 199 | }; 200 | }); 201 | } 202 | 203 | /** 204 | * 智能过滤决策 205 | */ 206 | function applyFilterDecision(segments, stats) { 207 | const { avgDuration, stdDev } = stats; 208 | 209 | // 动态调整阈值 210 | const baseThreshold = 0.65; 211 | const dynamicThreshold = Math.min(0.8, Math.max(0.5, 212 | baseThreshold - (stdDev / avgDuration) * 0.2 213 | )); 214 | 215 | return segments.filter(segment => { 216 | // 明确广告标记 217 | if (segment.isAd && segment.adScore > dynamicThreshold) { 218 | return false; 219 | } 220 | 221 | // 极短片段过滤(<1秒且不在开头) 222 | if (segment.duration < 1.0 && segment.index > 3) { 223 | return false; 224 | } 225 | 226 | // 保留关键片段(如包含MAP的) 227 | if (segment.hasMap) { 228 | return true; 229 | } 230 | 231 | // 默认保留 232 | return true; 233 | }); 234 | } 235 | 236 | /** 237 | * 完美重建M3U8 238 | */ 239 | function rebuildM3U8(headers, segments, originalContent) { 240 | // 收集需要保留的行号 241 | const keepLines = new Set(); 242 | 243 | // 保留所有头部信息 244 | headers.main.forEach((_, i) => keepLines.add(i)); 245 | 246 | // 保留所有片段内容 247 | segments.forEach(segment => { 248 | for (let i = segment.startLine; i <= segment.endLine; i++) { 249 | keepLines.add(i); 250 | } 251 | }); 252 | 253 | // 处理其他关键标签 254 | const lines = originalContent.split('\n'); 255 | const criticalTags = [ 256 | '#EXT-X-VERSION', 257 | '#EXT-X-TARGETDURATION', 258 | '#EXT-X-MEDIA-SEQUENCE', 259 | '#EXT-X-PLAYLIST-TYPE', 260 | '#EXT-X-ENDLIST' 261 | ]; 262 | 263 | for (let i = 0; i < lines.length; i++) { 264 | const line = lines[i].trim(); 265 | if (criticalTags.some(tag => line.startsWith(tag))) { 266 | keepLines.add(i); 267 | } 268 | } 269 | 270 | // 重建内容 271 | const filteredLines = lines.filter((_, i) => keepLines.has(i)); 272 | 273 | // 更新关键头部信息 274 | updateM3U8Headers(filteredLines, segments); 275 | 276 | return filteredLines.join('\n'); 277 | } 278 | 279 | /** 280 | * 更新M3U8头部信息 281 | */ 282 | function updateM3U8Headers(lines, segments) { 283 | if (segments.length === 0) return; 284 | 285 | // 更新TARGETDURATION 286 | const maxDuration = Math.max(...segments.map(s => s.duration)); 287 | for (let i = 0; i < lines.length; i++) { 288 | if (lines[i].startsWith('#EXT-X-TARGETDURATION')) { 289 | lines[i] = `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`; 290 | break; 291 | } 292 | } 293 | 294 | // 更新MEDIA-SEQUENCE 295 | if (segments[0].index > 0) { 296 | for (let i = 0; i < lines.length; i++) { 297 | if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE')) { 298 | lines[i] = `#EXT-X-MEDIA-SEQUENCE:${segments[0].index}`; 299 | break; 300 | } 301 | } 302 | } 303 | } 304 | ``` 305 | - **缓存支持**: 306 | - PHP:本地文件缓存 307 | - Deno:内存缓存 308 | - Cloudflare Worker: 309 | worker.js: KV 存储版(需设置变量名称为 `M3U8_PROXY_KV`) 310 | worker-chache.js: 基于worker的边缘网络自带cache缓存,可以直接调用,直接缓存,无限制且无需配置(感谢L站edwa佬友提供的思路) 311 | 312 | ### 高级功能 313 | - **主播放列表解析**:自动解析主播放列表(带递归深度限制) 314 | - **非 M3U8 内容处理**: 315 | - 音视频/图片文件:使用 TS 代理跳转加速 316 | - 其他内容:直接跳转原始 URL 317 | - **双代理设置**:全部脚本支持双代理配置 318 | - **广告处理**:支持 M3U8 全局加速及去除广告标记 319 | 320 | ## 部署与使用 321 | ``` 322 | deno 部署: 323 | fork该项目,打开deno面板,导入复刻的项目,Entrypoint填写deno.ts 324 | 325 | cf worker 部署: 326 | 新建kv后,绑定时设置变量名称为M3U8_PROXY_KV,复制worker.js到新建worker内部署。** worker-cache版不用配置kv ** 327 | 328 | PHP 部署: 329 | 把PHP脚本复制到PHP(CURL)环境服务器,设置伪静态规则 330 | ``` 331 | 332 | ### 通用调用方式 333 | ``` 334 | https://deployurl/?url=https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8 335 | 或 336 | https://deployurl/m3u8filter/https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8 337 | ``` 338 | 339 | ### 环境特定配置 340 | 341 | #### PHP 环境 342 | ``` 343 | m3u8filter.php?url=m3u8link 344 | 或 345 | m3u8filter.php/m3u8filter/m3u8link 346 | 或设置伪静态后 347 | m3u8filter/m3u8link 348 | ``` 349 | 350 | **PHP伪静态规则配置**: 351 | 352 | **Nginx**: 353 | ```nginx 354 | rewrite ^/m3u8filter/(https?):/(.*)$ /m3u8filter.php?url=$1://$2 last; 355 | ``` 356 | 357 | **Apache**: 358 | ```apache 359 | 360 | RewriteEngine On 361 | RewriteRule ^m3u8filter/(https?):/(.*)$ m3u8filter.php?url=$1://$2 [L,QSA] 362 | 363 | ``` 364 | 365 | ## 技术优化 366 | 367 | ### 性能优化 368 | - 基于哈希的缓存键,减少缓存冲突 369 | - 请求超时处理,避免服务卡死 370 | - 流处理改进,减少内存占用 371 | 372 | ### 功能增强 373 | - 智能广告检测和过滤,标记可能的广告片段 374 | - 增强对各种 HLS 标签的支持(如 EXT-X-BYTERANGE) 375 | - CORS 标头设置完善 376 | - 安全头添加,提高安全性 377 | - 支持保留原始响应头的关键信息 378 | 379 | ## 架构设计 380 | 381 | ### 模块化设计 382 | - 每个函数负责单一功能 383 | - 详细的错误处理和日志记录 384 | - 配置项分组和详细说明 385 | 386 | ### 错误处理 387 | - 统一的错误响应创建 388 | - 详细的错误信息反馈 389 | - 全局异常捕获,防止服务中断 390 | 391 | ### 日志与调试 392 | - 可配置日志开关(debug) 393 | - 请求和响应的详细日志 394 | - 时间戳和结构化日志输出 395 | 396 | ## 配置参数 397 | 398 | ### 通用配置项 399 | | 参数名 | 类型 | 默认值 | 描述 | 400 | |--------|------|--------|------| 401 | | `url` | string | 必填 | 要处理的 M3U8 文件 URL | 402 | | `PROXY_URL` | string | 可选 | 主代理服务器地址 | 403 | | `PROXY_TS` | string | 可选 | TS视频流代理服务器地址 | 404 | | `FILTER_DISCONTINUITY` | boolean | `true` | 是否过滤 discontinuity 标记 | 405 | | `CACHE_TTL` | number | `3600` | 缓存时间(秒) | 406 | | `MAX_RECURSION` | number | `3` | 最大重定向深度 | 407 | 408 | ### 脚本环境特定配置 409 | - **Cloudflare Worker**:需配置 `M3U8_PROXY_KV` KV 存储绑定 410 | - **PHP**:需配置可写的缓存目录 411 | - **Deno**:内存级缓存,可直接部署使用 412 | 413 | ## 开发指南 414 | 415 | ### 构建与部署 416 | 417 | #### Cloudflare Worker 418 | worker.js 419 | 1. 创建新的 Worker 项目 420 | 2. 绑定 KV 命名空间(名称为 `M3U8_PROXY_KV`) 421 | 3. 部署脚本 422 | 423 | worker-cache.js 424 | 1. 创建新的 Worker 项目 425 | 2. 复制代码 426 | 3. 部署脚本 427 | 428 | #### 如何将worker部署到cf pages? 429 | 第一种:fork该项目,修改你要部署的cf worker脚本名为`_worker.js`,在cfpage中导入fork的仓库,如果是kv缓存还需要配置kv变量,如果是cache版则直接部署。 430 | 431 | 第二种:下载cf worker脚本,重命名为_worker.js,并打包成_worker.js.zip 432 | 在 Cloudflare Pages 控制台中选择 上传资产后,为你的项目取名后点击 创建项目,然后上传你压缩好的 _worker.js.zip 文件后点击 部署站点。 433 | 部署完成后点击 继续处理站点 后,选择 设置 > 环境变量 > 制作为生产环境定义变量 > 添加KV变量(如果不是cf kv版可不用设置),点击保存。 434 | 返回 部署 选项卡,在右下角点击 创建新部署 后,重新上传 _worker.js.zip 文件后点击 保存并部署 即可。 435 | 436 | #### Deno 437 | fork该项目,在deno控制面板导入fork的项目,安装和部署命令参考下方代码块, 438 | 选择边缘节点自带cache api版(deno-cache.js)和内存缓存版(deno.js)都可以,Entrypoint配置项填写deno-cache.js或deno.ts,部署后返回Github action打开deploy进行授权 439 | ```bash 440 | 安装 441 | deno install -gArf jsr:@deno/deployctl 442 | 部署 443 | deployctl deploy 444 | ``` 445 | 446 | #### PHP 447 | 1. 上传 `m3u8filter.php` 到 web 服务器 448 | 2. 配置伪静态规则(可选) 449 | 3. 确保缓存目录可写 450 | 451 | ## 安全注意事项 452 | 453 | 1. 建议部署后启用 HTTPS 454 | 2. 定期更新 455 | 3. 监控异常请求 456 | 4. 对速率进行限制 457 | 458 | ## 故障排除 459 | 460 | ### 常见问题 461 | 1. **播放失败**: 462 | - 检查原始 URL 是否可访问 463 | - 验证代理服务器配置 464 | - 检查缓存是否过期 465 | - 检查脚本处理逻辑 466 | 467 | 2. **广告过滤不生效**: 468 | - 确认广告标记检测规则 469 | - 检查 M3U8 文件结构是否有变化 470 | 471 | 3. **性能问题**: 472 | - 调整缓存 TTL 473 | - 检查代理服务器性能 474 | - 优化网络连接 475 | 476 | ## 未来计划 477 | 478 | 1. 将m3u8广告过滤通过正则表达式匹配,达到动态过滤效果 479 | 480 | ## 贡献指南 481 | 482 | 欢迎通过 Issue 和 Pull Request 贡献代码。提交前请确保: 483 | 1. 代码符合项目风格 484 | 2. 通过基本测试 485 | 3. 更新相关文档 486 | 487 | ## 许可证 488 | 489 | 本项目采用 Apache-2.0 许可证开源。 490 | -------------------------------------------------------------------------------- /deno-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * M3U8 Proxy and Filter Script with Advanced HLS Support 3 | * 4 | * Features: 5 | * 1. Proxies M3U8 files and rewrites TS/fMP4 segment URLs 6 | * 2. Supports EXT-X-MAP initialization segments 7 | * 3. Handles encrypted streams (EXT-X-KEY) 8 | * 4. Filters discontinuity markers 9 | * 5. Uses Cache API for caching 10 | * 6. Auto-resolves master playlists recursively 11 | * 7. Detects non-M3U8 content and handles appropriately: 12 | * - Media files are proxied through the TS proxy 13 | * - Other content is redirected to original URL 14 | */ 15 | 16 | import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; 17 | 18 | // Configuration 19 | const CONFIG = { 20 | PORT: 8000, 21 | 22 | PROXY_URL: Deno.env.get("PROXY_URL") || '', 23 | // 如果环境变量未设置,默认 false;否则解析 "true"/"false" 24 | PROXY_URLENCODE: Deno.env.get("PROXY_URLENCODE")?.toLowerCase() === "true" || false, 25 | PROXY_TS: Deno.env.get("PROXY_TS") || '', 26 | // 如果环境变量未设置,默认 false;否则解析 "true"/"false" 27 | PROXY_TS_URLENCODE: Deno.env.get("PROXY_TS_URLENCODE")?.toLowerCase() === "true" || false, 28 | 29 | CACHE_TTL: 86400, // Cache TTL in seconds (24 hours) 30 | CACHE_NAME: 'm3u8-proxy-cache', // Cache storage name 31 | 32 | MAX_RECURSION: 50, // Max recursion for nested playlists 33 | FILTER_ADS_INTELLIGENTLY: true, // 是否开启智能过滤 34 | FILTER_REGEX: null, 35 | 36 | USER_AGENTS: [ 37 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 38 | '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' 39 | ], 40 | 41 | DEBUG: true // Enable debug logging 42 | }; 43 | 44 | // Media file extensions to check 45 | const MEDIA_FILE_EXTENSIONS = [ 46 | // Video formats 47 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 48 | // Audio formats 49 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 50 | // Image formats 51 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 52 | ]; 53 | 54 | // Media content types to check 55 | const MEDIA_CONTENT_TYPES = [ 56 | // Video types 57 | 'video/', 58 | // Audio types 59 | 'audio/', 60 | // Image types 61 | 'image/' 62 | ]; 63 | 64 | /** 65 | * Fetch with cache support 66 | */ 67 | async function fetchWithCache(url: string, options?: RequestInit): Promise { 68 | const cache = await caches.open(CONFIG.CACHE_NAME); 69 | const req = new Request(url); 70 | 71 | // Try to get from cache first 72 | const cached = await cache.match(req); 73 | if (cached) { 74 | if (CONFIG.DEBUG) console.log("[Cache hit]", url); 75 | return cached; 76 | } 77 | 78 | // Fetch fresh content 79 | const res = await fetch(url, options); 80 | 81 | // Clone the response to store in cache 82 | const resToCache = res.clone(); 83 | 84 | // Only cache successful responses 85 | if (res.status === 200) { 86 | // Create new headers with cache control 87 | const headers = new Headers(resToCache.headers); 88 | headers.set("Cache-Control", `public, max-age=${CONFIG.CACHE_TTL}`); 89 | 90 | // Create new response with cache headers 91 | const cachedResponse = new Response(resToCache.body, { 92 | status: resToCache.status, 93 | statusText: resToCache.statusText, 94 | headers 95 | }); 96 | 97 | await cache.put(req, cachedResponse); 98 | } 99 | 100 | return res; 101 | } 102 | 103 | /** 104 | * Main request handler 105 | */ 106 | async function handleRequest(request: Request): Promise { 107 | const url = new URL(request.url); 108 | 109 | try { 110 | // Extract target URL 111 | const targetUrl = getTargetUrl(url); 112 | if (!targetUrl) { 113 | return createResponse( 114 | "Please provide an M3U8 URL via the 'url' parameter or /m3u8filter/URL path", 115 | 400, 116 | { "Content-Type": "text/plain" } 117 | ); 118 | } 119 | 120 | // Check cache 121 | const cache = await caches.open(CONFIG.CACHE_NAME); 122 | const cacheKey = new Request(targetUrl); 123 | const cachedResponse = await cache.match(cacheKey); 124 | 125 | if (cachedResponse) { 126 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${targetUrl}`); 127 | const cachedContent = await cachedResponse.text(); 128 | return createM3u8Response(cachedContent); 129 | } 130 | 131 | // Process the M3U8 URL 132 | if (CONFIG.DEBUG) console.log(`[Processing] ${targetUrl}`); 133 | 134 | // Fetch and validate content 135 | const { content, contentType } = await fetchContentWithType(targetUrl); 136 | 137 | // Check if content is actually an M3U8 file 138 | if (!isM3u8Content(content, contentType)) { 139 | // Not an M3U8 file, check if it's a media file 140 | if (isMediaFile(targetUrl, contentType)) { 141 | if (CONFIG.DEBUG) console.log(`[Media file detected] Redirecting to TS proxy: ${targetUrl}`); 142 | return Response.redirect(proxyTsUrl(targetUrl), 302); 143 | } else { 144 | // Not a media file, redirect to original URL 145 | if (CONFIG.DEBUG) console.log(`[Not media content] Redirecting to original URL: ${targetUrl}`); 146 | return Response.redirect(targetUrl, 302); 147 | } 148 | } 149 | 150 | // Process the M3U8 content 151 | let processed = await processM3u8Content(targetUrl, content); 152 | //是否智能过滤广告 153 | if (CONFIG.FILTER_ADS_INTELLIGENTLY) { 154 | processed = SuperFilterAdsFromM3U8(processed, CONFIG.FILTER_REGEX); 155 | } 156 | 157 | // Cache the result 158 | const responseToCache = createM3u8Response(processed); 159 | await cache.put(cacheKey, responseToCache.clone()); 160 | 161 | return responseToCache; 162 | 163 | } catch (error) { 164 | console.error(`[Error] ${error.message}`); 165 | return createResponse( 166 | `Error processing request: ${error.message}`, 167 | 500, 168 | { "Content-Type": "text/plain" } 169 | ); 170 | } 171 | } 172 | 173 | 174 | /** 175 | * 超级M3U8广告算法过滤器 176 | * @param {string} m3u8Content - 原始M3U8内容 177 | * @param {string|null} regexFilter - 可选的正则过滤规则 178 | * @return {string} 过滤后的完整M3U8内容 179 | */ 180 | function SuperFilterAdsFromM3U8(m3u8Content, regexFilter = null) { 181 | if (!m3u8Content) return ''; 182 | 183 | // ==================== 第一阶段:预处理 ==================== 184 | // 1. 正则过滤 185 | let processedContent = regexFilter 186 | ? applyRegexFilter(m3u8Content, regexFilter) 187 | : m3u8Content; 188 | 189 | // 2. 解析M3U8结构 190 | const { segments, headers } = parseM3U8Structure(processedContent); 191 | if (segments.length === 0) return processedContent; 192 | 193 | // ==================== 第二阶段:科学分析 ==================== 194 | // 1. 计算基础统计量 195 | const stats = calculateSegmentStats(segments); 196 | 197 | // 2. 多维度广告检测 198 | const analyzedSegments = analyzeSegments(segments, stats); 199 | 200 | // 3. 智能过滤决策 201 | const filteredSegments = applyFilterDecision(analyzedSegments, stats); 202 | 203 | // ==================== 第三阶段:重建M3U8 ==================== 204 | return rebuildM3U8(headers, filteredSegments, processedContent); 205 | } 206 | 207 | // ==================== 辅助函数 ==================== 208 | 209 | /** 210 | * 应用正则过滤 211 | */ 212 | function applyRegexFilter(content, regexFilter) { 213 | try { 214 | const regex = new RegExp(regexFilter, 'gi'); 215 | return content.replace(regex, ''); 216 | } catch (e) { 217 | console.warn('正则过滤失败:', e); 218 | return content; 219 | } 220 | } 221 | 222 | /** 223 | * 深度解析M3U8结构 224 | */ 225 | function parseM3U8Structure(content) { 226 | const lines = content.split('\n'); 227 | const segments = []; 228 | const headers = { 229 | main: [], 230 | other: [] 231 | }; 232 | let currentDiscontinuity = false; 233 | let currentMap = null; 234 | let segmentIndex = 0; 235 | 236 | for (let i = 0; i < lines.length; i++) { 237 | const line = lines[i].trim(); 238 | 239 | // 收集头部信息 240 | if (i < 10 && line.startsWith('#EXT')) { 241 | headers.main.push(line); 242 | continue; 243 | } 244 | 245 | // 处理关键标签 246 | if (line.startsWith('#EXT-X-MAP:')) { 247 | currentMap = line; 248 | continue; 249 | } 250 | 251 | if (line.includes('#EXT-X-DISCONTINUITY')) { 252 | currentDiscontinuity = true; 253 | continue; 254 | } 255 | 256 | // 解析片段 257 | if (line.startsWith('#EXTINF:')) { 258 | const durationMatch = line.match(/#EXTINF:([\d.]+)/); 259 | if (durationMatch && lines[i + 1] && !lines[i + 1].startsWith('#')) { 260 | const duration = parseFloat(durationMatch[1]); 261 | const url = lines[i + 1].trim(); 262 | 263 | segments.push({ 264 | index: segmentIndex++, 265 | startLine: i, 266 | endLine: i + 1, 267 | duration, 268 | url, 269 | hasDiscontinuity: currentDiscontinuity, 270 | hasMap: currentMap !== null, 271 | content: currentMap 272 | ? [currentMap, line, lines[i + 1]].join('\n') 273 | : [line, lines[i + 1]].join('\n'), 274 | isAd: false, // 初始标记 275 | adScore: 0 // 广告概率得分 276 | }); 277 | 278 | currentDiscontinuity = false; 279 | currentMap = null; 280 | i++; // 跳过URL行 281 | } 282 | } else if (line.startsWith('#')) { 283 | headers.other.push(line); 284 | } 285 | } 286 | 287 | return { segments, headers }; 288 | } 289 | 290 | /** 291 | * 计算高级统计量 292 | */ 293 | function calculateSegmentStats(segments) { 294 | const durations = segments.map(s => s.duration); 295 | const totalDuration = durations.reduce((sum, d) => sum + d, 0); 296 | const avgDuration = totalDuration / durations.length; 297 | 298 | // 计算标准差和百分位数 299 | const squaredDiffs = durations.map(d => Math.pow(d - avgDuration, 2)); 300 | const stdDev = Math.sqrt(squaredDiffs.reduce((sum, sd) => sum + sd, 0) / durations.length); 301 | 302 | // 排序后的时长数组用于百分位计算 303 | const sortedDurations = [...durations].sort((a, b) => a - b); 304 | const p10 = sortedDurations[Math.floor(durations.length * 0.1)]; 305 | const p90 = sortedDurations[Math.floor(durations.length * 0.9)]; 306 | 307 | return { 308 | avgDuration, 309 | stdDev, 310 | p10, 311 | p90, 312 | totalDuration, 313 | segmentCount: segments.length, 314 | durationRange: [sortedDurations[0], sortedDurations[sortedDurations.length - 1]] 315 | }; 316 | } 317 | 318 | /** 319 | * 多维度片段分析 320 | */ 321 | function analyzeSegments(segments, stats) { 322 | const { avgDuration, stdDev, p10, p90 } = stats; 323 | 324 | return segments.map(segment => { 325 | const deviation = Math.abs(segment.duration - avgDuration); 326 | const zScore = deviation / stdDev; 327 | 328 | // 1. 时长异常检测 329 | const durationAbnormality = Math.min(1, zScore / 3); // 0-1范围 330 | 331 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 332 | let positionFactor = 0; 333 | if (segment.index < 3 && segment.duration < p10) { 334 | positionFactor = 0.8; // 开头的短片段很可疑 335 | } else if (segment.index > segments.length - 3 && segment.duration < p10) { 336 | positionFactor = 0.5; // 结尾的短片段中等可疑 337 | } 338 | 339 | // 3. 不连续标记检测 340 | const discontinuityFactor = segment.hasDiscontinuity ? 0.3 : 0; 341 | 342 | // 综合广告概率 343 | const adScore = Math.min(1, 344 | (durationAbnormality * 0.6) + 345 | (positionFactor * 0.3) + 346 | (discontinuityFactor * 0.1) 347 | ); 348 | 349 | return { 350 | ...segment, 351 | adScore, 352 | isAd: adScore > 0.65, // 阈值可调整 353 | stats: { deviation, zScore } 354 | }; 355 | }); 356 | } 357 | 358 | /** 359 | * 智能过滤决策 360 | */ 361 | function applyFilterDecision(segments, stats) { 362 | const { avgDuration, stdDev } = stats; 363 | 364 | // 动态调整阈值 365 | const baseThreshold = 0.65; 366 | const dynamicThreshold = Math.min(0.8, Math.max(0.5, 367 | baseThreshold - (stdDev / avgDuration) * 0.2 368 | )); 369 | 370 | return segments.filter(segment => { 371 | // 明确广告标记 372 | if (segment.isAd && segment.adScore > dynamicThreshold) { 373 | return false; 374 | } 375 | 376 | // 极短片段过滤(<1秒且不在开头) 377 | if (segment.duration < 1.0 && segment.index > 3) { 378 | return false; 379 | } 380 | 381 | // 保留关键片段(如包含MAP的) 382 | if (segment.hasMap) { 383 | return true; 384 | } 385 | 386 | // 默认保留 387 | return true; 388 | }); 389 | } 390 | 391 | /** 392 | * 完美重建M3U8 393 | */ 394 | function rebuildM3U8(headers, segments, originalContent) { 395 | // 收集需要保留的行号 396 | const keepLines = new Set(); 397 | 398 | // 保留所有头部信息 399 | headers.main.forEach((_, i) => keepLines.add(i)); 400 | 401 | // 保留所有片段内容 402 | segments.forEach(segment => { 403 | for (let i = segment.startLine; i <= segment.endLine; i++) { 404 | keepLines.add(i); 405 | } 406 | }); 407 | 408 | // 处理其他关键标签 409 | const lines = originalContent.split('\n'); 410 | const criticalTags = [ 411 | '#EXT-X-VERSION', 412 | '#EXT-X-TARGETDURATION', 413 | '#EXT-X-MEDIA-SEQUENCE', 414 | '#EXT-X-PLAYLIST-TYPE', 415 | '#EXT-X-ENDLIST' 416 | ]; 417 | 418 | for (let i = 0; i < lines.length; i++) { 419 | const line = lines[i].trim(); 420 | if (criticalTags.some(tag => line.startsWith(tag))) { 421 | keepLines.add(i); 422 | } 423 | } 424 | 425 | // 重建内容 426 | const filteredLines = lines.filter((_, i) => keepLines.has(i)); 427 | 428 | // 更新关键头部信息 429 | updateM3U8Headers(filteredLines, segments); 430 | 431 | return filteredLines.join('\n'); 432 | } 433 | 434 | /** 435 | * 更新M3U8头部信息 436 | */ 437 | function updateM3U8Headers(lines, segments) { 438 | if (segments.length === 0) return; 439 | 440 | // 更新TARGETDURATION 441 | const maxDuration = Math.max(...segments.map(s => s.duration)); 442 | for (let i = 0; i < lines.length; i++) { 443 | if (lines[i].startsWith('#EXT-X-TARGETDURATION')) { 444 | lines[i] = `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`; 445 | break; 446 | } 447 | } 448 | 449 | // 更新MEDIA-SEQUENCE 450 | if (segments[0].index > 0) { 451 | for (let i = 0; i < lines.length; i++) { 452 | if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE')) { 453 | lines[i] = `#EXT-X-MEDIA-SEQUENCE:${segments[0].index}`; 454 | break; 455 | } 456 | } 457 | } 458 | } 459 | 460 | 461 | /** 462 | * Check if content is a valid M3U8 file 463 | */ 464 | function isM3u8Content(content: string, contentType: string): boolean { 465 | // Check content type header 466 | if (contentType && ( 467 | contentType.includes('application/vnd.apple.mpegurl') || 468 | contentType.includes('application/x-mpegurl'))) { 469 | return true; 470 | } 471 | 472 | // Check content for M3U8 signature 473 | if (content && content.trim().startsWith('#EXTM3U')) { 474 | return true; 475 | } 476 | 477 | return false; 478 | } 479 | 480 | /** 481 | * Check if the file is a media file based on extension and content type 482 | */ 483 | function isMediaFile(url: string, contentType: string): boolean { 484 | // Check by content type 485 | if (contentType) { 486 | for (const mediaType of MEDIA_CONTENT_TYPES) { 487 | if (contentType.toLowerCase().startsWith(mediaType)) { 488 | return true; 489 | } 490 | } 491 | } 492 | 493 | // Check by file extension 494 | const urlLower = url.toLowerCase(); 495 | for (const ext of MEDIA_FILE_EXTENSIONS) { 496 | // Check if URL ends with the extension or has it followed by a query parameter 497 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 498 | return true; 499 | } 500 | } 501 | 502 | return false; 503 | } 504 | 505 | /** 506 | * Extract target URL from request 507 | */ 508 | function getTargetUrl(url: URL): string | null { 509 | // Check query parameter 510 | if (url.searchParams.has('url')) { 511 | return url.searchParams.get('url')!; 512 | } 513 | 514 | // Check path format: /m3u8filter/URL 515 | const pathMatch = url.pathname.match(/^\/m3u8filter\/(.+)/); 516 | if (pathMatch && pathMatch[1]) { 517 | return decodeURIComponent(pathMatch[1]); 518 | } 519 | 520 | return null; 521 | } 522 | 523 | /** 524 | * Create a standardized response 525 | */ 526 | function createResponse(body: string, status = 200, headers: Record = {}): Response { 527 | const responseHeaders = new Headers(headers); 528 | responseHeaders.set("Access-Control-Allow-Origin", "*"); 529 | 530 | return new Response(body, { 531 | status, 532 | headers: responseHeaders 533 | }); 534 | } 535 | 536 | /** 537 | * Create an M3U8 response with proper headers 538 | */ 539 | function createM3u8Response(content: string): Response { 540 | return createResponse(content, 200, { 541 | "Content-Type": "application/vnd.apple.mpegurl", 542 | "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL}` 543 | }); 544 | } 545 | 546 | /** 547 | * Fetch content with content type information 548 | */ 549 | async function fetchContentWithType(url: string): Promise<{ content: string; contentType: string }> { 550 | const headers = new Headers({ 551 | 'User-Agent': getRandomUserAgent(), 552 | 'Accept': '*/*', 553 | 'Referer': new URL(url).origin 554 | }); 555 | 556 | let fetchUrl = url; 557 | if (CONFIG.PROXY_URL) { 558 | fetchUrl = CONFIG.PROXY_URLENCODE 559 | ? `${CONFIG.PROXY_URL}${encodeURIComponent(url)}` 560 | : `${CONFIG.PROXY_URL}${url}`; 561 | } 562 | 563 | try { 564 | const response = await fetchWithCache(fetchUrl, { headers }); 565 | if (!response.ok) { 566 | throw new Error(`HTTP error ${response.status}: ${response.statusText}`); 567 | } 568 | 569 | const content = await response.text(); 570 | const contentType = response.headers.get('Content-Type') || ''; 571 | 572 | return { content, contentType }; 573 | } catch (error) { 574 | throw new Error(`Failed to fetch ${url}: ${error.message}`); 575 | } 576 | } 577 | 578 | /** 579 | * Process M3U8 content from the initial URL 580 | */ 581 | async function processM3u8Content(url: string, content: string, recursionDepth = 0): Promise { 582 | // Check if this is a master playlist 583 | if (content.includes('#EXT-X-STREAM-INF')) { 584 | if (CONFIG.DEBUG) console.log(`[Master playlist detected] ${url}`); 585 | return await processMasterPlaylist(url, content, recursionDepth); 586 | } 587 | 588 | // Process as a media playlist 589 | if (CONFIG.DEBUG) console.log(`[Media playlist] ${url}`); 590 | return processMediaPlaylist(url, content); 591 | } 592 | 593 | /** 594 | * Process a master playlist by selecting the first variant stream 595 | */ 596 | async function processMasterPlaylist(url: string, content: string, recursionDepth: number): Promise { 597 | if (recursionDepth > CONFIG.MAX_RECURSION) { 598 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 599 | } 600 | 601 | const baseUrl = getBaseUrl(url); 602 | const lines = content.split('\n'); 603 | 604 | let variantUrl = ''; 605 | 606 | // Find the first variant stream URL 607 | for (let i = 0; i < lines.length; i++) { 608 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 609 | // The next non-comment line should be the variant URL 610 | for (let j = i + 1; j < lines.length; j++) { 611 | const line = lines[j].trim(); 612 | if (line && !line.startsWith('#')) { 613 | variantUrl = resolveUrl(baseUrl, line); 614 | break; 615 | } 616 | } 617 | if (variantUrl) break; 618 | } 619 | } 620 | 621 | if (!variantUrl) { 622 | throw new Error('No variant stream found in master playlist'); 623 | } 624 | 625 | // Recursively process the variant stream 626 | if (CONFIG.DEBUG) console.log(`[Selected variant] ${variantUrl}`); 627 | const { content: variantContent } = await fetchContentWithType(variantUrl); 628 | return await processM3u8Content(variantUrl, variantContent, recursionDepth + 1); 629 | } 630 | 631 | /** 632 | * Process a media playlist by rewriting segment URLs 633 | */ 634 | function processMediaPlaylist(url: string, content: string): string { 635 | const baseUrl = getBaseUrl(url); 636 | const lines = content.split('\n'); 637 | const output: string[] = []; 638 | 639 | let isNextLineSegment = false; 640 | 641 | for (let i = 0; i < lines.length; i++) { 642 | const line = lines[i].trim(); 643 | 644 | // Skip empty lines 645 | if (!line) continue; 646 | 647 | // Handle EXT-X-KEY (encryption) 648 | if (line.startsWith('#EXT-X-KEY')) { 649 | output.push(processKeyLine(line, baseUrl)); 650 | continue; 651 | } 652 | 653 | // Handle EXT-X-MAP (initialization segment) 654 | if (line.startsWith('#EXT-X-MAP')) { 655 | output.push(processMapLine(line, baseUrl)); 656 | continue; 657 | } 658 | 659 | // Mark segment lines 660 | if (line.startsWith('#EXTINF')) { 661 | isNextLineSegment = true; 662 | output.push(line); 663 | continue; 664 | } 665 | 666 | // Process segment URLs 667 | if (isNextLineSegment && !line.startsWith('#')) { 668 | const absoluteUrl = resolveUrl(baseUrl, line); 669 | output.push(proxyTsUrl(absoluteUrl)); 670 | isNextLineSegment = false; 671 | continue; 672 | } 673 | 674 | // Pass through all other lines 675 | output.push(line); 676 | } 677 | 678 | return output.join('\n'); 679 | } 680 | 681 | /** 682 | * Process EXT-X-KEY line by proxying the key URL 683 | */ 684 | function processKeyLine(line: string, baseUrl: string): string { 685 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 686 | const absoluteUri = resolveUrl(baseUrl, uri); 687 | return `URI="${proxyTsUrl(absoluteUri)}"`; 688 | }); 689 | } 690 | 691 | /** 692 | * Process EXT-X-MAP line by proxying the map URL 693 | */ 694 | function processMapLine(line: string, baseUrl: string): string { 695 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 696 | const absoluteUri = resolveUrl(baseUrl, uri); 697 | return `URI="${proxyTsUrl(absoluteUri)}"`; 698 | }); 699 | } 700 | 701 | /** 702 | * Apply TS proxy to a URL 703 | */ 704 | function proxyTsUrl(url: string): string { 705 | if (!CONFIG.PROXY_TS) return url; 706 | 707 | return CONFIG.PROXY_TS_URLENCODE 708 | ? `${CONFIG.PROXY_TS}${encodeURIComponent(url)}` 709 | : `${CONFIG.PROXY_TS}${url}`; 710 | } 711 | 712 | /** 713 | * Get a random user agent from the configured list 714 | */ 715 | function getRandomUserAgent(): string { 716 | return CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 717 | } 718 | 719 | /** 720 | * Extract the base URL from a full URL 721 | */ 722 | function getBaseUrl(url: string): string { 723 | try { 724 | const parsedUrl = new URL(url); 725 | const pathParts = parsedUrl.pathname.split('/'); 726 | pathParts.pop(); // Remove the last part (filename) 727 | 728 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 729 | } catch (e) { 730 | // Fallback: find the last slash 731 | const lastSlashIndex = url.lastIndexOf('/'); 732 | return lastSlashIndex > 8 ? url.substring(0, lastSlashIndex + 1) : url; 733 | } 734 | } 735 | 736 | /** 737 | * Resolve a relative URL against a base URL 738 | */ 739 | function resolveUrl(baseUrl: string, relativeUrl: string): string { 740 | // Already absolute URL 741 | if (relativeUrl.match(/^https?:\/\//i)) { 742 | return relativeUrl; 743 | } 744 | 745 | try { 746 | return new URL(relativeUrl, baseUrl).toString(); 747 | } catch (e) { 748 | // Simple fallback 749 | if (relativeUrl.startsWith('/')) { 750 | const urlObj = new URL(baseUrl); 751 | return `${urlObj.origin}${relativeUrl}`; 752 | } 753 | return `${baseUrl}${relativeUrl}`; 754 | } 755 | } 756 | 757 | // Start the server 758 | console.log(`Starting M3U8 Filter server on port ${CONFIG.PORT}`); 759 | serve(handleRequest, { port: CONFIG.PORT }); 760 | -------------------------------------------------------------------------------- /deno.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * M3U8 Proxy and Filter Script with Advanced HLS Support 3 | * 4 | * Features: 5 | * 1. Proxies M3U8 files and rewrites TS/fMP4 segment URLs 6 | * 2. Supports EXT-X-MAP initialization segments 7 | * 3. Handles encrypted streams (EXT-X-KEY) 8 | * 4. Filters discontinuity markers 9 | * 5. Supports caching 10 | * 6. Auto-resolves master playlists recursively 11 | * 7. Detects non-M3U8 content and handles appropriately: 12 | * - Media files are proxied through the TS proxy 13 | * - Other content is redirected to original URL 14 | */ 15 | 16 | import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; 17 | 18 | // Configuration 19 | const CONFIG = { 20 | PORT: 8000, 21 | 22 | PROXY_URL: Deno.env.get("PROXY_URL") || '', 23 | // 如果环境变量未设置,默认 false;否则解析 "true"/"false" 24 | PROXY_URLENCODE: Deno.env.get("PROXY_URLENCODE")?.toLowerCase() === "true" || false, 25 | 26 | PROXY_TS: Deno.env.get("PROXY_TS") || '', 27 | // 如果环境变量未设置,默认 false;否则解析 "true"/"false" 28 | PROXY_TS_URLENCODE: Deno.env.get("PROXY_TS_URLENCODE")?.toLowerCase() === "true" || false, 29 | 30 | CACHE_ENABLED: true, 31 | CACHE_TTL: 3600, // Cache TTL in seconds 32 | 33 | MAX_RECURSION: 30, // Max recursion for nested playlists 34 | 35 | FILTER_ADS_INTELLIGENTLY: true, // 启用智能过滤 36 | FILTER_REGEX: null, // 可选的正则过滤规则,例如: 'ad\.com|adsegment' 37 | 38 | USER_AGENTS: [ 39 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 40 | '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' 41 | ], 42 | 43 | DEBUG: true // Enable debug logging 44 | }; 45 | 46 | // Media file extensions to check 47 | const MEDIA_FILE_EXTENSIONS = [ 48 | // Video formats 49 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 50 | // Audio formats 51 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 52 | // Image formats 53 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 54 | ]; 55 | 56 | // Media content types to check 57 | const MEDIA_CONTENT_TYPES = [ 58 | // Video types 59 | 'video/', 60 | // Audio types 61 | 'audio/', 62 | // Image types 63 | 'image/' 64 | ]; 65 | 66 | // Simple in-memory cache implementation 67 | class SimpleCache { 68 | private cache: Map; 69 | 70 | constructor(private ttl: number = 3600) { 71 | this.cache = new Map(); 72 | } 73 | 74 | set(key: string, value: string): void { 75 | this.cache.set(key, { 76 | value, 77 | expires: Date.now() + this.ttl * 1000 78 | }); 79 | } 80 | 81 | get(key: string): string | null { 82 | const item = this.cache.get(key); 83 | if (!item) return null; 84 | 85 | if (Date.now() > item.expires) { 86 | this.cache.delete(key); 87 | return null; 88 | } 89 | 90 | return item.value; 91 | } 92 | 93 | // Periodically clean expired items 94 | cleanUp(): void { 95 | const now = Date.now(); 96 | for (const [key, item] of this.cache.entries()) { 97 | if (now > item.expires) { 98 | this.cache.delete(key); 99 | } 100 | } 101 | } 102 | } 103 | 104 | // Initialize cache 105 | const cache = new SimpleCache(CONFIG.CACHE_TTL); 106 | 107 | // Start a periodic cleanup 108 | if (CONFIG.CACHE_ENABLED) { 109 | setInterval(() => cache.cleanUp(), 60000); // Clean every minute 110 | } 111 | 112 | /** 113 | * Main request handler 114 | */ 115 | async function handleRequest(request: Request): Promise { 116 | const url = new URL(request.url); 117 | 118 | try { 119 | // Extract target URL 120 | const targetUrl = getTargetUrl(url); 121 | if (!targetUrl) { 122 | return createResponse( 123 | "Please provide an M3U8 URL via the 'url' parameter or /m3u8filter/URL path", 124 | 400, 125 | { "Content-Type": "text/plain" } 126 | ); 127 | } 128 | 129 | // Check cache 130 | const cacheKey = `m3u8:${targetUrl}`; 131 | if (CONFIG.CACHE_ENABLED) { 132 | const cachedContent = cache.get(cacheKey); 133 | if (cachedContent) { 134 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${targetUrl}`); 135 | return createM3u8Response(cachedContent); 136 | } 137 | } 138 | 139 | // Process the M3U8 URL 140 | if (CONFIG.DEBUG) console.log(`[Processing] ${targetUrl}`); 141 | 142 | // Fetch content with type information 143 | const { content, contentType } = await fetchContentWithType(targetUrl); 144 | 145 | // Check if content is actually an M3U8 file 146 | if (!isM3u8Content(content, contentType)) { 147 | // Not an M3U8 file, check if it's a media file 148 | if (isMediaFile(targetUrl, contentType)) { 149 | if (CONFIG.DEBUG) console.log(`[Media file detected] Redirecting to TS proxy: ${targetUrl}`); 150 | return Response.redirect(proxyTsUrl(targetUrl), 302); 151 | } else { 152 | // Not a media file, redirect to original URL 153 | if (CONFIG.DEBUG) console.log(`[Not media content] Redirecting to original URL: ${targetUrl}`); 154 | return Response.redirect(targetUrl, 302); 155 | } 156 | } 157 | 158 | // Process the M3U8 content 159 | let processed = await processM3u8Content(targetUrl, content); 160 | 161 | //是否智能过滤广告 162 | if (CONFIG.FILTER_ADS_INTELLIGENTLY) { 163 | processed = SuperFilterAdsFromM3U8(processed, CONFIG.FILTER_REGEX); 164 | } 165 | 166 | // Cache the result 167 | if (CONFIG.CACHE_ENABLED) { 168 | cache.set(cacheKey, processed); 169 | } 170 | 171 | return createM3u8Response(processed); 172 | 173 | } catch (error) { 174 | console.error(`[Error] ${error.message}`); 175 | return createResponse( 176 | `Error processing request: ${error.message}`, 177 | 500, 178 | { "Content-Type": "text/plain" } 179 | ); 180 | } 181 | } 182 | 183 | 184 | /** 185 | * 超级M3U8广告算法过滤器 186 | * @param {string} m3u8Content - 原始M3U8内容 187 | * @param {string|null} regexFilter - 可选的正则过滤规则 188 | * @return {string} 过滤后的完整M3U8内容 189 | */ 190 | function SuperFilterAdsFromM3U8(m3u8Content, regexFilter = null) { 191 | if (!m3u8Content) return ''; 192 | 193 | // ==================== 第一阶段:预处理 ==================== 194 | // 1. 正则过滤 195 | let processedContent = regexFilter 196 | ? applyRegexFilter(m3u8Content, regexFilter) 197 | : m3u8Content; 198 | 199 | // 2. 解析M3U8结构 200 | const { segments, headers } = parseM3U8Structure(processedContent); 201 | if (segments.length === 0) return processedContent; 202 | 203 | // ==================== 第二阶段:科学分析 ==================== 204 | // 1. 计算基础统计量 205 | const stats = calculateSegmentStats(segments); 206 | 207 | // 2. 多维度广告检测 208 | const analyzedSegments = analyzeSegments(segments, stats); 209 | 210 | // 3. 智能过滤决策 211 | const filteredSegments = applyFilterDecision(analyzedSegments, stats); 212 | 213 | // ==================== 第三阶段:重建M3U8 ==================== 214 | return rebuildM3U8(headers, filteredSegments, processedContent); 215 | } 216 | 217 | // ==================== 辅助函数 ==================== 218 | 219 | /** 220 | * 应用正则过滤 221 | */ 222 | function applyRegexFilter(content, regexFilter) { 223 | try { 224 | const regex = new RegExp(regexFilter, 'gi'); 225 | return content.replace(regex, ''); 226 | } catch (e) { 227 | console.warn('正则过滤失败:', e); 228 | return content; 229 | } 230 | } 231 | 232 | /** 233 | * 深度解析M3U8结构 234 | */ 235 | function parseM3U8Structure(content) { 236 | const lines = content.split('\n'); 237 | const segments = []; 238 | const headers = { 239 | main: [], 240 | other: [] 241 | }; 242 | let currentDiscontinuity = false; 243 | let currentMap = null; 244 | let segmentIndex = 0; 245 | 246 | for (let i = 0; i < lines.length; i++) { 247 | const line = lines[i].trim(); 248 | 249 | // 收集头部信息 250 | if (i < 10 && line.startsWith('#EXT')) { 251 | headers.main.push(line); 252 | continue; 253 | } 254 | 255 | // 处理关键标签 256 | if (line.startsWith('#EXT-X-MAP:')) { 257 | currentMap = line; 258 | continue; 259 | } 260 | 261 | if (line.includes('#EXT-X-DISCONTINUITY')) { 262 | currentDiscontinuity = true; 263 | continue; 264 | } 265 | 266 | // 解析片段 267 | if (line.startsWith('#EXTINF:')) { 268 | const durationMatch = line.match(/#EXTINF:([\d.]+)/); 269 | if (durationMatch && lines[i + 1] && !lines[i + 1].startsWith('#')) { 270 | const duration = parseFloat(durationMatch[1]); 271 | const url = lines[i + 1].trim(); 272 | 273 | segments.push({ 274 | index: segmentIndex++, 275 | startLine: i, 276 | endLine: i + 1, 277 | duration, 278 | url, 279 | hasDiscontinuity: currentDiscontinuity, 280 | hasMap: currentMap !== null, 281 | content: currentMap 282 | ? [currentMap, line, lines[i + 1]].join('\n') 283 | : [line, lines[i + 1]].join('\n'), 284 | isAd: false, // 初始标记 285 | adScore: 0 // 广告概率得分 286 | }); 287 | 288 | currentDiscontinuity = false; 289 | currentMap = null; 290 | i++; // 跳过URL行 291 | } 292 | } else if (line.startsWith('#')) { 293 | headers.other.push(line); 294 | } 295 | } 296 | 297 | return { segments, headers }; 298 | } 299 | 300 | /** 301 | * 计算高级统计量 302 | */ 303 | function calculateSegmentStats(segments) { 304 | const durations = segments.map(s => s.duration); 305 | const totalDuration = durations.reduce((sum, d) => sum + d, 0); 306 | const avgDuration = totalDuration / durations.length; 307 | 308 | // 计算标准差和百分位数 309 | const squaredDiffs = durations.map(d => Math.pow(d - avgDuration, 2)); 310 | const stdDev = Math.sqrt(squaredDiffs.reduce((sum, sd) => sum + sd, 0) / durations.length); 311 | 312 | // 排序后的时长数组用于百分位计算 313 | const sortedDurations = [...durations].sort((a, b) => a - b); 314 | const p10 = sortedDurations[Math.floor(durations.length * 0.1)]; 315 | const p90 = sortedDurations[Math.floor(durations.length * 0.9)]; 316 | 317 | return { 318 | avgDuration, 319 | stdDev, 320 | p10, 321 | p90, 322 | totalDuration, 323 | segmentCount: segments.length, 324 | durationRange: [sortedDurations[0], sortedDurations[sortedDurations.length - 1]] 325 | }; 326 | } 327 | 328 | /** 329 | * 多维度片段分析 330 | */ 331 | function analyzeSegments(segments, stats) { 332 | const { avgDuration, stdDev, p10, p90 } = stats; 333 | 334 | return segments.map(segment => { 335 | const deviation = Math.abs(segment.duration - avgDuration); 336 | const zScore = deviation / stdDev; 337 | 338 | // 1. 时长异常检测 339 | const durationAbnormality = Math.min(1, zScore / 3); // 0-1范围 340 | 341 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 342 | let positionFactor = 0; 343 | if (segment.index < 3 && segment.duration < p10) { 344 | positionFactor = 0.8; // 开头的短片段很可疑 345 | } else if (segment.index > segments.length - 3 && segment.duration < p10) { 346 | positionFactor = 0.5; // 结尾的短片段中等可疑 347 | } 348 | 349 | // 3. 不连续标记检测 350 | const discontinuityFactor = segment.hasDiscontinuity ? 0.3 : 0; 351 | 352 | // 综合广告概率 353 | const adScore = Math.min(1, 354 | (durationAbnormality * 0.6) + 355 | (positionFactor * 0.3) + 356 | (discontinuityFactor * 0.1) 357 | ); 358 | 359 | return { 360 | ...segment, 361 | adScore, 362 | isAd: adScore > 0.65, // 阈值可调整 363 | stats: { deviation, zScore } 364 | }; 365 | }); 366 | } 367 | 368 | /** 369 | * 智能过滤决策 370 | */ 371 | function applyFilterDecision(segments, stats) { 372 | const { avgDuration, stdDev } = stats; 373 | 374 | // 动态调整阈值 375 | const baseThreshold = 0.65; 376 | const dynamicThreshold = Math.min(0.8, Math.max(0.5, 377 | baseThreshold - (stdDev / avgDuration) * 0.2 378 | )); 379 | 380 | return segments.filter(segment => { 381 | // 明确广告标记 382 | if (segment.isAd && segment.adScore > dynamicThreshold) { 383 | return false; 384 | } 385 | 386 | // 极短片段过滤(<1秒且不在开头) 387 | if (segment.duration < 1.0 && segment.index > 3) { 388 | return false; 389 | } 390 | 391 | // 保留关键片段(如包含MAP的) 392 | if (segment.hasMap) { 393 | return true; 394 | } 395 | 396 | // 默认保留 397 | return true; 398 | }); 399 | } 400 | 401 | /** 402 | * 完美重建M3U8 403 | */ 404 | function rebuildM3U8(headers, segments, originalContent) { 405 | // 收集需要保留的行号 406 | const keepLines = new Set(); 407 | 408 | // 保留所有头部信息 409 | headers.main.forEach((_, i) => keepLines.add(i)); 410 | 411 | // 保留所有片段内容 412 | segments.forEach(segment => { 413 | for (let i = segment.startLine; i <= segment.endLine; i++) { 414 | keepLines.add(i); 415 | } 416 | }); 417 | 418 | // 处理其他关键标签 419 | const lines = originalContent.split('\n'); 420 | const criticalTags = [ 421 | '#EXT-X-VERSION', 422 | '#EXT-X-TARGETDURATION', 423 | '#EXT-X-MEDIA-SEQUENCE', 424 | '#EXT-X-PLAYLIST-TYPE', 425 | '#EXT-X-ENDLIST' 426 | ]; 427 | 428 | for (let i = 0; i < lines.length; i++) { 429 | const line = lines[i].trim(); 430 | if (criticalTags.some(tag => line.startsWith(tag))) { 431 | keepLines.add(i); 432 | } 433 | } 434 | 435 | // 重建内容 436 | const filteredLines = lines.filter((_, i) => keepLines.has(i)); 437 | 438 | // 更新关键头部信息 439 | updateM3U8Headers(filteredLines, segments); 440 | 441 | return filteredLines.join('\n'); 442 | } 443 | 444 | /** 445 | * 更新M3U8头部信息 446 | */ 447 | function updateM3U8Headers(lines, segments) { 448 | if (segments.length === 0) return; 449 | 450 | // 更新TARGETDURATION 451 | const maxDuration = Math.max(...segments.map(s => s.duration)); 452 | for (let i = 0; i < lines.length; i++) { 453 | if (lines[i].startsWith('#EXT-X-TARGETDURATION')) { 454 | lines[i] = `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`; 455 | break; 456 | } 457 | } 458 | 459 | // 更新MEDIA-SEQUENCE 460 | if (segments[0].index > 0) { 461 | for (let i = 0; i < lines.length; i++) { 462 | if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE')) { 463 | lines[i] = `#EXT-X-MEDIA-SEQUENCE:${segments[0].index}`; 464 | break; 465 | } 466 | } 467 | } 468 | } 469 | 470 | /** 471 | * Check if content is a valid M3U8 file 472 | */ 473 | function isM3u8Content(content: string, contentType: string): boolean { 474 | // Check content type header 475 | if (contentType && ( 476 | contentType.includes('application/vnd.apple.mpegurl') || 477 | contentType.includes('application/x-mpegurl'))) { 478 | return true; 479 | } 480 | 481 | // Check content for M3U8 signature 482 | if (content && content.trim().startsWith('#EXTM3U')) { 483 | return true; 484 | } 485 | 486 | return false; 487 | } 488 | 489 | /** 490 | * Check if the file is a media file based on extension and content type 491 | */ 492 | function isMediaFile(url: string, contentType: string): boolean { 493 | // Check by content type 494 | if (contentType) { 495 | for (const mediaType of MEDIA_CONTENT_TYPES) { 496 | if (contentType.toLowerCase().startsWith(mediaType)) { 497 | return true; 498 | } 499 | } 500 | } 501 | 502 | // Check by file extension 503 | const urlLower = url.toLowerCase(); 504 | for (const ext of MEDIA_FILE_EXTENSIONS) { 505 | // Check if URL ends with the extension or has it followed by a query parameter 506 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 507 | return true; 508 | } 509 | } 510 | 511 | return false; 512 | } 513 | 514 | /** 515 | * Extract target URL from request 516 | */ 517 | function getTargetUrl(url: URL): string | null { 518 | // Check query parameter 519 | if (url.searchParams.has('url')) { 520 | return url.searchParams.get('url')!; 521 | } 522 | 523 | // Check path format: /m3u8filter/URL 524 | const pathMatch = url.pathname.match(/^\/m3u8filter\/(.+)/); 525 | if (pathMatch && pathMatch[1]) { 526 | return decodeURIComponent(pathMatch[1]); 527 | } 528 | 529 | return null; 530 | } 531 | 532 | /** 533 | * Create a standardized response 534 | */ 535 | function createResponse(body: string, status = 200, headers: Record = {}): Response { 536 | const responseHeaders = new Headers(headers); 537 | responseHeaders.set("Access-Control-Allow-Origin", "*"); 538 | 539 | return new Response(body, { 540 | status, 541 | headers: responseHeaders 542 | }); 543 | } 544 | 545 | /** 546 | * Create an M3U8 response with proper headers 547 | */ 548 | function createM3u8Response(content: string): Response { 549 | return createResponse(content, 200, { 550 | "Content-Type": "application/vnd.apple.mpegurl", 551 | "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL}` 552 | }); 553 | } 554 | 555 | /** 556 | * Fetch content with content type information 557 | */ 558 | async function fetchContentWithType(url: string): Promise<{ content: string; contentType: string }> { 559 | const headers = new Headers({ 560 | 'User-Agent': getRandomUserAgent(), 561 | 'Accept': '*/*', 562 | 'Referer': new URL(url).origin 563 | }); 564 | 565 | let fetchUrl = url; 566 | if (CONFIG.PROXY_URL) { 567 | fetchUrl = CONFIG.PROXY_URLENCODE 568 | ? `${CONFIG.PROXY_URL}${encodeURIComponent(url)}` 569 | : `${CONFIG.PROXY_URL}${url}`; 570 | } 571 | 572 | try { 573 | const response = await fetch(fetchUrl, { headers }); 574 | if (!response.ok) { 575 | throw new Error(`HTTP error ${response.status}: ${response.statusText}`); 576 | } 577 | 578 | const content = await response.text(); 579 | const contentType = response.headers.get('Content-Type') || ''; 580 | 581 | return { content, contentType }; 582 | } catch (error) { 583 | throw new Error(`Failed to fetch ${url}: ${error.message}`); 584 | } 585 | } 586 | 587 | /** 588 | * Process M3U8 content from the initial URL 589 | */ 590 | async function processM3u8Content(url: string, content: string, recursionDepth = 0): Promise { 591 | // Check if this is a master playlist 592 | if (content.includes('#EXT-X-STREAM-INF')) { 593 | if (CONFIG.DEBUG) console.log(`[Master playlist detected] ${url}`); 594 | return await processMasterPlaylist(url, content, recursionDepth); 595 | } 596 | 597 | // Process as a media playlist 598 | if (CONFIG.DEBUG) console.log(`[Media playlist] ${url}`); 599 | return processMediaPlaylist(url, content); 600 | } 601 | 602 | /** 603 | * Process a master playlist by selecting the first variant stream 604 | */ 605 | async function processMasterPlaylist(url: string, content: string, recursionDepth: number): Promise { 606 | if (recursionDepth > CONFIG.MAX_RECURSION) { 607 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 608 | } 609 | 610 | const baseUrl = getBaseUrl(url); 611 | const lines = content.split('\n'); 612 | 613 | let variantUrl = ''; 614 | 615 | // Find the first variant stream URL 616 | for (let i = 0; i < lines.length; i++) { 617 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 618 | // The next non-comment line should be the variant URL 619 | for (let j = i + 1; j < lines.length; j++) { 620 | const line = lines[j].trim(); 621 | if (line && !line.startsWith('#')) { 622 | variantUrl = resolveUrl(baseUrl, line); 623 | break; 624 | } 625 | } 626 | if (variantUrl) break; 627 | } 628 | } 629 | 630 | if (!variantUrl) { 631 | throw new Error('No variant stream found in master playlist'); 632 | } 633 | 634 | // Recursively process the variant stream 635 | if (CONFIG.DEBUG) console.log(`[Selected variant] ${variantUrl}`); 636 | const { content: variantContent } = await fetchContentWithType(variantUrl); 637 | return await processM3u8Content(variantUrl, variantContent, recursionDepth + 1); 638 | } 639 | 640 | /** 641 | * Main M3U8 processing function (legacy method) 642 | */ 643 | async function processM3u8Url(url: string, recursionDepth = 0): Promise { 644 | if (recursionDepth > CONFIG.MAX_RECURSION) { 645 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 646 | } 647 | 648 | // Fetch the M3U8 content 649 | const { content, contentType } = await fetchContentWithType(url); 650 | 651 | // Check if content is actually an M3U8 file 652 | if (!isM3u8Content(content, contentType)) { 653 | throw new Error('Invalid M3U8 content'); 654 | } 655 | 656 | return processM3u8Content(url, content, recursionDepth); 657 | } 658 | 659 | /** 660 | * Process a media playlist by rewriting segment URLs 661 | */ 662 | function processMediaPlaylist(url: string, content: string): string { 663 | const baseUrl = getBaseUrl(url); 664 | const lines = content.split('\n'); 665 | const output: string[] = []; 666 | 667 | let isNextLineSegment = false; 668 | 669 | for (let i = 0; i < lines.length; i++) { 670 | const line = lines[i].trim(); 671 | 672 | // Skip empty lines 673 | if (!line) continue; 674 | 675 | // Handle EXT-X-KEY (encryption) 676 | if (line.startsWith('#EXT-X-KEY')) { 677 | output.push(processKeyLine(line, baseUrl)); 678 | continue; 679 | } 680 | 681 | // Handle EXT-X-MAP (initialization segment) 682 | if (line.startsWith('#EXT-X-MAP')) { 683 | output.push(processMapLine(line, baseUrl)); 684 | continue; 685 | } 686 | 687 | // Mark segment lines 688 | if (line.startsWith('#EXTINF')) { 689 | isNextLineSegment = true; 690 | output.push(line); 691 | continue; 692 | } 693 | 694 | // Process segment URLs 695 | if (isNextLineSegment && !line.startsWith('#')) { 696 | const absoluteUrl = resolveUrl(baseUrl, line); 697 | output.push(proxyTsUrl(absoluteUrl)); 698 | isNextLineSegment = false; 699 | continue; 700 | } 701 | 702 | // Pass through all other lines 703 | output.push(line); 704 | } 705 | 706 | return output.join('\n'); 707 | } 708 | 709 | /** 710 | * Process EXT-X-KEY line by proxying the key URL 711 | */ 712 | function processKeyLine(line: string, baseUrl: string): string { 713 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 714 | const absoluteUri = resolveUrl(baseUrl, uri); 715 | return `URI="${proxyTsUrl(absoluteUri)}"`; 716 | }); 717 | } 718 | 719 | /** 720 | * Process EXT-X-MAP line by proxying the map URL 721 | */ 722 | function processMapLine(line: string, baseUrl: string): string { 723 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 724 | const absoluteUri = resolveUrl(baseUrl, uri); 725 | return `URI="${proxyTsUrl(absoluteUri)}"`; 726 | }); 727 | } 728 | 729 | /** 730 | * Apply TS proxy to a URL 731 | */ 732 | function proxyTsUrl(url: string): string { 733 | if (!CONFIG.PROXY_TS) return url; 734 | 735 | return CONFIG.PROXY_TS_URLENCODE 736 | ? `${CONFIG.PROXY_TS}${encodeURIComponent(url)}` 737 | : `${CONFIG.PROXY_TS}${url}`; 738 | } 739 | 740 | /** 741 | * Get a random user agent from the configured list 742 | */ 743 | function getRandomUserAgent(): string { 744 | return CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 745 | } 746 | 747 | /** 748 | * Extract the base URL from a full URL 749 | */ 750 | function getBaseUrl(url: string): string { 751 | try { 752 | const parsedUrl = new URL(url); 753 | const pathParts = parsedUrl.pathname.split('/'); 754 | pathParts.pop(); // Remove the last part (filename) 755 | 756 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 757 | } catch (e) { 758 | // Fallback: find the last slash 759 | const lastSlashIndex = url.lastIndexOf('/'); 760 | return lastSlashIndex > 8 ? url.substring(0, lastSlashIndex + 1) : url; 761 | } 762 | } 763 | 764 | /** 765 | * Resolve a relative URL against a base URL 766 | */ 767 | function resolveUrl(baseUrl: string, relativeUrl: string): string { 768 | // Already absolute URL 769 | if (relativeUrl.match(/^https?:\/\//i)) { 770 | return relativeUrl; 771 | } 772 | 773 | try { 774 | return new URL(relativeUrl, baseUrl).toString(); 775 | } catch (e) { 776 | // Simple fallback 777 | if (relativeUrl.startsWith('/')) { 778 | const urlObj = new URL(baseUrl); 779 | return `${urlObj.origin}${relativeUrl}`; 780 | } 781 | return `${baseUrl}${relativeUrl}`; 782 | } 783 | } 784 | 785 | // Start the server 786 | console.log(`Starting M3U8 Filter server on port ${CONFIG.PORT}`); 787 | serve(handleRequest, { port: CONFIG.PORT }); 788 | -------------------------------------------------------------------------------- /edgeone.js: -------------------------------------------------------------------------------- 1 | /** 2 | * M3U8 Proxy and Filter for EdgeOne Edge Functions 3 | * 4 | * Features: 5 | * 1. Proxies M3U8 files and rewrites TS/fMP4 segment URLs 6 | * 2. Supports EXT-X-MAP initialization segments 7 | * 3. Handles encrypted streams (EXT-X-KEY) 8 | * 4. Filters discontinuity markers 9 | * 5. Uses EdgeOne Cache API for caching 10 | * 6. Auto-resolves master playlists 11 | * 7. Detects non-M3U8 content: 12 | * - If it's a media file (audio/video/image), proxies through TS proxy 13 | * - Otherwise redirects to original URL 14 | */ 15 | 16 | // Configuration 17 | const CONFIG = { 18 | PROXY_URL: 'https://proxy.mengze.vip/proxy/', // Main proxy URL (leave empty for direct fetch) 19 | PROXY_URLENCODE: true, // Whether to URL-encode target URLs 20 | 21 | PROXY_TS: 'https://proxy.mengze.vip/proxy/', // TS segment proxy URL 22 | PROXY_TS_URLENCODE: true, // Whether to URL-encode TS URLs 23 | 24 | CACHE_TTL: 10, // Cache TTL in seconds (10s for EdgeOne) 25 | 26 | MAX_RECURSION: 5, // Max recursion for nested playlists 27 | FILTER_ADS_INTELLIGENTLY: true, // Whether 智能过滤 28 | FILTER_REGEX: null, 29 | 30 | USER_AGENTS: [ 31 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 32 | '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' 33 | ], 34 | 35 | DEBUG: false // Enable debug logging 36 | }; 37 | 38 | // Media file extensions to check 39 | const MEDIA_FILE_EXTENSIONS = [ 40 | // Video formats 41 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 42 | // Audio formats 43 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 44 | // Image formats 45 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 46 | ]; 47 | 48 | // Media content types to check 49 | const MEDIA_CONTENT_TYPES = [ 50 | // Video types 51 | 'video/', 52 | // Audio types 53 | 'audio/', 54 | // Image types 55 | 'image/' 56 | ]; 57 | 58 | /** 59 | * Fetch and cache resource with EdgeOne Cache API 60 | */ 61 | async function fetchAndCache(event, request, cacheKey) { 62 | const cache = caches.default; 63 | let response = await fetch(request); 64 | 65 | // Set cache control header 66 | response = new Response(response.body, response); 67 | response.headers.set('Cache-Control', `s-maxage=${CONFIG.CACHE_TTL}`); 68 | 69 | // Cache the response 70 | event.waitUntil(cache.put(cacheKey, response.clone())); 71 | 72 | // Set cache miss header 73 | response.headers.set('x-edgefunctions-cache', 'miss'); 74 | 75 | return response; 76 | } 77 | 78 | /** 79 | * Main request handler 80 | */ 81 | async function handleEvent(event) { 82 | const request = event.request; 83 | const url = new URL(request.url); 84 | 85 | try { 86 | // Extract target URL 87 | const targetUrl = getTargetUrl(url); 88 | if (!targetUrl) { 89 | return createResponse( 90 | "Please provide an M3U8 URL via the 'url' parameter or /m3u8filter/URL path", 91 | 400, 92 | { "Content-Type": "text/plain" } 93 | ); 94 | } 95 | 96 | // Create cache key 97 | const cacheKey = new Request(targetUrl); 98 | const cache = caches.default; 99 | 100 | try { 101 | // Try to get from cache first 102 | let response = await cache.match(cacheKey); 103 | 104 | // If cached response exists 105 | if (response) { 106 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${targetUrl}`); 107 | response = new Response(response.body, response); 108 | response.headers.set('x-edgefunctions-cache', 'hit'); 109 | return response; 110 | } 111 | 112 | // Cache miss, fetch and process 113 | return await processAndCache(event, targetUrl, cacheKey); 114 | 115 | } catch (e) { 116 | // Cache error, delete and retry 117 | await cache.delete(cacheKey); 118 | return await processAndCache(event, targetUrl, cacheKey); 119 | } 120 | 121 | } catch (error) { 122 | console.error(`[Error] ${error.message}`); 123 | return createResponse( 124 | `Error processing request: ${error.message}`, 125 | 500, 126 | { "Content-Type": "text/plain" } 127 | ); 128 | } 129 | } 130 | 131 | /** 132 | * Process content and cache result 133 | */ 134 | async function processAndCache(event, targetUrl, cacheKey) { 135 | if (CONFIG.DEBUG) console.log(`[Processing] ${targetUrl}`); 136 | 137 | // Fetch and validate content 138 | const { content, contentType } = await fetchContentWithType(targetUrl); 139 | 140 | // Check if content is actually an M3U8 file 141 | if (!isM3u8Content(content, contentType)) { 142 | // Not an M3U8 file, check if it's a media file 143 | if (isMediaFile(targetUrl, contentType)) { 144 | if (CONFIG.DEBUG) console.log(`[Media file detected] Redirecting to TS proxy: ${targetUrl}`); 145 | return Response.redirect(proxyTsUrl(targetUrl), 302); 146 | } else { 147 | // Not a media file, redirect to original URL 148 | if (CONFIG.DEBUG) console.log(`[Not media content] Redirecting to original URL: ${targetUrl}`); 149 | return Response.redirect(targetUrl, 302); 150 | } 151 | } 152 | 153 | // Process the M3U8 content 154 | let processed = await processM3u8Content(targetUrl, content, 0); 155 | //是否智能过滤广告 156 | if (CONFIG.FILTER_ADS_INTELLIGENTLY) { 157 | processed = SuperFilterAdsFromM3U8(processed, CONFIG.FILTER_REGEX); 158 | } 159 | 160 | // Create and cache the response 161 | const response = createM3u8Response(processed); 162 | event.waitUntil(caches.default.put(cacheKey, response.clone())); 163 | 164 | return response; 165 | } 166 | 167 | /** 168 | * 超级M3U8广告算法过滤器 169 | * @param {string} m3u8Content - 原始M3U8内容 170 | * @param {string|null} regexFilter - 可选的正则过滤规则 171 | * @return {string} 过滤后的完整M3U8内容 172 | */ 173 | function SuperFilterAdsFromM3U8(m3u8Content, regexFilter = null) { 174 | if (!m3u8Content) return ''; 175 | 176 | // ==================== 第一阶段:预处理 ==================== 177 | // 1. 正则过滤 178 | let processedContent = regexFilter 179 | ? applyRegexFilter(m3u8Content, regexFilter) 180 | : m3u8Content; 181 | 182 | // 2. 解析M3U8结构 183 | const { segments, headers } = parseM3U8Structure(processedContent); 184 | if (segments.length === 0) return processedContent; 185 | 186 | // ==================== 第二阶段:科学分析 ==================== 187 | // 1. 计算基础统计量 188 | const stats = calculateSegmentStats(segments); 189 | 190 | // 2. 多维度广告检测 191 | const analyzedSegments = analyzeSegments(segments, stats); 192 | 193 | // 3. 智能过滤决策 194 | const filteredSegments = applyFilterDecision(analyzedSegments, stats); 195 | 196 | // ==================== 第三阶段:重建M3U8 ==================== 197 | return rebuildM3U8(headers, filteredSegments, processedContent); 198 | } 199 | 200 | // ==================== 辅助函数 ==================== 201 | 202 | /** 203 | * 应用正则过滤 204 | */ 205 | function applyRegexFilter(content, regexFilter) { 206 | try { 207 | const regex = new RegExp(regexFilter, 'gi'); 208 | return content.replace(regex, ''); 209 | } catch (e) { 210 | console.warn('正则过滤失败:', e); 211 | return content; 212 | } 213 | } 214 | 215 | /** 216 | * 深度解析M3U8结构 217 | */ 218 | function parseM3U8Structure(content) { 219 | const lines = content.split('\n'); 220 | const segments = []; 221 | const headers = { 222 | main: [], 223 | other: [] 224 | }; 225 | let currentDiscontinuity = false; 226 | let currentMap = null; 227 | let segmentIndex = 0; 228 | 229 | for (let i = 0; i < lines.length; i++) { 230 | const line = lines[i].trim(); 231 | 232 | // 收集头部信息 233 | if (i < 10 && line.startsWith('#EXT')) { 234 | headers.main.push(line); 235 | continue; 236 | } 237 | 238 | // 处理关键标签 239 | if (line.startsWith('#EXT-X-MAP:')) { 240 | currentMap = line; 241 | continue; 242 | } 243 | 244 | if (line.includes('#EXT-X-DISCONTINUITY')) { 245 | currentDiscontinuity = true; 246 | continue; 247 | } 248 | 249 | // 解析片段 250 | if (line.startsWith('#EXTINF:')) { 251 | const durationMatch = line.match(/#EXTINF:([\d.]+)/); 252 | if (durationMatch && lines[i + 1] && !lines[i + 1].startsWith('#')) { 253 | const duration = parseFloat(durationMatch[1]); 254 | const url = lines[i + 1].trim(); 255 | 256 | segments.push({ 257 | index: segmentIndex++, 258 | startLine: i, 259 | endLine: i + 1, 260 | duration, 261 | url, 262 | hasDiscontinuity: currentDiscontinuity, 263 | hasMap: currentMap !== null, 264 | content: currentMap 265 | ? [currentMap, line, lines[i + 1]].join('\n') 266 | : [line, lines[i + 1]].join('\n'), 267 | isAd: false, // 初始标记 268 | adScore: 0 // 广告概率得分 269 | }); 270 | 271 | currentDiscontinuity = false; 272 | currentMap = null; 273 | i++; // 跳过URL行 274 | } 275 | } else if (line.startsWith('#')) { 276 | headers.other.push(line); 277 | } 278 | } 279 | 280 | return { segments, headers }; 281 | } 282 | 283 | /** 284 | * 计算高级统计量 285 | */ 286 | function calculateSegmentStats(segments) { 287 | const durations = segments.map(s => s.duration); 288 | const totalDuration = durations.reduce((sum, d) => sum + d, 0); 289 | const avgDuration = totalDuration / durations.length; 290 | 291 | // 计算标准差和百分位数 292 | const squaredDiffs = durations.map(d => Math.pow(d - avgDuration, 2)); 293 | const stdDev = Math.sqrt(squaredDiffs.reduce((sum, sd) => sum + sd, 0) / durations.length); 294 | 295 | // 排序后的时长数组用于百分位计算 296 | const sortedDurations = [...durations].sort((a, b) => a - b); 297 | const p10 = sortedDurations[Math.floor(durations.length * 0.1)]; 298 | const p90 = sortedDurations[Math.floor(durations.length * 0.9)]; 299 | 300 | return { 301 | avgDuration, 302 | stdDev, 303 | p10, 304 | p90, 305 | totalDuration, 306 | segmentCount: segments.length, 307 | durationRange: [sortedDurations[0], sortedDurations[sortedDurations.length - 1]] 308 | }; 309 | } 310 | 311 | /** 312 | * 多维度片段分析 313 | */ 314 | function analyzeSegments(segments, stats) { 315 | const { avgDuration, stdDev, p10, p90 } = stats; 316 | 317 | return segments.map(segment => { 318 | const deviation = Math.abs(segment.duration - avgDuration); 319 | const zScore = deviation / stdDev; 320 | 321 | // 1. 时长异常检测 322 | const durationAbnormality = Math.min(1, zScore / 3); // 0-1范围 323 | 324 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 325 | let positionFactor = 0; 326 | if (segment.index < 3 && segment.duration < p10) { 327 | positionFactor = 0.8; // 开头的短片段很可疑 328 | } else if (segment.index > segments.length - 3 && segment.duration < p10) { 329 | positionFactor = 0.5; // 结尾的短片段中等可疑 330 | } 331 | 332 | // 3. 不连续标记检测 333 | const discontinuityFactor = segment.hasDiscontinuity ? 0.3 : 0; 334 | 335 | // 综合广告概率 336 | const adScore = Math.min(1, 337 | (durationAbnormality * 0.6) + 338 | (positionFactor * 0.3) + 339 | (discontinuityFactor * 0.1) 340 | ); 341 | 342 | return { 343 | ...segment, 344 | adScore, 345 | isAd: adScore > 0.65, // 阈值可调整 346 | stats: { deviation, zScore } 347 | }; 348 | }); 349 | } 350 | 351 | /** 352 | * 智能过滤决策 353 | */ 354 | function applyFilterDecision(segments, stats) { 355 | const { avgDuration, stdDev } = stats; 356 | 357 | // 动态调整阈值 358 | const baseThreshold = 0.65; 359 | const dynamicThreshold = Math.min(0.8, Math.max(0.5, 360 | baseThreshold - (stdDev / avgDuration) * 0.2 361 | )); 362 | 363 | return segments.filter(segment => { 364 | // 明确广告标记 365 | if (segment.isAd && segment.adScore > dynamicThreshold) { 366 | return false; 367 | } 368 | 369 | // 极短片段过滤(<1秒且不在开头) 370 | if (segment.duration < 1.0 && segment.index > 3) { 371 | return false; 372 | } 373 | 374 | // 保留关键片段(如包含MAP的) 375 | if (segment.hasMap) { 376 | return true; 377 | } 378 | 379 | // 默认保留 380 | return true; 381 | }); 382 | } 383 | 384 | /** 385 | * 完美重建M3U8 386 | */ 387 | function rebuildM3U8(headers, segments, originalContent) { 388 | // 收集需要保留的行号 389 | const keepLines = new Set(); 390 | 391 | // 保留所有头部信息 392 | headers.main.forEach((_, i) => keepLines.add(i)); 393 | 394 | // 保留所有片段内容 395 | segments.forEach(segment => { 396 | for (let i = segment.startLine; i <= segment.endLine; i++) { 397 | keepLines.add(i); 398 | } 399 | }); 400 | 401 | // 处理其他关键标签 402 | const lines = originalContent.split('\n'); 403 | const criticalTags = [ 404 | '#EXT-X-VERSION', 405 | '#EXT-X-TARGETDURATION', 406 | '#EXT-X-MEDIA-SEQUENCE', 407 | '#EXT-X-PLAYLIST-TYPE', 408 | '#EXT-X-ENDLIST' 409 | ]; 410 | 411 | for (let i = 0; i < lines.length; i++) { 412 | const line = lines[i].trim(); 413 | if (criticalTags.some(tag => line.startsWith(tag))) { 414 | keepLines.add(i); 415 | } 416 | } 417 | 418 | // 重建内容 419 | const filteredLines = lines.filter((_, i) => keepLines.has(i)); 420 | 421 | // 更新关键头部信息 422 | updateM3U8Headers(filteredLines, segments); 423 | 424 | return filteredLines.join('\n'); 425 | } 426 | 427 | /** 428 | * 更新M3U8头部信息 429 | */ 430 | function updateM3U8Headers(lines, segments) { 431 | if (segments.length === 0) return; 432 | 433 | // 更新TARGETDURATION 434 | const maxDuration = Math.max(...segments.map(s => s.duration)); 435 | for (let i = 0; i < lines.length; i++) { 436 | if (lines[i].startsWith('#EXT-X-TARGETDURATION')) { 437 | lines[i] = `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`; 438 | break; 439 | } 440 | } 441 | 442 | // 更新MEDIA-SEQUENCE 443 | if (segments[0].index > 0) { 444 | for (let i = 0; i < lines.length; i++) { 445 | if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE')) { 446 | lines[i] = `#EXT-X-MEDIA-SEQUENCE:${segments[0].index}`; 447 | break; 448 | } 449 | } 450 | } 451 | } 452 | 453 | /** 454 | * Check if content is a valid M3U8 file 455 | */ 456 | function isM3u8Content(content, contentType) { 457 | // Check content type header 458 | if (contentType && ( 459 | contentType.includes('application/vnd.apple.mpegurl') || 460 | contentType.includes('application/x-mpegurl'))) { 461 | return true; 462 | } 463 | 464 | // Check content for M3U8 signature 465 | if (content && content.trim().startsWith('#EXTM3U')) { 466 | return true; 467 | } 468 | 469 | return false; 470 | } 471 | 472 | /** 473 | * Check if the file is a media file based on extension and content type 474 | */ 475 | function isMediaFile(url, contentType) { 476 | // Check by content type 477 | if (contentType) { 478 | for (const mediaType of MEDIA_CONTENT_TYPES) { 479 | if (contentType.toLowerCase().startsWith(mediaType)) { 480 | return true; 481 | } 482 | } 483 | } 484 | 485 | // Check by file extension 486 | const urlLower = url.toLowerCase(); 487 | for (const ext of MEDIA_FILE_EXTENSIONS) { 488 | // Check if URL ends with the extension or has it followed by a query parameter 489 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 490 | return true; 491 | } 492 | } 493 | 494 | return false; 495 | } 496 | 497 | /** 498 | * Extract target URL from request 499 | */ 500 | function getTargetUrl(url) { 501 | // Check query parameter 502 | if (url.searchParams.has('url')) { 503 | return url.searchParams.get('url'); 504 | } 505 | 506 | // Check path format: /m3u8filter/URL 507 | const pathMatch = url.pathname.match(/^\/m3u8filter\/(.+)/); 508 | if (pathMatch && pathMatch[1]) { 509 | return decodeURIComponent(pathMatch[1]); 510 | } 511 | 512 | return null; 513 | } 514 | 515 | /** 516 | * Create a standardized response 517 | */ 518 | function createResponse(body, status = 200, headers = {}) { 519 | const responseHeaders = new Headers(headers); 520 | responseHeaders.set("Access-Control-Allow-Origin", "*"); 521 | 522 | return new Response(body, { 523 | status, 524 | headers: responseHeaders 525 | }); 526 | } 527 | 528 | /** 529 | * Create an M3U8 response with proper headers 530 | */ 531 | function createM3u8Response(content) { 532 | return createResponse(content, 200, { 533 | "Content-Type": "application/vnd.apple.mpegurl", 534 | "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL}` 535 | }); 536 | } 537 | 538 | /** 539 | * Fetch content with content type information 540 | */ 541 | async function fetchContentWithType(url) { 542 | const headers = new Headers({ 543 | 'User-Agent': getRandomUserAgent(), 544 | 'Accept': '*/*', 545 | 'Referer': new URL(url).origin 546 | }); 547 | 548 | let fetchUrl = url; 549 | if (CONFIG.PROXY_URL) { 550 | fetchUrl = CONFIG.PROXY_URLENCODE 551 | ? `${CONFIG.PROXY_URL}${encodeURIComponent(url)}` 552 | : `${CONFIG.PROXY_URL}${url}`; 553 | } 554 | 555 | try { 556 | const response = await fetch(fetchUrl, { headers }); 557 | if (!response.ok) { 558 | throw new Error(`HTTP error ${response.status}: ${response.statusText}`); 559 | } 560 | 561 | const content = await response.text(); 562 | const contentType = response.headers.get('Content-Type') || ''; 563 | 564 | return { content, contentType }; 565 | } catch (error) { 566 | throw new Error(`Failed to fetch ${url}: ${error.message}`); 567 | } 568 | } 569 | 570 | /** 571 | * Process M3U8 content from the initial URL 572 | */ 573 | async function processM3u8Content(url, content, recursionDepth = 0) { 574 | // Check if this is a master playlist 575 | if (content.includes('#EXT-X-STREAM-INF')) { 576 | if (CONFIG.DEBUG) console.log(`[Master playlist detected] ${url}`); 577 | return await processMasterPlaylist(url, content, recursionDepth); 578 | } 579 | 580 | // Process as a media playlist 581 | if (CONFIG.DEBUG) console.log(`[Media playlist] ${url}`); 582 | return processMediaPlaylist(url, content); 583 | } 584 | 585 | /** 586 | * Process a master playlist by selecting the first variant stream 587 | */ 588 | async function processMasterPlaylist(url, content, recursionDepth) { 589 | if (recursionDepth > CONFIG.MAX_RECURSION) { 590 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 591 | } 592 | 593 | const baseUrl = getBaseUrl(url); 594 | const lines = content.split('\n'); 595 | 596 | let variantUrl = ''; 597 | 598 | // Find the first variant stream URL 599 | for (let i = 0; i < lines.length; i++) { 600 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 601 | // The next non-comment line should be the variant URL 602 | for (let j = i + 1; j < lines.length; j++) { 603 | const line = lines[j].trim(); 604 | if (line && !line.startsWith('#')) { 605 | variantUrl = resolveUrl(baseUrl, line); 606 | break; 607 | } 608 | } 609 | if (variantUrl) break; 610 | } 611 | } 612 | 613 | if (!variantUrl) { 614 | throw new Error('No variant stream found in master playlist'); 615 | } 616 | 617 | // Process the variant stream 618 | if (CONFIG.DEBUG) console.log(`[Selected variant] ${variantUrl}`); 619 | const variantContent = await fetchContent(variantUrl); 620 | return await processM3u8Content(variantUrl, variantContent, recursionDepth + 1); 621 | } 622 | 623 | /** 624 | * Process a media playlist by rewriting segment URLs 625 | */ 626 | function processMediaPlaylist(url, content) { 627 | const baseUrl = getBaseUrl(url); 628 | const lines = content.split('\n'); 629 | const output = []; 630 | 631 | let isNextLineSegment = false; 632 | 633 | for (let i = 0; i < lines.length; i++) { 634 | const line = lines[i].trim(); 635 | 636 | // Skip empty lines 637 | if (!line) continue; 638 | 639 | // Handle EXT-X-KEY (encryption) 640 | if (line.startsWith('#EXT-X-KEY')) { 641 | output.push(processKeyLine(line, baseUrl)); 642 | continue; 643 | } 644 | 645 | // Handle EXT-X-MAP (initialization segment) 646 | if (line.startsWith('#EXT-X-MAP')) { 647 | output.push(processMapLine(line, baseUrl)); 648 | continue; 649 | } 650 | 651 | // Mark segment lines 652 | if (line.startsWith('#EXTINF')) { 653 | isNextLineSegment = true; 654 | output.push(line); 655 | continue; 656 | } 657 | 658 | // Process segment URLs 659 | if (isNextLineSegment && !line.startsWith('#')) { 660 | const absoluteUrl = resolveUrl(baseUrl, line); 661 | output.push(proxyTsUrl(absoluteUrl)); 662 | isNextLineSegment = false; 663 | continue; 664 | } 665 | 666 | // Pass through all other lines 667 | output.push(line); 668 | } 669 | 670 | return output.join('\n'); 671 | } 672 | 673 | /** 674 | * Process EXT-X-KEY line by proxying the key URL 675 | */ 676 | function processKeyLine(line, baseUrl) { 677 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 678 | const absoluteUri = resolveUrl(baseUrl, uri); 679 | return `URI="${proxyTsUrl(absoluteUri)}"`; 680 | }); 681 | } 682 | 683 | /** 684 | * Process EXT-X-MAP line by proxying the map URL 685 | */ 686 | function processMapLine(line, baseUrl) { 687 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 688 | const absoluteUri = resolveUrl(baseUrl, uri); 689 | return `URI="${proxyTsUrl(absoluteUri)}"`; 690 | }); 691 | } 692 | 693 | /** 694 | * Apply TS proxy to a URL 695 | */ 696 | function proxyTsUrl(url) { 697 | if (!CONFIG.PROXY_TS) return url; 698 | 699 | return CONFIG.PROXY_TS_URLENCODE 700 | ? `${CONFIG.PROXY_TS}${encodeURIComponent(url)}` 701 | : `${CONFIG.PROXY_TS}${url}`; 702 | } 703 | 704 | /** 705 | * Get a random user agent from the configured list 706 | */ 707 | function getRandomUserAgent() { 708 | return CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 709 | } 710 | 711 | /** 712 | * Extract the base URL from a full URL 713 | */ 714 | function getBaseUrl(url) { 715 | try { 716 | const parsedUrl = new URL(url); 717 | const pathParts = parsedUrl.pathname.split('/'); 718 | pathParts.pop(); // Remove the last part (filename) 719 | 720 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 721 | } catch (e) { 722 | // Fallback: find the last slash 723 | const lastSlashIndex = url.lastIndexOf('/'); 724 | return lastSlashIndex > 8 ? url.substring(0, lastSlashIndex + 1) : url; 725 | } 726 | } 727 | 728 | /** 729 | * Resolve a relative URL against a base URL 730 | */ 731 | function resolveUrl(baseUrl, relativeUrl) { 732 | // Already absolute URL 733 | if (relativeUrl.match(/^https?:\/\//i)) { 734 | return relativeUrl; 735 | } 736 | 737 | try { 738 | return new URL(relativeUrl, baseUrl).toString(); 739 | } catch (e) { 740 | // Simple fallback 741 | if (relativeUrl.startsWith('/')) { 742 | const urlObj = new URL(baseUrl); 743 | return `${urlObj.origin}${relativeUrl}`; 744 | } 745 | return `${baseUrl}${relativeUrl}`; 746 | } 747 | } 748 | 749 | // EdgeOne event listener 750 | addEventListener('fetch', (event) => { 751 | event.respondWith(handleEvent(event)); 752 | }); 753 | -------------------------------------------------------------------------------- /m3u8filter-curl.php: -------------------------------------------------------------------------------- 1 | CACHE_TIME && unlink($file)) { 75 | $count++; 76 | } 77 | } 78 | return $count; 79 | } 80 | 81 | /** 82 | * 从缓存获取内容 83 | */ 84 | function getFromCache($url) { 85 | $cacheFile = getCacheFilename($url); 86 | if (!file_exists($cacheFile)) return false; 87 | 88 | if (time() - filemtime($cacheFile) > CACHE_TIME) { 89 | cleanExpiredCache(); 90 | return false; 91 | } 92 | 93 | return file_get_contents($cacheFile); 94 | } 95 | 96 | /** 97 | * 写入缓存 98 | */ 99 | function writeToCache($url, $content) { 100 | if (!is_dir(CACHE_DIR)) mkdir(CACHE_DIR, 0755, true); 101 | file_put_contents(getCacheFilename($url), $content); 102 | } 103 | 104 | /** 105 | * 使用 curl 获取远程内容。增加超时设置和错误处理。 106 | * 107 | * @param string $url 请求的 URL 108 | * @param array $customHeaders 可选:自定义 HTTP 头 109 | * @return array [ 'content' => string|false, 'contentType' => string ] 110 | */ 111 | function curlFetch($url, $customHeaders = []) { 112 | global $userAgents; 113 | 114 | // 如果配置了代理 URL,则拼接代理路径 115 | if (!empty(PROXY_URL)) { 116 | $url = PROXY_URL . (PROXY_URLENCODE ? urlencode($url) : $url); 117 | } 118 | 119 | $ch = curl_init(); 120 | 121 | // 构造请求头,默认 Accept: */* 122 | $defaultHeaders = [ 123 | 'Accept: */*' 124 | ]; 125 | $allHeaders = array_merge($defaultHeaders, $customHeaders); 126 | 127 | curl_setopt_array($ch, [ 128 | CURLOPT_URL => $url, 129 | CURLOPT_RETURNTRANSFER => true, 130 | CURLOPT_FOLLOWLOCATION => true, 131 | CURLOPT_USERAGENT => $userAgents[array_rand($userAgents)], 132 | CURLOPT_HTTPHEADER => $allHeaders, 133 | CURLOPT_HEADER => true, // 同时返回header和body部分 134 | CURLOPT_CONNECTTIMEOUT => 30, // 建立连接超时(秒) 135 | CURLOPT_TIMEOUT => 60, // 整体超时(秒) 136 | ]); 137 | 138 | $response = curl_exec($ch); 139 | if (curl_errno($ch)) { 140 | error_log('CURL Error (' . curl_errno($ch) . '): ' . curl_error($ch)); 141 | curl_close($ch); 142 | return [ 143 | 'content' => false, 144 | 'contentType' => '' 145 | ]; 146 | } 147 | 148 | $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 149 | $headersStr = substr($response, 0, $headerSize); 150 | $content = substr($response, $headerSize); 151 | $contentType = ''; 152 | 153 | // 解析 header 获取内容类型 154 | $headerLines = explode("\r\n", $headersStr); 155 | foreach ($headerLines as $header) { 156 | if (stripos($header, 'Content-Type:') === 0) { 157 | $contentType = trim(substr($header, 13)); 158 | break; 159 | } 160 | } 161 | 162 | curl_close($ch); 163 | 164 | return [ 165 | 'content' => $content, 166 | 'contentType' => $contentType 167 | ]; 168 | } 169 | 170 | /** 171 | * 获取远程内容(封装 curlFetch) 172 | */ 173 | function fetchContentWithType($url) { 174 | return curlFetch($url); 175 | } 176 | 177 | /** 178 | * 判断内容是否为 M3U8 格式(通过内容头或内容签名) 179 | */ 180 | function isM3u8Content($content, $contentType) { 181 | // 根据 Content-Type 头判断 182 | if ($contentType && 183 | (stripos($contentType, 'application/vnd.apple.mpegurl') !== false || 184 | stripos($contentType, 'application/x-mpegurl') !== false)) { 185 | return true; 186 | } 187 | 188 | // 检查文件开头字符 189 | if ($content && strpos(trim($content), '#EXTM3U') === 0) { 190 | return true; 191 | } 192 | 193 | return false; 194 | } 195 | 196 | /** 197 | * 判断是否为媒体文件(通过扩展名或内容类型) 198 | */ 199 | function isMediaFile($url, $contentType) { 200 | // 根据 Content-Type 判断 201 | if ($contentType) { 202 | foreach (MEDIA_CONTENT_TYPES as $mediaType) { 203 | if (stripos($contentType, $mediaType) === 0) { 204 | return true; 205 | } 206 | } 207 | } 208 | 209 | // 根据 URL 扩展名判断 210 | $urlLower = strtolower($url); 211 | foreach (MEDIA_FILE_EXTENSIONS as $ext) { 212 | // 判断 URL 中是否包含扩展名,例如以该扩展名结尾或者扩展名后跟查询参数 213 | if (strpos($urlLower, $ext) !== false && 214 | (substr($urlLower, -strlen($ext)) === $ext || strpos($urlLower, $ext . '?') !== false)) { 215 | return true; 216 | } 217 | } 218 | 219 | return false; 220 | } 221 | 222 | /** 223 | * 生成 TS 分片代理 URL 224 | */ 225 | function proxyTsUrl($url) { 226 | if (empty(PROXY_TS)) return $url; 227 | 228 | return PROXY_TS . (PROXY_TS_URLENCODE ? urlencode($url) : $url); 229 | } 230 | 231 | /** 232 | * 解析相对 URL 为绝对 URL 233 | */ 234 | function resolveUrl($baseUrl, $relativeUrl) { 235 | if (preg_match('/^https?:\/\//i', $relativeUrl)) return $relativeUrl; 236 | 237 | $parsed = parse_url($baseUrl); 238 | if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) { 239 | return $relativeUrl; 240 | } 241 | 242 | $scheme = $parsed['scheme']; 243 | $host = $parsed['host']; 244 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 245 | 246 | if (strpos($relativeUrl, '/') === 0) { 247 | return "$scheme://$host$port$relativeUrl"; 248 | } 249 | 250 | $path = isset($parsed['path']) ? $parsed['path'] : ''; 251 | if ($path !== '' && substr($path, -1) !== '/') { 252 | $path = dirname($path) . '/'; 253 | } 254 | 255 | return "$scheme://$host$port$path$relativeUrl"; 256 | } 257 | 258 | /** 259 | * 修改加密密钥 URI(通过代理 TS 地址) 260 | */ 261 | function modifyKeyUri($line, $baseUrl) { 262 | if (preg_match('/URI="([^"]+)"/', $line, $matches)) { 263 | $absoluteUri = resolveUrl($baseUrl, $matches[1]); 264 | if (!empty(PROXY_TS)) { 265 | $proxiedUri = proxyTsUrl($absoluteUri); 266 | $line = str_replace($matches[1], $proxiedUri, $line); 267 | } 268 | } 269 | return $line; 270 | } 271 | 272 | /** 273 | * 修改 M3U8 内容中的 URL : 274 | * 1. 修改 EXT-X-MAP 初始化段; 275 | * 2. 修改加密密钥; 276 | * 3. 修改媒体分片地址(通过 TS 代理)。 277 | */ 278 | function modifyM3u8Urls($content, $baseUrl) { 279 | $lines = explode("\n", $content); 280 | $modified = []; 281 | $isNextLineMedia = false; 282 | 283 | foreach ($lines as $line) { 284 | $trimmed = trim($line); 285 | 286 | if (empty($trimmed)) { 287 | $modified[] = $line; 288 | continue; 289 | } 290 | 291 | // 处理 EXT-X-MAP 初始化段 292 | if (strpos($trimmed, '#EXT-X-MAP:') === 0) { 293 | if (preg_match('/URI="([^"]+)"/', $trimmed, $matches)) { 294 | $absoluteUri = resolveUrl($baseUrl, $matches[1]); 295 | if (!empty(PROXY_TS)) { 296 | $proxiedUri = proxyTsUrl($absoluteUri); 297 | $line = str_replace($matches[1], $proxiedUri, $line); 298 | } 299 | } 300 | $modified[] = $line; 301 | continue; 302 | } 303 | 304 | // 处理加密密钥 305 | if (strpos($trimmed, '#EXT-X-KEY') === 0) { 306 | $modified[] = modifyKeyUri($line, $baseUrl); 307 | continue; 308 | } 309 | 310 | // 处理媒体分片:EXTINF 后一行通常为媒体文件 URL 311 | if (strpos($trimmed, '#EXTINF:') === 0) { 312 | $isNextLineMedia = true; 313 | $modified[] = $line; 314 | } elseif ($isNextLineMedia && strpos($trimmed, '#') !== 0) { 315 | $absoluteUrl = resolveUrl($baseUrl, $trimmed); 316 | if (!empty(PROXY_TS)) { 317 | $modified[] = proxyTsUrl($absoluteUrl); 318 | } else { 319 | $modified[] = $absoluteUrl; 320 | } 321 | $isNextLineMedia = false; 322 | } else { 323 | $modified[] = $line; 324 | $isNextLineMedia = false; 325 | } 326 | } 327 | 328 | return implode("\n", $modified); 329 | } 330 | 331 | /** 332 | * 对 M3U8 内容过滤 discontinuity 标记 333 | */ 334 | function filterDiscontinuity($content) { 335 | if (!FILTER_DISCONTINUITY) return $content; 336 | 337 | return implode("\n", array_filter(explode("\n", $content), function($line) { 338 | return !empty(trim($line)) && strpos(trim($line), '#EXT-X-DISCONTINUITY') !== 0; 339 | })); 340 | } 341 | 342 | /** 343 | * 获取基础 URL 路径,用于解析相对地址 344 | */ 345 | function getBaseDirectoryUrl($url) { 346 | $parsed = parse_url($url); 347 | if (!$parsed || !isset($parsed['path'])) return $url; 348 | 349 | $path = $parsed['path']; 350 | $lastSlash = strrpos($path, '/'); 351 | $path = $lastSlash !== false ? substr($path, 0, $lastSlash + 1) : '/'; 352 | 353 | $scheme = $parsed['scheme'] ?? 'https'; 354 | $host = $parsed['host'] ?? ''; 355 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 356 | 357 | return "$scheme://$host$port$path"; 358 | } 359 | 360 | /** 361 | * 主处理函数:对提供的 M3U8 URL 进行代理和过滤处理 362 | */ 363 | function processM3u8Url($url) { 364 | // 尝试从缓存中获取 365 | if (($cached = getFromCache($url)) !== false) { 366 | header('Content-Type: application/vnd.apple.mpegurl'); 367 | header('Access-Control-Allow-Origin: *'); 368 | echo $cached; 369 | return; 370 | } 371 | 372 | // 使用 CURL 获取内容和响应头类型 373 | $result = fetchContentWithType($url); 374 | $content = $result['content']; 375 | $contentType = $result['contentType']; 376 | 377 | // 判断是否为 M3U8 内容 378 | if (!isM3u8Content($content, $contentType)) { 379 | // 非 M3U8 文件,则判断是否为媒体文件,采用 TS代理跳转 380 | if (isMediaFile($url, $contentType)) { 381 | header('Location: ' . proxyTsUrl($url)); 382 | } else { 383 | // 非媒体文件,直接跳转到原始 URL 384 | header("Location: $url"); 385 | } 386 | exit; 387 | } 388 | 389 | // 主播放列表递归解析。如果遇到 EXT-X-STREAM-INF,则根据其后续的媒体 URL 重新请求 390 | $currentUrl = $url; 391 | $recursionCount = 0; 392 | while (strpos($content, '#EXT-X-STREAM-INF') !== false) { 393 | if ($recursionCount >= MAX_RECURSION) { 394 | error_log("Exceeded maximum recursion count: " . MAX_RECURSION); 395 | break; 396 | } 397 | 398 | $lines = array_filter(explode("\n", $content), 'trim'); 399 | foreach ($lines as $line) { 400 | $line = trim($line); 401 | if (empty($line)) continue; 402 | if ($line[0] !== '#') { 403 | $currentUrl = resolveUrl($currentUrl, $line); 404 | break; 405 | } 406 | } 407 | 408 | $result = fetchContentWithType($currentUrl); 409 | if (!$result['content']) { 410 | error_log("Failed to fetch M3U8 content from URL: " . $currentUrl); 411 | break; 412 | } 413 | $content = $result['content']; 414 | $recursionCount++; 415 | } 416 | 417 | // 处理并修改 M3U8 中的 URL 418 | $baseUrl = getBaseDirectoryUrl($currentUrl); 419 | $filtered = filterDiscontinuity($content); 420 | $modified = modifyM3u8Urls($filtered, $baseUrl); 421 | 422 | // 写入缓存后返回 423 | writeToCache($url, $modified); 424 | header('Content-Type: application/vnd.apple.mpegurl'); 425 | header('Access-Control-Allow-Origin: *'); 426 | echo $modified; 427 | } 428 | 429 | /** 430 | * 获取目标 URL 431 | */ 432 | function getTargetUrl() { 433 | $url = isset($_GET['url']) ? $_GET['url'] : null; 434 | 435 | // 如果 URL 参数为空,则尝试从 REQUEST_URI 中提取参数 436 | if (empty($url)) { 437 | $path = $_SERVER['REQUEST_URI'] ?? ''; 438 | if (preg_match('/\/m3u8filter\/(.+)/', $path, $matches)) { 439 | $url = $matches[1]; 440 | } 441 | } 442 | 443 | return !empty($url) ? urldecode($url) : null; 444 | } 445 | 446 | // ========== 主执行逻辑 ========== 447 | $TargetUrl = getTargetUrl(); 448 | if (!empty($TargetUrl)) { 449 | processM3u8Url($TargetUrl); 450 | } else { 451 | header('Content-Type: text/plain'); 452 | echo "请通过 url 参数提供M3U8地址"; 453 | } 454 | ?> 455 | -------------------------------------------------------------------------------- /m3u8filter.php: -------------------------------------------------------------------------------- 1 | CACHE_TIME && unlink($file)) { 81 | $count++; 82 | } 83 | } 84 | return $count; 85 | } 86 | 87 | /** 88 | * 从缓存获取内容 89 | */ 90 | function getFromCache($url) { 91 | $cacheFile = getCacheFilename($url); 92 | if (!file_exists($cacheFile)) return false; 93 | 94 | if (time() - filemtime($cacheFile) > CACHE_TIME) { 95 | cleanExpiredCache(); 96 | return false; 97 | } 98 | 99 | return file_get_contents($cacheFile); 100 | } 101 | 102 | /** 103 | * 写入缓存 104 | */ 105 | function writeToCache($url, $content) { 106 | if (!is_dir(CACHE_DIR)) mkdir(CACHE_DIR, 0755, true); 107 | file_put_contents(getCacheFilename($url), $content); 108 | } 109 | 110 | /** 111 | * 使用 curl 获取远程内容。增加超时设置和错误处理。 112 | * 113 | * @param string $url 请求的 URL 114 | * @param array $customHeaders 可选:自定义 HTTP 头 115 | * @return array [ 'content' => string|false, 'contentType' => string ] 116 | */ 117 | function curlFetch($url, $customHeaders = []) { 118 | global $userAgents; 119 | 120 | // 如果配置了代理 URL,则拼接代理路径 121 | if (!empty(PROXY_URL)) { 122 | $url = PROXY_URL . (PROXY_URLENCODE ? urlencode($url) : $url); 123 | } 124 | 125 | $ch = curl_init(); 126 | 127 | // 构造请求头,默认 Accept: */* 128 | $defaultHeaders = [ 129 | 'Accept: */*' 130 | ]; 131 | $allHeaders = array_merge($defaultHeaders, $customHeaders); 132 | 133 | curl_setopt_array($ch, [ 134 | CURLOPT_URL => $url, 135 | CURLOPT_RETURNTRANSFER => true, 136 | CURLOPT_FOLLOWLOCATION => true, 137 | CURLOPT_USERAGENT => $userAgents[array_rand($userAgents)], 138 | CURLOPT_HTTPHEADER => $allHeaders, 139 | CURLOPT_HEADER => true, // 同时返回header和body部分 140 | CURLOPT_CONNECTTIMEOUT => 30, // 建立连接超时(秒) 141 | CURLOPT_TIMEOUT => 60, // 整体超时(秒) 142 | ]); 143 | 144 | $response = curl_exec($ch); 145 | if (curl_errno($ch)) { 146 | error_log('CURL Error (' . curl_errno($ch) . '): ' . curl_error($ch)); 147 | curl_close($ch); 148 | return [ 149 | 'content' => false, 150 | 'contentType' => '' 151 | ]; 152 | } 153 | 154 | $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 155 | $headersStr = substr($response, 0, $headerSize); 156 | $content = substr($response, $headerSize); 157 | $contentType = ''; 158 | 159 | // 解析 header 获取内容类型 160 | $headerLines = explode("\r\n", $headersStr); 161 | foreach ($headerLines as $header) { 162 | if (stripos($header, 'Content-Type:') === 0) { 163 | $contentType = trim(substr($header, 13)); 164 | break; 165 | } 166 | } 167 | 168 | curl_close($ch); 169 | 170 | return [ 171 | 'content' => $content, 172 | 'contentType' => $contentType 173 | ]; 174 | } 175 | 176 | /** 177 | * 获取远程内容(封装 curlFetch) 178 | */ 179 | function fetchContentWithType($url) { 180 | return curlFetch($url); 181 | } 182 | 183 | /** 184 | * 判断内容是否为 M3U8 格式(通过内容头或内容签名) 185 | */ 186 | function isM3u8Content($content, $contentType) { 187 | // 根据 Content-Type 头判断 188 | if ($contentType && 189 | (stripos($contentType, 'application/vnd.apple.mpegurl') !== false || 190 | stripos($contentType, 'application/x-mpegurl') !== false)) { 191 | return true; 192 | } 193 | 194 | // 检查文件开头字符 195 | if ($content && strpos(trim($content), '#EXTM3U') === 0) { 196 | return true; 197 | } 198 | 199 | return false; 200 | } 201 | 202 | /** 203 | * 判断是否为媒体文件(通过扩展名或内容类型) 204 | */ 205 | function isMediaFile($url, $contentType) { 206 | // 根据 Content-Type 判断 207 | if ($contentType) { 208 | foreach (MEDIA_CONTENT_TYPES as $mediaType) { 209 | if (stripos($contentType, $mediaType) === 0) { 210 | return true; 211 | } 212 | } 213 | } 214 | 215 | // 根据 URL 扩展名判断 216 | $urlLower = strtolower($url); 217 | foreach (MEDIA_FILE_EXTENSIONS as $ext) { 218 | // 判断 URL 中是否包含扩展名,例如以该扩展名结尾或者扩展名后跟查询参数 219 | if (strpos($urlLower, $ext) !== false && 220 | (substr($urlLower, -strlen($ext)) === $ext || strpos($urlLower, $ext . '?') !== false)) { 221 | return true; 222 | } 223 | } 224 | 225 | return false; 226 | } 227 | 228 | /** 229 | * 生成 TS 分片代理 URL 230 | */ 231 | function proxyTsUrl($url) { 232 | if (empty(PROXY_TS)) return $url; 233 | 234 | return PROXY_TS . (PROXY_TS_URLENCODE ? urlencode($url) : $url); 235 | } 236 | 237 | /** 238 | * 解析相对 URL 为绝对 URL 239 | */ 240 | function resolveUrl($baseUrl, $relativeUrl) { 241 | if (preg_match('/^https?:\/\//i', $relativeUrl)) return $relativeUrl; 242 | 243 | $parsed = parse_url($baseUrl); 244 | if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) { 245 | return $relativeUrl; 246 | } 247 | 248 | $scheme = $parsed['scheme']; 249 | $host = $parsed['host']; 250 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 251 | 252 | if (strpos($relativeUrl, '/') === 0) { 253 | return "$scheme://$host$port$relativeUrl"; 254 | } 255 | 256 | $path = isset($parsed['path']) ? $parsed['path'] : ''; 257 | if ($path !== '' && substr($path, -1) !== '/') { 258 | $path = dirname($path) . '/'; 259 | } 260 | 261 | return "$scheme://$host$port$path$relativeUrl"; 262 | } 263 | 264 | /** 265 | * 修改加密密钥 URI(通过代理 TS 地址) 266 | */ 267 | function modifyKeyUri($line, $baseUrl) { 268 | if (preg_match('/URI="([^"]+)"/', $line, $matches)) { 269 | $absoluteUri = resolveUrl($baseUrl, $matches[1]); 270 | if (!empty(PROXY_TS)) { 271 | $proxiedUri = proxyTsUrl($absoluteUri); 272 | $line = str_replace($matches[1], $proxiedUri, $line); 273 | } 274 | } 275 | return $line; 276 | } 277 | 278 | /** 279 | * 修改 M3U8 内容中的 URL : 280 | * 1. 修改 EXT-X-MAP 初始化段; 281 | * 2. 修改加密密钥; 282 | * 3. 修改媒体分片地址(通过 TS 代理)。 283 | */ 284 | function modifyM3u8Urls($content, $baseUrl) { 285 | $lines = explode("\n", $content); 286 | $modified = []; 287 | $isNextLineMedia = false; 288 | 289 | foreach ($lines as $line) { 290 | $trimmed = trim($line); 291 | 292 | if (empty($trimmed)) { 293 | $modified[] = $line; 294 | continue; 295 | } 296 | 297 | // 处理 EXT-X-MAP 初始化段 298 | if (strpos($trimmed, '#EXT-X-MAP:') === 0) { 299 | if (preg_match('/URI="([^"]+)"/', $trimmed, $matches)) { 300 | $absoluteUri = resolveUrl($baseUrl, $matches[1]); 301 | if (!empty(PROXY_TS)) { 302 | $proxiedUri = proxyTsUrl($absoluteUri); 303 | $line = str_replace($matches[1], $proxiedUri, $line); 304 | } 305 | } 306 | $modified[] = $line; 307 | continue; 308 | } 309 | 310 | // 处理加密密钥 311 | if (strpos($trimmed, '#EXT-X-KEY') === 0) { 312 | $modified[] = modifyKeyUri($line, $baseUrl); 313 | continue; 314 | } 315 | 316 | // 处理媒体分片:EXTINF 后一行通常为媒体文件 URL 317 | if (strpos($trimmed, '#EXTINF:') === 0) { 318 | $isNextLineMedia = true; 319 | $modified[] = $line; 320 | } elseif ($isNextLineMedia && strpos($trimmed, '#') !== 0) { 321 | $absoluteUrl = resolveUrl($baseUrl, $trimmed); 322 | if (!empty(PROXY_TS)) { 323 | $modified[] = proxyTsUrl($absoluteUrl); 324 | } else { 325 | $modified[] = $absoluteUrl; 326 | } 327 | $isNextLineMedia = false; 328 | } else { 329 | $modified[] = $line; 330 | $isNextLineMedia = false; 331 | } 332 | } 333 | 334 | return implode("\n", $modified); 335 | } 336 | 337 | /** 338 | * 智能过滤M3U8内容 339 | * @param string $content - 原始M3U8内容 340 | * @param string|null $regexFilter - 可选的正则过滤规则 341 | * @return string 过滤后的完整M3U8内容 342 | */ 343 | function filterDiscontinuity($content, $regexFilter = null) { 344 | if (empty($content)) return $content; 345 | 346 | // ==================== 第一阶段:预处理 ==================== 347 | // 1. 正则过滤 348 | $processedContent = $regexFilter 349 | ? applyRegexFilter($content, $regexFilter) 350 | : $content; 351 | 352 | // 2. 解析M3U8结构 353 | $parsed = parseM3U8Structure($processedContent); 354 | $segments = $parsed['segments']; 355 | $headers = $parsed['headers']; 356 | 357 | if (empty($segments)) return $processedContent; 358 | 359 | // ==================== 第二阶段:科学分析 ==================== 360 | // 1. 计算基础统计量 361 | $stats = calculateSegmentStats($segments); 362 | 363 | // 2. 多维度广告检测 364 | $analyzedSegments = analyzeSegments($segments, $stats); 365 | 366 | // 3. 智能过滤决策 367 | $filteredSegments = applyFilterDecision($analyzedSegments, $stats); 368 | 369 | // ==================== 第三阶段:重建M3U8 ==================== 370 | return rebuildM3U8($headers, $filteredSegments, $processedContent); 371 | } 372 | 373 | // ==================== 辅助函数 ==================== 374 | 375 | /** 376 | * 应用正则过滤 377 | */ 378 | function applyRegexFilter($content, $regexFilter) { 379 | try { 380 | return preg_replace('/' . $regexFilter . '/i', '', $content); 381 | } catch (Exception $e) { 382 | error_log('正则过滤失败: ' . $e->getMessage()); 383 | return $content; 384 | } 385 | } 386 | 387 | /** 388 | * 深度解析M3U8结构 389 | */ 390 | function parseM3U8Structure($content) { 391 | $lines = explode("\n", $content); 392 | $segments = []; 393 | $headers = [ 394 | 'main' => [], 395 | 'other' => [] 396 | ]; 397 | $currentDiscontinuity = false; 398 | $currentMap = null; 399 | $segmentIndex = 0; 400 | 401 | foreach ($lines as $i => $line) { 402 | $line = trim($line); 403 | 404 | // 收集头部信息 405 | if ($i < 10 && strpos($line, '#EXT') === 0) { 406 | $headers['main'][] = $line; 407 | continue; 408 | } 409 | 410 | // 处理关键标签 411 | if (strpos($line, '#EXT-X-MAP:') === 0) { 412 | $currentMap = $line; 413 | continue; 414 | } 415 | 416 | if (strpos($line, '#EXT-X-DISCONTINUITY') !== false) { 417 | $currentDiscontinuity = true; 418 | continue; 419 | } 420 | 421 | // 解析片段 422 | if (strpos($line, '#EXTINF:') === 0) { 423 | if (preg_match('/#EXTINF:([\d.]+)/', $line, $durationMatch)) { 424 | $nextLine = $lines[$i + 1] ?? ''; 425 | if (!empty($nextLine) && strpos($nextLine, '#') !== 0) { 426 | $duration = (float)$durationMatch[1]; 427 | $url = trim($nextLine); 428 | 429 | $segments[] = [ 430 | 'index' => $segmentIndex++, 431 | 'startLine' => $i, 432 | 'endLine' => $i + 1, 433 | 'duration' => $duration, 434 | 'url' => $url, 435 | 'hasDiscontinuity' => $currentDiscontinuity, 436 | 'hasMap' => $currentMap !== null, 437 | 'content' => $currentMap 438 | ? $currentMap . "\n" . $line . "\n" . $nextLine 439 | : $line . "\n" . $nextLine, 440 | 'isAd' => false, // 初始标记 441 | 'adScore' => 0 // 广告概率得分 442 | ]; 443 | 444 | $currentDiscontinuity = false; 445 | $currentMap = null; 446 | } 447 | } 448 | } elseif (strpos($line, '#') === 0) { 449 | $headers['other'][] = $line; 450 | } 451 | } 452 | 453 | return ['segments' => $segments, 'headers' => $headers]; 454 | } 455 | 456 | /** 457 | * 计算高级统计量 458 | */ 459 | function calculateSegmentStats($segments) { 460 | $durations = array_column($segments, 'duration'); 461 | $totalDuration = array_sum($durations); 462 | $avgDuration = $totalDuration / count($durations); 463 | 464 | // 计算标准差 465 | $squaredDiffs = array_map(function($d) use ($avgDuration) { 466 | return pow($d - $avgDuration, 2); 467 | }, $durations); 468 | $stdDev = sqrt(array_sum($squaredDiffs) / count($durations)); 469 | 470 | // 排序后的时长数组用于百分位计算 471 | sort($durations); 472 | $p10 = $durations[(int)(count($durations) * 0.1)]; 473 | $p90 = $durations[(int)(count($durations) * 0.9)]; 474 | 475 | return [ 476 | 'avgDuration' => $avgDuration, 477 | 'stdDev' => $stdDev, 478 | 'p10' => $p10, 479 | 'p90' => $p90, 480 | 'totalDuration' => $totalDuration, 481 | 'segmentCount' => count($segments), 482 | 'durationRange' => [$durations[0], $durations[count($durations)-1]] 483 | ]; 484 | } 485 | 486 | /** 487 | * 多维度片段分析 488 | */ 489 | function analyzeSegments($segments, $stats) { 490 | $avgDuration = $stats['avgDuration']; 491 | $stdDev = $stats['stdDev']; 492 | $p10 = $stats['p10']; 493 | $p90 = $stats['p90']; 494 | 495 | $analyzed = []; 496 | foreach ($segments as $segment) { 497 | $deviation = abs($segment['duration'] - $avgDuration); 498 | $zScore = $stdDev > 0 ? $deviation / $stdDev : 0; 499 | 500 | // 1. 时长异常检测 501 | $durationAbnormality = min(1, $zScore / 3); // 0-1范围 502 | 503 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 504 | $positionFactor = 0; 505 | if ($segment['index'] < 3 && $segment['duration'] < $p10) { 506 | $positionFactor = 0.8; // 开头的短片段很可疑 507 | } elseif ($segment['index'] > count($segments) - 3 && $segment['duration'] < $p10) { 508 | $positionFactor = 0.5; // 结尾的短片段中等可疑 509 | } 510 | 511 | // 3. 不连续标记检测 512 | $discontinuityFactor = $segment['hasDiscontinuity'] ? 0.3 : 0; 513 | 514 | // 综合广告概率 515 | $adScore = min(1, 516 | ($durationAbnormality * 0.6) + 517 | ($positionFactor * 0.3) + 518 | ($discontinuityFactor * 0.1) 519 | ); 520 | 521 | $segment['adScore'] = $adScore; 522 | $segment['isAd'] = $adScore > 0.65; // 阈值可调整 523 | $segment['stats'] = ['deviation' => $deviation, 'zScore' => $zScore]; 524 | 525 | $analyzed[] = $segment; 526 | } 527 | 528 | return $analyzed; 529 | } 530 | 531 | /** 532 | * 智能过滤决策 533 | */ 534 | function applyFilterDecision($segments, $stats) { 535 | $avgDuration = $stats['avgDuration']; 536 | $stdDev = $stats['stdDev']; 537 | 538 | // 动态调整阈值 539 | $baseThreshold = 0.65; 540 | $dynamicThreshold = min(0.8, max(0.5, 541 | $baseThreshold - ($stdDev / $avgDuration) * 0.2 542 | )); 543 | 544 | return array_filter($segments, function($segment) use ($dynamicThreshold) { 545 | // 明确广告标记 546 | if ($segment['isAd'] && $segment['adScore'] > $dynamicThreshold) { 547 | return false; 548 | } 549 | 550 | // 极短片段过滤(<1秒且不在开头) 551 | if ($segment['duration'] < 1.0 && $segment['index'] > 3) { 552 | return false; 553 | } 554 | 555 | // 保留关键片段(如包含MAP的) 556 | if ($segment['hasMap']) { 557 | return true; 558 | } 559 | 560 | // 默认保留 561 | return true; 562 | }); 563 | } 564 | 565 | /** 566 | * 完美重建M3U8 567 | */ 568 | function rebuildM3U8($headers, $segments, $originalContent) { 569 | // 收集需要保留的行号 570 | $keepLines = []; 571 | 572 | // 保留所有头部信息 573 | foreach ($headers['main'] as $line) { 574 | $keepLines[] = $line; 575 | } 576 | 577 | // 保留所有片段内容 578 | foreach ($segments as $segment) { 579 | $contentLines = explode("\n", $segment['content']); 580 | foreach ($contentLines as $line) { 581 | $keepLines[] = $line; 582 | } 583 | } 584 | 585 | // 处理其他关键标签 586 | $lines = explode("\n", $originalContent); 587 | $criticalTags = [ 588 | '#EXT-X-VERSION', 589 | '#EXT-X-TARGETDURATION', 590 | '#EXT-X-MEDIA-SEQUENCE', 591 | '#EXT-X-PLAYLIST-TYPE', 592 | '#EXT-X-ENDLIST' 593 | ]; 594 | 595 | foreach ($lines as $line) { 596 | $line = trim($line); 597 | foreach ($criticalTags as $tag) { 598 | if (strpos($line, $tag) === 0) { 599 | $keepLines[] = $line; 600 | break; 601 | } 602 | } 603 | } 604 | 605 | // 重建内容 606 | $filteredLines = array_unique($keepLines); 607 | 608 | // 更新关键头部信息 609 | $filteredContent = implode("\n", $filteredLines); 610 | $filteredContent = updateM3U8Headers($filteredContent, $segments); 611 | 612 | return $filteredContent; 613 | } 614 | 615 | /** 616 | * 更新M3U8头部信息 617 | */ 618 | function updateM3U8Headers($content, $segments) { 619 | if (empty($segments)) return $content; 620 | 621 | $lines = explode("\n", $content); 622 | 623 | // 更新TARGETDURATION 624 | $maxDuration = max(array_column($segments, 'duration')); 625 | foreach ($lines as &$line) { 626 | if (strpos($line, '#EXT-X-TARGETDURATION') === 0) { 627 | $line = '#EXT-X-TARGETDURATION:' . ceil($maxDuration); 628 | break; 629 | } 630 | } 631 | 632 | // 更新MEDIA-SEQUENCE 633 | if ($segments[0]['index'] > 0) { 634 | foreach ($lines as &$line) { 635 | if (strpos($line, '#EXT-X-MEDIA-SEQUENCE') === 0) { 636 | $line = '#EXT-X-MEDIA-SEQUENCE:' . $segments[0]['index']; 637 | break; 638 | } 639 | } 640 | } 641 | 642 | return implode("\n", $lines); 643 | } 644 | 645 | /** 646 | * 获取基础 URL 路径,用于解析相对地址 647 | */ 648 | function getBaseDirectoryUrl($url) { 649 | $parsed = parse_url($url); 650 | if (!$parsed || !isset($parsed['path'])) return $url; 651 | 652 | $path = $parsed['path']; 653 | $lastSlash = strrpos($path, '/'); 654 | $path = $lastSlash !== false ? substr($path, 0, $lastSlash + 1) : '/'; 655 | 656 | $scheme = $parsed['scheme'] ?? 'https'; 657 | $host = $parsed['host'] ?? ''; 658 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 659 | 660 | return "$scheme://$host$port$path"; 661 | } 662 | 663 | /** 664 | * 主处理函数:对提供的 M3U8 URL 进行代理和过滤处理 665 | */ 666 | function processM3u8Url($url) { 667 | // 尝试从缓存中获取 668 | if (($cached = getFromCache($url)) !== false) { 669 | header('Content-Type: application/vnd.apple.mpegurl'); 670 | header('Access-Control-Allow-Origin: *'); 671 | echo $cached; 672 | return; 673 | } 674 | 675 | // 使用 CURL 获取内容和响应头类型 676 | $result = fetchContentWithType($url); 677 | $content = $result['content']; 678 | $contentType = $result['contentType']; 679 | 680 | // 判断是否为 M3U8 内容 681 | if (!isM3u8Content($content, $contentType)) { 682 | // 非 M3U8 文件,则判断是否为媒体文件,采用 TS代理跳转 683 | if (isMediaFile($url, $contentType)) { 684 | header('Location: ' . proxyTsUrl($url)); 685 | } else { 686 | // 非媒体文件,直接跳转到原始 URL 687 | header("Location: $url"); 688 | } 689 | exit; 690 | } 691 | 692 | // 主播放列表递归解析。如果遇到 EXT-X-STREAM-INF,则根据其后续的媒体 URL 重新请求 693 | $currentUrl = $url; 694 | $recursionCount = 0; 695 | while (strpos($content, '#EXT-X-STREAM-INF') !== false) { 696 | if ($recursionCount >= MAX_RECURSION) { 697 | error_log("Exceeded maximum recursion count: " . MAX_RECURSION); 698 | break; 699 | } 700 | 701 | $lines = array_filter(explode("\n", $content), 'trim'); 702 | foreach ($lines as $line) { 703 | $line = trim($line); 704 | if (empty($line)) continue; 705 | if ($line[0] !== '#') { 706 | $currentUrl = resolveUrl($currentUrl, $line); 707 | break; 708 | } 709 | } 710 | 711 | $result = fetchContentWithType($currentUrl); 712 | if (!$result['content']) { 713 | error_log("Failed to fetch M3U8 content from URL: " . $currentUrl); 714 | break; 715 | } 716 | $content = $result['content']; 717 | $recursionCount++; 718 | } 719 | 720 | // 处理并修改 M3U8 中的 URL 721 | $baseUrl = getBaseDirectoryUrl($currentUrl); 722 | // 替换原来的 filterDiscontinuity 调用 723 | $filtered = FILTER_ADS_INTELLIGENTLY 724 | ? filterDiscontinuity($content, FILTER_REGEX) 725 | : $content; 726 | $modified = modifyM3u8Urls($filtered, $baseUrl); 727 | 728 | // 写入缓存后返回 729 | writeToCache($url, $modified); 730 | header('Content-Type: application/vnd.apple.mpegurl'); 731 | header('Access-Control-Allow-Origin: *'); 732 | echo $modified; 733 | } 734 | 735 | /** 736 | * 获取目标 URL 737 | */ 738 | function getTargetUrl() { 739 | $url = isset($_GET['url']) ? $_GET['url'] : null; 740 | 741 | // 如果 URL 参数为空,则尝试从 REQUEST_URI 中提取参数 742 | if (empty($url)) { 743 | $path = $_SERVER['REQUEST_URI'] ?? ''; 744 | if (preg_match('/\/m3u8filter\/(.+)/', $path, $matches)) { 745 | $url = $matches[1]; 746 | } 747 | } 748 | 749 | return !empty($url) ? urldecode($url) : null; 750 | } 751 | 752 | // ========== 主执行逻辑 ========== 753 | $TargetUrl = getTargetUrl(); 754 | if (!empty($TargetUrl)) { 755 | processM3u8Url($TargetUrl); 756 | } else { 757 | header('Content-Type: text/plain'); 758 | echo "请通过 url 参数提供M3U8地址"; 759 | } 760 | ?> 761 | -------------------------------------------------------------------------------- /node.js: -------------------------------------------------------------------------------- 1 | /* 2 | node.js m3u8过滤代理脚本,未测试 3 | Usage: 4 | Install Node.js (v14+ recommended) 5 | Save the script as m3u8-proxy.js 6 | Run with: node m3u8-proxy.js 7 | Access via: 8 | http://localhost:8000/?url=[M3U8_URL] 9 | http://localhost:8000/m3u8filter/[M3U8_URL] 10 | The server will cache processed playlists in the m3u8files/ directory and automatically clean up expired files. All configuration options are at the top of the script for easy customization. 11 | */ 12 | const http = require('http'); 13 | const https = require('https'); 14 | const fs = require('fs'); 15 | const path = require('path'); 16 | const url = require('url'); 17 | 18 | // ========== Configuration ========== 19 | const CONFIG = { 20 | PORT: 8000, 21 | PROXY_URL: 'https://proxy.mengze.vip/proxy/', // Main proxy URL 22 | PROXY_URLENCODE: true, // Whether to encode target URLs 23 | 24 | PROXY_TS: 'https://proxy.mengze.vip/proxy/', // TS segment proxy URL 25 | PROXY_TS_URLENCODE: true, // Whether to encode TS URLs 26 | 27 | CACHE_DIR: 'm3u8files/', // Cache directory 28 | CACHE_TIME: 86400, // Cache time in seconds (24 hours) 29 | 30 | MAX_RECURSION: 30, // Max recursion depth for master playlists 31 | FILTER_DISCONTINUITY: true, // Whether to filter discontinuity markers 32 | 33 | USER_AGENTS: [ 34 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36', 35 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15' 36 | ], 37 | 38 | MEDIA_FILE_EXTENSIONS: [ 39 | // Video formats 40 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 41 | // Audio formats 42 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 43 | // Image formats 44 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 45 | ], 46 | 47 | MEDIA_CONTENT_TYPES: [ 48 | // Video types 49 | 'video/', 50 | // Audio types 51 | 'audio/', 52 | // Image types 53 | 'image/' 54 | ] 55 | }; 56 | 57 | // ========== Helper Functions ========== 58 | 59 | /** 60 | * Get cache filename for a URL 61 | */ 62 | function getCacheFilename(targetUrl) { 63 | const crypto = require('crypto'); 64 | const hash = crypto.createHash('md5').update(targetUrl).digest('hex'); 65 | return path.join(CONFIG.CACHE_DIR, `${hash}.m3u8`); 66 | } 67 | 68 | /** 69 | * Clean expired cache files 70 | */ 71 | function cleanExpiredCache() { 72 | if (!fs.existsSync(CONFIG.CACHE_DIR)) { 73 | return 0; 74 | } 75 | 76 | let count = 0; 77 | const files = fs.readdirSync(CONFIG.CACHE_DIR); 78 | const now = Math.floor(Date.now() / 1000); 79 | 80 | files.forEach(file => { 81 | if (file.endsWith('.m3u8')) { 82 | const filePath = path.join(CONFIG.CACHE_DIR, file); 83 | const stats = fs.statSync(filePath); 84 | const mtime = Math.floor(stats.mtimeMs / 1000); 85 | 86 | if (now - mtime > CONFIG.CACHE_TIME) { 87 | fs.unlinkSync(filePath); 88 | count++; 89 | } 90 | } 91 | }); 92 | 93 | return count; 94 | } 95 | 96 | /** 97 | * Get content from cache 98 | */ 99 | function getFromCache(targetUrl) { 100 | const cacheFile = getCacheFilename(targetUrl); 101 | 102 | if (!fs.existsSync(cacheFile)) { 103 | return null; 104 | } 105 | 106 | const stats = fs.statSync(cacheFile); 107 | const mtime = Math.floor(stats.mtimeMs / 1000); 108 | const now = Math.floor(Date.now() / 1000); 109 | 110 | if (now - mtime > CONFIG.CACHE_TIME) { 111 | cleanExpiredCache(); 112 | return null; 113 | } 114 | 115 | return fs.readFileSync(cacheFile, 'utf8'); 116 | } 117 | 118 | /** 119 | * Write content to cache 120 | */ 121 | function writeToCache(targetUrl, content) { 122 | if (!fs.existsSync(CONFIG.CACHE_DIR)) { 123 | fs.mkdirSync(CONFIG.CACHE_DIR, { recursive: true }); 124 | } 125 | 126 | const cacheFile = getCacheFilename(targetUrl); 127 | fs.writeFileSync(cacheFile, content, 'utf8'); 128 | } 129 | 130 | /** 131 | * Fetch content with type information 132 | */ 133 | function fetchContentWithType(targetUrl) { 134 | return new Promise((resolve, reject) => { 135 | const userAgent = CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 136 | let fetchUrl = targetUrl; 137 | 138 | if (CONFIG.PROXY_URL) { 139 | fetchUrl = CONFIG.PROXY_URL + (CONFIG.PROXY_URLENCODE ? encodeURIComponent(targetUrl) : targetUrl); 140 | } 141 | 142 | const parsedUrl = new URL(fetchUrl); 143 | const options = { 144 | headers: { 145 | 'User-Agent': userAgent, 146 | 'Accept': '*/*', 147 | 'Referer': new URL(targetUrl).origin 148 | } 149 | }; 150 | 151 | const protocol = parsedUrl.protocol === 'https:' ? https : http; 152 | 153 | protocol.get(fetchUrl, options, (res) => { 154 | if (res.statusCode !== 200) { 155 | return reject(new Error(`HTTP error ${res.statusCode}: ${res.statusMessage}`)); 156 | } 157 | 158 | let data = ''; 159 | res.on('data', (chunk) => { 160 | data += chunk; 161 | }); 162 | 163 | res.on('end', () => { 164 | resolve({ 165 | content: data, 166 | contentType: res.headers['content-type'] || '' 167 | }); 168 | }); 169 | }).on('error', (err) => { 170 | reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`)); 171 | }); 172 | }); 173 | } 174 | 175 | /** 176 | * Check if content is M3U8 177 | */ 178 | function isM3u8Content(content, contentType) { 179 | // Check content type 180 | if (contentType && ( 181 | contentType.includes('application/vnd.apple.mpegurl') || 182 | contentType.includes('application/x-mpegurl'))) { 183 | return true; 184 | } 185 | 186 | // Check content signature 187 | if (content && content.trim().startsWith('#EXTM3U')) { 188 | return true; 189 | } 190 | 191 | return false; 192 | } 193 | 194 | /** 195 | * Check if URL points to a media file 196 | */ 197 | function isMediaFile(targetUrl, contentType) { 198 | // Check content type 199 | if (contentType) { 200 | for (const mediaType of CONFIG.MEDIA_CONTENT_TYPES) { 201 | if (contentType.toLowerCase().startsWith(mediaType)) { 202 | return true; 203 | } 204 | } 205 | } 206 | 207 | // Check file extension 208 | const targetUrlLower = targetUrl.toLowerCase(); 209 | for (const ext of CONFIG.MEDIA_FILE_EXTENSIONS) { 210 | if (targetUrlLower.includes(ext) && 211 | (targetUrlLower.includes(`${ext}?`) || targetUrlLower.endsWith(ext))) { 212 | return true; 213 | } 214 | } 215 | 216 | return false; 217 | } 218 | 219 | /** 220 | * Generate proxied TS URL 221 | */ 222 | function proxyTsUrl(targetUrl) { 223 | if (!CONFIG.PROXY_TS) return targetUrl; 224 | 225 | return CONFIG.PROXY_TS + (CONFIG.PROXY_TS_URLENCODE ? encodeURIComponent(targetUrl) : targetUrl); 226 | } 227 | 228 | /** 229 | * Resolve relative URL against base URL 230 | */ 231 | function resolveUrl(baseUrl, relativeUrl) { 232 | if (/^https?:\/\//i.test(relativeUrl)) { 233 | return relativeUrl; 234 | } 235 | 236 | try { 237 | return new URL(relativeUrl, baseUrl).toString(); 238 | } catch (e) { 239 | // Fallback for invalid URLs 240 | const parsedBase = new URL(baseUrl); 241 | if (relativeUrl.startsWith('/')) { 242 | return `${parsedBase.origin}${relativeUrl}`; 243 | } 244 | return `${baseUrl}${relativeUrl}`; 245 | } 246 | } 247 | 248 | /** 249 | * Modify encryption key URI 250 | */ 251 | function modifyKeyUri(line, baseUrl) { 252 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 253 | const absoluteUri = resolveUrl(baseUrl, uri); 254 | return `URI="${proxyTsUrl(absoluteUri)}"`; 255 | }); 256 | } 257 | 258 | /** 259 | * Modify M3U8 content URLs 260 | */ 261 | function modifyM3u8Urls(content, baseUrl) { 262 | const lines = content.split('\n'); 263 | const modified = []; 264 | let isNextLineMedia = false; 265 | 266 | for (const line of lines) { 267 | const trimmed = line.trim(); 268 | 269 | if (!trimmed) { 270 | modified.push(line); 271 | continue; 272 | } 273 | 274 | // Handle EXT-X-MAP 275 | if (trimmed.startsWith('#EXT-X-MAP:')) { 276 | const modifiedLine = trimmed.replace(/URI="([^"]+)"/, (match, uri) => { 277 | const absoluteUri = resolveUrl(baseUrl, uri); 278 | return `URI="${proxyTsUrl(absoluteUri)}"`; 279 | }); 280 | modified.push(modifiedLine); 281 | continue; 282 | } 283 | 284 | // Handle encryption keys 285 | if (trimmed.startsWith('#EXT-X-KEY')) { 286 | modified.push(modifyKeyUri(line, baseUrl)); 287 | continue; 288 | } 289 | 290 | // Handle media segments 291 | if (trimmed.startsWith('#EXTINF:')) { 292 | isNextLineMedia = true; 293 | modified.push(line); 294 | } else if (isNextLineMedia && !trimmed.startsWith('#')) { 295 | const absoluteUrl = resolveUrl(baseUrl, trimmed); 296 | modified.push(proxyTsUrl(absoluteUrl)); 297 | isNextLineMedia = false; 298 | } else { 299 | modified.push(line); 300 | isNextLineMedia = false; 301 | } 302 | } 303 | 304 | return modified.join('\n'); 305 | } 306 | 307 | /** 308 | * Filter discontinuity markers 309 | */ 310 | function filterDiscontinuity(content) { 311 | if (!CONFIG.FILTER_DISCONTINUITY) return content; 312 | 313 | return content.split('\n') 314 | .filter(line => { 315 | const trimmed = line.trim(); 316 | return trimmed && !trimmed.startsWith('#EXT-X-DISCONTINUITY'); 317 | }) 318 | .join('\n'); 319 | } 320 | 321 | /** 322 | * Get base directory URL 323 | */ 324 | function getBaseDirectoryUrl(targetUrl) { 325 | const parsed = new URL(targetUrl); 326 | const pathParts = parsed.pathname.split('/'); 327 | pathParts.pop(); // Remove last part (filename) 328 | parsed.pathname = pathParts.join('/') + '/'; 329 | return parsed.toString(); 330 | } 331 | 332 | /** 333 | * Process M3U8 URL 334 | */ 335 | async function processM3u8Url(targetUrl, res) { 336 | try { 337 | // Try to get from cache 338 | const cached = getFromCache(targetUrl); 339 | if (cached) { 340 | res.writeHead(200, { 341 | 'Content-Type': 'application/vnd.apple.mpegurl', 342 | 'Access-Control-Allow-Origin': '*' 343 | }); 344 | res.end(cached); 345 | return; 346 | } 347 | 348 | // Fetch content with type info 349 | const { content, contentType } = await fetchContentWithType(targetUrl); 350 | 351 | // Check if it's M3U8 content 352 | if (!isM3u8Content(content, contentType)) { 353 | if (isMediaFile(targetUrl, contentType)) { 354 | res.writeHead(302, { 355 | 'Location': proxyTsUrl(targetUrl) 356 | }); 357 | res.end(); 358 | } else { 359 | res.writeHead(302, { 360 | 'Location': targetUrl 361 | }); 362 | res.end(); 363 | } 364 | return; 365 | } 366 | 367 | // Process master playlist with recursion 368 | let currentUrl = targetUrl; 369 | let processedContent = content; 370 | let recursionCount = 0; 371 | 372 | while (processedContent.includes('#EXT-X-STREAM-INF') && recursionCount < CONFIG.MAX_RECURSION) { 373 | const lines = processedContent.split('\n').filter(line => line.trim()); 374 | let variantUrl = ''; 375 | 376 | for (const line of lines) { 377 | if (line.trim().startsWith('#EXT-X-STREAM-INF')) { 378 | // Next non-comment line is the variant URL 379 | const nextLine = lines[lines.indexOf(line) + 1]; 380 | if (nextLine && !nextLine.trim().startsWith('#')) { 381 | variantUrl = resolveUrl(currentUrl, nextLine.trim()); 382 | break; 383 | } 384 | } 385 | } 386 | 387 | if (!variantUrl) break; 388 | 389 | const variantResult = await fetchContentWithType(variantUrl); 390 | processedContent = variantResult.content; 391 | currentUrl = variantUrl; 392 | recursionCount++; 393 | } 394 | 395 | // Process the content 396 | const baseUrl = getBaseDirectoryUrl(currentUrl); 397 | const filtered = filterDiscontinuity(processedContent); 398 | const modified = modifyM3u8Urls(filtered, baseUrl); 399 | 400 | // Write to cache and send response 401 | writeToCache(targetUrl, modified); 402 | res.writeHead(200, { 403 | 'Content-Type': 'application/vnd.apple.mpegurl', 404 | 'Access-Control-Allow-Origin': '*' 405 | }); 406 | res.end(modified); 407 | } catch (error) { 408 | console.error(`Error processing ${targetUrl}:`, error); 409 | res.writeHead(500, { 'Content-Type': 'text/plain' }); 410 | res.end(`Error processing request: ${error.message}`); 411 | } 412 | } 413 | 414 | /** 415 | * Get target URL from request 416 | */ 417 | function getTargetUrl(req) { 418 | const parsedUrl = new URL(req.url, `http://${req.headers.host}`); 419 | let targetUrl = parsedUrl.searchParams.get('url'); 420 | 421 | if (!targetUrl) { 422 | const pathMatch = req.url.match(/\/m3u8filter\/(.+)/); 423 | if (pathMatch && pathMatch[1]) { 424 | targetUrl = decodeURIComponent(pathMatch[1]); 425 | } 426 | } 427 | 428 | return targetUrl; 429 | } 430 | 431 | // ========== Server Setup ========== 432 | const server = http.createServer((req, res) => { 433 | const targetUrl = getTargetUrl(req); 434 | 435 | if (!targetUrl) { 436 | res.writeHead(400, { 'Content-Type': 'text/plain' }); 437 | res.end('Please provide an M3U8 URL via the "url" parameter or /m3u8filter/URL path'); 438 | return; 439 | } 440 | 441 | processM3u8Url(targetUrl, res); 442 | }); 443 | 444 | // Start the server 445 | server.listen(CONFIG.PORT, () => { 446 | console.log(`M3U8 Proxy Server running on port ${CONFIG.PORT}`); 447 | if (!fs.existsSync(CONFIG.CACHE_DIR)) { 448 | fs.mkdirSync(CONFIG.CACHE_DIR, { recursive: true }); 449 | } 450 | }); 451 | -------------------------------------------------------------------------------- /worker-cache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * M3U8 Proxy and Filter for Cloudflare Workers 3 | * 4 | * Features: 5 | * 1. Proxies M3U8 files and rewrites TS/fMP4 segment URLs 6 | * 2. Supports EXT-X-MAP initialization segments 7 | * 3. Handles encrypted streams (EXT-X-KEY) 8 | * 4. Filters discontinuity markers 9 | * 5. Uses Cache API for caching(无kv额度限制且不用配置) 10 | * 6. Auto-resolves master playlists 11 | * 7. Detects non-M3U8 content: 12 | * - If it's a media file (audio/video/image), proxies through TS proxy 13 | * - Otherwise redirects to original URL 14 | */ 15 | 16 | // Configuration 17 | const CONFIG = { 18 | PROXY_URL: 'https://proxy.mengze.vip/proxy/', // Main proxy URL (leave empty for direct fetch) 19 | PROXY_URLENCODE: true, // Whether to URL-encode target URLs 20 | 21 | PROXY_TS: 'https://proxy.mengze.vip/proxy/', // TS segment proxy URL 22 | PROXY_TS_URLENCODE: true, // Whether to URL-encode TS URLs 23 | 24 | CACHE_TTL: 86400, // Cache TTL in seconds (24 hours) 25 | CACHE_NAME: 'm3u8-proxy-cache', // Cache storage name 26 | 27 | MAX_RECURSION: 5, // Max recursion for nested playlists 28 | FILTER_ADS_INTELLIGENTLY: true, // Whether 智能过滤 29 | FILTER_REGEX: null, 30 | 31 | USER_AGENTS: [ 32 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 33 | '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' 34 | ], 35 | 36 | DEBUG: false // Enable debug logging 37 | }; 38 | 39 | // Media file extensions to check 40 | const MEDIA_FILE_EXTENSIONS = [ 41 | // Video formats 42 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 43 | // Audio formats 44 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 45 | // Image formats 46 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 47 | ]; 48 | 49 | // Media content types to check 50 | const MEDIA_CONTENT_TYPES = [ 51 | // Video types 52 | 'video/', 53 | // Audio types 54 | 'audio/', 55 | // Image types 56 | 'image/' 57 | ]; 58 | 59 | /** 60 | * Main request handler 61 | */ 62 | async function handleRequest(request) { 63 | const url = new URL(request.url); 64 | 65 | try { 66 | // Extract target URL 67 | const targetUrl = getTargetUrl(url); 68 | if (!targetUrl) { 69 | return createResponse( 70 | "Please provide an M3U8 URL via the 'url' parameter or /m3u8filter/URL path", 71 | 400, 72 | { "Content-Type": "text/plain" } 73 | ); 74 | } 75 | 76 | // Check cache 77 | const cacheKey = new Request(targetUrl); 78 | const cache = await caches.open(CONFIG.CACHE_NAME); 79 | const cachedResponse = await cache.match(cacheKey); 80 | 81 | if (cachedResponse) { 82 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${targetUrl}`); 83 | const cachedContent = await cachedResponse.text(); 84 | return createM3u8Response(cachedContent); 85 | } 86 | 87 | // Process the M3U8 URL 88 | if (CONFIG.DEBUG) console.log(`[Processing] ${targetUrl}`); 89 | 90 | // Fetch and validate content 91 | const { content, contentType } = await fetchContentWithType(targetUrl); 92 | 93 | // Check if content is actually an M3U8 file 94 | if (!isM3u8Content(content, contentType)) { 95 | // Not an M3U8 file, check if it's a media file 96 | if (isMediaFile(targetUrl, contentType)) { 97 | if (CONFIG.DEBUG) console.log(`[Media file detected] Redirecting to TS proxy: ${targetUrl}`); 98 | return Response.redirect(proxyTsUrl(targetUrl), 302); 99 | } else { 100 | // Not a media file, redirect to original URL 101 | if (CONFIG.DEBUG) console.log(`[Not media content] Redirecting to original URL: ${targetUrl}`); 102 | return Response.redirect(targetUrl, 302); 103 | } 104 | } 105 | 106 | // Process the M3U8 content 107 | let processed = await processM3u8Content(targetUrl, content, 0); 108 | //是否智能过滤广告 109 | if (CONFIG.FILTER_ADS_INTELLIGENTLY) { 110 | processed = SuperFilterAdsFromM3U8(processed, CONFIG.FILTER_REGEX); 111 | } 112 | 113 | // Cache the result 114 | const responseToCache = createM3u8Response(processed); 115 | await cache.put(cacheKey, responseToCache.clone()); 116 | 117 | return responseToCache; 118 | 119 | } catch (error) { 120 | console.error(`[Error] ${error.message}`); 121 | return createResponse( 122 | `Error processing request: ${error.message}`, 123 | 500, 124 | { "Content-Type": "text/plain" } 125 | ); 126 | } 127 | } 128 | 129 | 130 | /** 131 | * 超级M3U8广告算法过滤器 132 | * @param {string} m3u8Content - 原始M3U8内容 133 | * @param {string|null} regexFilter - 可选的正则过滤规则 134 | * @return {string} 过滤后的完整M3U8内容 135 | */ 136 | function SuperFilterAdsFromM3U8(m3u8Content, regexFilter = null) { 137 | if (!m3u8Content) return ''; 138 | 139 | // ==================== 第一阶段:预处理 ==================== 140 | // 1. 正则过滤 141 | let processedContent = regexFilter 142 | ? applyRegexFilter(m3u8Content, regexFilter) 143 | : m3u8Content; 144 | 145 | // 2. 解析M3U8结构 146 | const { segments, headers } = parseM3U8Structure(processedContent); 147 | if (segments.length === 0) return processedContent; 148 | 149 | // ==================== 第二阶段:科学分析 ==================== 150 | // 1. 计算基础统计量 151 | const stats = calculateSegmentStats(segments); 152 | 153 | // 2. 多维度广告检测 154 | const analyzedSegments = analyzeSegments(segments, stats); 155 | 156 | // 3. 智能过滤决策 157 | const filteredSegments = applyFilterDecision(analyzedSegments, stats); 158 | 159 | // ==================== 第三阶段:重建M3U8 ==================== 160 | return rebuildM3U8(headers, filteredSegments, processedContent); 161 | } 162 | 163 | // ==================== 辅助函数 ==================== 164 | 165 | /** 166 | * 应用正则过滤 167 | */ 168 | function applyRegexFilter(content, regexFilter) { 169 | try { 170 | const regex = new RegExp(regexFilter, 'gi'); 171 | return content.replace(regex, ''); 172 | } catch (e) { 173 | console.warn('正则过滤失败:', e); 174 | return content; 175 | } 176 | } 177 | 178 | /** 179 | * 深度解析M3U8结构 180 | */ 181 | function parseM3U8Structure(content) { 182 | const lines = content.split('\n'); 183 | const segments = []; 184 | const headers = { 185 | main: [], 186 | other: [] 187 | }; 188 | let currentDiscontinuity = false; 189 | let currentMap = null; 190 | let segmentIndex = 0; 191 | 192 | for (let i = 0; i < lines.length; i++) { 193 | const line = lines[i].trim(); 194 | 195 | // 收集头部信息 196 | if (i < 10 && line.startsWith('#EXT')) { 197 | headers.main.push(line); 198 | continue; 199 | } 200 | 201 | // 处理关键标签 202 | if (line.startsWith('#EXT-X-MAP:')) { 203 | currentMap = line; 204 | continue; 205 | } 206 | 207 | if (line.includes('#EXT-X-DISCONTINUITY')) { 208 | currentDiscontinuity = true; 209 | continue; 210 | } 211 | 212 | // 解析片段 213 | if (line.startsWith('#EXTINF:')) { 214 | const durationMatch = line.match(/#EXTINF:([\d.]+)/); 215 | if (durationMatch && lines[i + 1] && !lines[i + 1].startsWith('#')) { 216 | const duration = parseFloat(durationMatch[1]); 217 | const url = lines[i + 1].trim(); 218 | 219 | segments.push({ 220 | index: segmentIndex++, 221 | startLine: i, 222 | endLine: i + 1, 223 | duration, 224 | url, 225 | hasDiscontinuity: currentDiscontinuity, 226 | hasMap: currentMap !== null, 227 | content: currentMap 228 | ? [currentMap, line, lines[i + 1]].join('\n') 229 | : [line, lines[i + 1]].join('\n'), 230 | isAd: false, // 初始标记 231 | adScore: 0 // 广告概率得分 232 | }); 233 | 234 | currentDiscontinuity = false; 235 | currentMap = null; 236 | i++; // 跳过URL行 237 | } 238 | } else if (line.startsWith('#')) { 239 | headers.other.push(line); 240 | } 241 | } 242 | 243 | return { segments, headers }; 244 | } 245 | 246 | /** 247 | * 计算高级统计量 248 | */ 249 | function calculateSegmentStats(segments) { 250 | const durations = segments.map(s => s.duration); 251 | const totalDuration = durations.reduce((sum, d) => sum + d, 0); 252 | const avgDuration = totalDuration / durations.length; 253 | 254 | // 计算标准差和百分位数 255 | const squaredDiffs = durations.map(d => Math.pow(d - avgDuration, 2)); 256 | const stdDev = Math.sqrt(squaredDiffs.reduce((sum, sd) => sum + sd, 0) / durations.length); 257 | 258 | // 排序后的时长数组用于百分位计算 259 | const sortedDurations = [...durations].sort((a, b) => a - b); 260 | const p10 = sortedDurations[Math.floor(durations.length * 0.1)]; 261 | const p90 = sortedDurations[Math.floor(durations.length * 0.9)]; 262 | 263 | return { 264 | avgDuration, 265 | stdDev, 266 | p10, 267 | p90, 268 | totalDuration, 269 | segmentCount: segments.length, 270 | durationRange: [sortedDurations[0], sortedDurations[sortedDurations.length - 1]] 271 | }; 272 | } 273 | 274 | /** 275 | * 多维度片段分析 276 | */ 277 | function analyzeSegments(segments, stats) { 278 | const { avgDuration, stdDev, p10, p90 } = stats; 279 | 280 | return segments.map(segment => { 281 | const deviation = Math.abs(segment.duration - avgDuration); 282 | const zScore = deviation / stdDev; 283 | 284 | // 1. 时长异常检测 285 | const durationAbnormality = Math.min(1, zScore / 3); // 0-1范围 286 | 287 | // 2. 位置异常检测(开头/结尾的短片段更可能是广告) 288 | let positionFactor = 0; 289 | if (segment.index < 3 && segment.duration < p10) { 290 | positionFactor = 0.8; // 开头的短片段很可疑 291 | } else if (segment.index > segments.length - 3 && segment.duration < p10) { 292 | positionFactor = 0.5; // 结尾的短片段中等可疑 293 | } 294 | 295 | // 3. 不连续标记检测 296 | const discontinuityFactor = segment.hasDiscontinuity ? 0.3 : 0; 297 | 298 | // 综合广告概率 299 | const adScore = Math.min(1, 300 | (durationAbnormality * 0.6) + 301 | (positionFactor * 0.3) + 302 | (discontinuityFactor * 0.1) 303 | ); 304 | 305 | return { 306 | ...segment, 307 | adScore, 308 | isAd: adScore > 0.65, // 阈值可调整 309 | stats: { deviation, zScore } 310 | }; 311 | }); 312 | } 313 | 314 | /** 315 | * 智能过滤决策 316 | */ 317 | function applyFilterDecision(segments, stats) { 318 | const { avgDuration, stdDev } = stats; 319 | 320 | // 动态调整阈值 321 | const baseThreshold = 0.65; 322 | const dynamicThreshold = Math.min(0.8, Math.max(0.5, 323 | baseThreshold - (stdDev / avgDuration) * 0.2 324 | )); 325 | 326 | return segments.filter(segment => { 327 | // 明确广告标记 328 | if (segment.isAd && segment.adScore > dynamicThreshold) { 329 | return false; 330 | } 331 | 332 | // 极短片段过滤(<1秒且不在开头) 333 | if (segment.duration < 1.0 && segment.index > 3) { 334 | return false; 335 | } 336 | 337 | // 保留关键片段(如包含MAP的) 338 | if (segment.hasMap) { 339 | return true; 340 | } 341 | 342 | // 默认保留 343 | return true; 344 | }); 345 | } 346 | 347 | /** 348 | * 完美重建M3U8 349 | */ 350 | function rebuildM3U8(headers, segments, originalContent) { 351 | // 收集需要保留的行号 352 | const keepLines = new Set(); 353 | 354 | // 保留所有头部信息 355 | headers.main.forEach((_, i) => keepLines.add(i)); 356 | 357 | // 保留所有片段内容 358 | segments.forEach(segment => { 359 | for (let i = segment.startLine; i <= segment.endLine; i++) { 360 | keepLines.add(i); 361 | } 362 | }); 363 | 364 | // 处理其他关键标签 365 | const lines = originalContent.split('\n'); 366 | const criticalTags = [ 367 | '#EXT-X-VERSION', 368 | '#EXT-X-TARGETDURATION', 369 | '#EXT-X-MEDIA-SEQUENCE', 370 | '#EXT-X-PLAYLIST-TYPE', 371 | '#EXT-X-ENDLIST' 372 | ]; 373 | 374 | for (let i = 0; i < lines.length; i++) { 375 | const line = lines[i].trim(); 376 | if (criticalTags.some(tag => line.startsWith(tag))) { 377 | keepLines.add(i); 378 | } 379 | } 380 | 381 | // 重建内容 382 | const filteredLines = lines.filter((_, i) => keepLines.has(i)); 383 | 384 | // 更新关键头部信息 385 | updateM3U8Headers(filteredLines, segments); 386 | 387 | return filteredLines.join('\n'); 388 | } 389 | 390 | /** 391 | * 更新M3U8头部信息 392 | */ 393 | function updateM3U8Headers(lines, segments) { 394 | if (segments.length === 0) return; 395 | 396 | // 更新TARGETDURATION 397 | const maxDuration = Math.max(...segments.map(s => s.duration)); 398 | for (let i = 0; i < lines.length; i++) { 399 | if (lines[i].startsWith('#EXT-X-TARGETDURATION')) { 400 | lines[i] = `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`; 401 | break; 402 | } 403 | } 404 | 405 | // 更新MEDIA-SEQUENCE 406 | if (segments[0].index > 0) { 407 | for (let i = 0; i < lines.length; i++) { 408 | if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE')) { 409 | lines[i] = `#EXT-X-MEDIA-SEQUENCE:${segments[0].index}`; 410 | break; 411 | } 412 | } 413 | } 414 | } 415 | 416 | 417 | 418 | /** 419 | * Check if content is a valid M3U8 file 420 | */ 421 | function isM3u8Content(content, contentType) { 422 | // Check content type header 423 | if (contentType && ( 424 | contentType.includes('application/vnd.apple.mpegurl') || 425 | contentType.includes('application/x-mpegurl'))) { 426 | return true; 427 | } 428 | 429 | // Check content for M3U8 signature 430 | if (content && content.trim().startsWith('#EXTM3U')) { 431 | return true; 432 | } 433 | 434 | return false; 435 | } 436 | 437 | /** 438 | * Check if the file is a media file based on extension and content type 439 | */ 440 | function isMediaFile(url, contentType) { 441 | // Check by content type 442 | if (contentType) { 443 | for (const mediaType of MEDIA_CONTENT_TYPES) { 444 | if (contentType.toLowerCase().startsWith(mediaType)) { 445 | return true; 446 | } 447 | } 448 | } 449 | 450 | // Check by file extension 451 | const urlLower = url.toLowerCase(); 452 | for (const ext of MEDIA_FILE_EXTENSIONS) { 453 | // Check if URL ends with the extension or has it followed by a query parameter 454 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 455 | return true; 456 | } 457 | } 458 | 459 | return false; 460 | } 461 | 462 | /** 463 | * Extract target URL from request 464 | */ 465 | function getTargetUrl(url) { 466 | // Check query parameter 467 | if (url.searchParams.has('url')) { 468 | return url.searchParams.get('url'); 469 | } 470 | 471 | // Check path format: /m3u8filter/URL 472 | const pathMatch = url.pathname.match(/^\/m3u8filter\/(.+)/); 473 | if (pathMatch && pathMatch[1]) { 474 | return decodeURIComponent(pathMatch[1]); 475 | } 476 | 477 | return null; 478 | } 479 | 480 | /** 481 | * Create a standardized response 482 | */ 483 | function createResponse(body, status = 200, headers = {}) { 484 | const responseHeaders = new Headers(headers); 485 | responseHeaders.set("Access-Control-Allow-Origin", "*"); 486 | 487 | return new Response(body, { 488 | status, 489 | headers: responseHeaders 490 | }); 491 | } 492 | 493 | /** 494 | * Create an M3U8 response with proper headers 495 | */ 496 | function createM3u8Response(content) { 497 | return createResponse(content, 200, { 498 | "Content-Type": "application/vnd.apple.mpegurl", 499 | "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL}` 500 | }); 501 | } 502 | 503 | /** 504 | * Fetch content with content type information 505 | */ 506 | async function fetchContentWithType(url) { 507 | const headers = new Headers({ 508 | 'User-Agent': getRandomUserAgent(), 509 | 'Accept': '*/*', 510 | 'Referer': new URL(url).origin 511 | }); 512 | 513 | let fetchUrl = url; 514 | if (CONFIG.PROXY_URL) { 515 | fetchUrl = CONFIG.PROXY_URLENCODE 516 | ? `${CONFIG.PROXY_URL}${encodeURIComponent(url)}` 517 | : `${CONFIG.PROXY_URL}${url}`; 518 | } 519 | 520 | try { 521 | const response = await fetch(fetchUrl, { headers }); 522 | if (!response.ok) { 523 | throw new Error(`HTTP error ${response.status}: ${response.statusText}`); 524 | } 525 | 526 | const content = await response.text(); 527 | const contentType = response.headers.get('Content-Type') || ''; 528 | 529 | return { content, contentType }; 530 | } catch (error) { 531 | throw new Error(`Failed to fetch ${url}: ${error.message}`); 532 | } 533 | } 534 | 535 | /** 536 | * Fetch content with proper headers 537 | */ 538 | async function fetchContent(url) { 539 | const { content } = await fetchContentWithType(url); 540 | return content; 541 | } 542 | 543 | /** 544 | * Process M3U8 content from the initial URL 545 | */ 546 | async function processM3u8Content(url, content, recursionDepth = 0) { 547 | // Check if this is a master playlist 548 | if (content.includes('#EXT-X-STREAM-INF')) { 549 | if (CONFIG.DEBUG) console.log(`[Master playlist detected] ${url}`); 550 | return await processMasterPlaylist(url, content, recursionDepth); 551 | } 552 | 553 | // Process as a media playlist 554 | if (CONFIG.DEBUG) console.log(`[Media playlist] ${url}`); 555 | return processMediaPlaylist(url, content); 556 | } 557 | 558 | /** 559 | * Process a master playlist by selecting the first variant stream 560 | */ 561 | async function processMasterPlaylist(url, content, recursionDepth) { 562 | if (recursionDepth > CONFIG.MAX_RECURSION) { 563 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 564 | } 565 | 566 | const baseUrl = getBaseUrl(url); 567 | const lines = content.split('\n'); 568 | 569 | let variantUrl = ''; 570 | 571 | // Find the first variant stream URL 572 | for (let i = 0; i < lines.length; i++) { 573 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 574 | // The next non-comment line should be the variant URL 575 | for (let j = i + 1; j < lines.length; j++) { 576 | const line = lines[j].trim(); 577 | if (line && !line.startsWith('#')) { 578 | variantUrl = resolveUrl(baseUrl, line); 579 | break; 580 | } 581 | } 582 | if (variantUrl) break; 583 | } 584 | } 585 | 586 | if (!variantUrl) { 587 | throw new Error('No variant stream found in master playlist'); 588 | } 589 | 590 | // Check cache first for variant 591 | const cache = await caches.open(CONFIG.CACHE_NAME); 592 | const cacheKey = new Request(variantUrl); 593 | const cachedResponse = await cache.match(cacheKey); 594 | 595 | if (cachedResponse) { 596 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${variantUrl}`); 597 | return await cachedResponse.text(); 598 | } 599 | 600 | // Recursively process the variant stream 601 | if (CONFIG.DEBUG) console.log(`[Selected variant] ${variantUrl}`); 602 | const variantContent = await fetchContent(variantUrl); 603 | const processed = await processM3u8Content(variantUrl, variantContent, recursionDepth + 1); 604 | 605 | // Cache the variant result 606 | const responseToCache = createM3u8Response(processed); 607 | await cache.put(cacheKey, responseToCache.clone()); 608 | 609 | return processed; 610 | } 611 | 612 | /** 613 | * Process a media playlist by rewriting segment URLs 614 | */ 615 | function processMediaPlaylist(url, content) { 616 | const baseUrl = getBaseUrl(url); 617 | const lines = content.split('\n'); 618 | const output = []; 619 | 620 | let isNextLineSegment = false; 621 | 622 | for (let i = 0; i < lines.length; i++) { 623 | const line = lines[i].trim(); 624 | 625 | // Skip empty lines 626 | if (!line) continue; 627 | 628 | // Handle EXT-X-KEY (encryption) 629 | if (line.startsWith('#EXT-X-KEY')) { 630 | output.push(processKeyLine(line, baseUrl)); 631 | continue; 632 | } 633 | 634 | // Handle EXT-X-MAP (initialization segment) 635 | if (line.startsWith('#EXT-X-MAP')) { 636 | output.push(processMapLine(line, baseUrl)); 637 | continue; 638 | } 639 | 640 | // Mark segment lines 641 | if (line.startsWith('#EXTINF')) { 642 | isNextLineSegment = true; 643 | output.push(line); 644 | continue; 645 | } 646 | 647 | // Process segment URLs 648 | if (isNextLineSegment && !line.startsWith('#')) { 649 | const absoluteUrl = resolveUrl(baseUrl, line); 650 | output.push(proxyTsUrl(absoluteUrl)); 651 | isNextLineSegment = false; 652 | continue; 653 | } 654 | 655 | // Pass through all other lines 656 | output.push(line); 657 | } 658 | 659 | return output.join('\n'); 660 | } 661 | 662 | /** 663 | * Process EXT-X-KEY line by proxying the key URL 664 | */ 665 | function processKeyLine(line, baseUrl) { 666 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 667 | const absoluteUri = resolveUrl(baseUrl, uri); 668 | return `URI="${proxyTsUrl(absoluteUri)}"`; 669 | }); 670 | } 671 | 672 | /** 673 | * Process EXT-X-MAP line by proxying the map URL 674 | */ 675 | function processMapLine(line, baseUrl) { 676 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 677 | const absoluteUri = resolveUrl(baseUrl, uri); 678 | return `URI="${proxyTsUrl(absoluteUri)}"`; 679 | }); 680 | } 681 | 682 | /** 683 | * Apply TS proxy to a URL 684 | */ 685 | function proxyTsUrl(url) { 686 | if (!CONFIG.PROXY_TS) return url; 687 | 688 | return CONFIG.PROXY_TS_URLENCODE 689 | ? `${CONFIG.PROXY_TS}${encodeURIComponent(url)}` 690 | : `${CONFIG.PROXY_TS}${url}`; 691 | } 692 | 693 | /** 694 | * Get a random user agent from the configured list 695 | */ 696 | function getRandomUserAgent() { 697 | return CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 698 | } 699 | 700 | /** 701 | * Extract the base URL from a full URL 702 | */ 703 | function getBaseUrl(url) { 704 | try { 705 | const parsedUrl = new URL(url); 706 | const pathParts = parsedUrl.pathname.split('/'); 707 | pathParts.pop(); // Remove the last part (filename) 708 | 709 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 710 | } catch (e) { 711 | // Fallback: find the last slash 712 | const lastSlashIndex = url.lastIndexOf('/'); 713 | return lastSlashIndex > 8 ? url.substring(0, lastSlashIndex + 1) : url; 714 | } 715 | } 716 | 717 | /** 718 | * Resolve a relative URL against a base URL 719 | */ 720 | function resolveUrl(baseUrl, relativeUrl) { 721 | // Already absolute URL 722 | if (relativeUrl.match(/^https?:\/\//i)) { 723 | return relativeUrl; 724 | } 725 | 726 | try { 727 | return new URL(relativeUrl, baseUrl).toString(); 728 | } catch (e) { 729 | // Simple fallback 730 | if (relativeUrl.startsWith('/')) { 731 | const urlObj = new URL(baseUrl); 732 | return `${urlObj.origin}${relativeUrl}`; 733 | } 734 | return `${baseUrl}${relativeUrl}`; 735 | } 736 | } 737 | 738 | // Main handler using ES Modules syntax 739 | export default { 740 | async fetch(request) { 741 | return handleRequest(request); 742 | } 743 | }; 744 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * M3U8 Proxy and Filter for Cloudflare Workers 3 | * 4 | * Features: 5 | * 1. Proxies M3U8 files and rewrites TS/fMP4 segment URLs 6 | * 2. Supports EXT-X-MAP initialization segments 7 | * 3. Handles encrypted streams (EXT-X-KEY) 8 | * 4. Filters discontinuity markers 9 | * 5. Uses KV for caching 10 | * 6. Auto-resolves master playlists 11 | * 7. Detects non-M3U8 content: 12 | * - If it's a media file (audio/video/image), proxies through TS proxy 13 | * - Otherwise redirects to original URL 14 | * 8. cf部署时需要创建kv存储,绑定时,需要设置变量名称为M3U8_PROXY_KV 15 | */ 16 | 17 | // Configuration 18 | const CONFIG = { 19 | PROXY_URL: 'https://proxy.mengze.vip/proxy/', // Main proxy URL (leave empty for direct fetch) 20 | PROXY_URLENCODE: true, // Whether to URL-encode target URLs 21 | 22 | PROXY_TS: 'https://proxy.mengze.vip/proxy/', // TS segment proxy URL 23 | PROXY_TS_URLENCODE: true, // Whether to URL-encode TS URLs 24 | 25 | CACHE_TTL: 86400, // Cache TTL in seconds (24 hours) 26 | 27 | MAX_RECURSION: 5, // Max recursion for nested playlists 28 | FILTER_DISCONTINUITY: true, // Whether to filter discontinuity markers 29 | 30 | USER_AGENTS: [ 31 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 32 | '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' 33 | ], 34 | 35 | DEBUG: false // Enable debug logging 36 | }; 37 | 38 | // Media file extensions to check 39 | const MEDIA_FILE_EXTENSIONS = [ 40 | // Video formats 41 | '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', 42 | // Audio formats 43 | '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', 44 | // Image formats 45 | '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' 46 | ]; 47 | 48 | // Media content types to check 49 | const MEDIA_CONTENT_TYPES = [ 50 | // Video types 51 | 'video/', 52 | // Audio types 53 | 'audio/', 54 | // Image types 55 | 'image/' 56 | ]; 57 | 58 | /** 59 | * Main request handler 60 | */ 61 | async function handleRequest(request, env) { 62 | const url = new URL(request.url); 63 | 64 | try { 65 | // Extract target URL 66 | const targetUrl = getTargetUrl(url); 67 | if (!targetUrl) { 68 | return createResponse( 69 | "Please provide an M3U8 URL via the 'url' parameter or /m3u8filter/URL path", 70 | 400, 71 | { "Content-Type": "text/plain" } 72 | ); 73 | } 74 | 75 | // Check cache 76 | const cacheKey = `m3u8:${targetUrl}`; 77 | const cachedContent = await env.M3U8_PROXY_KV.get(cacheKey); 78 | if (cachedContent) { 79 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${targetUrl}`); 80 | return createM3u8Response(cachedContent); 81 | } 82 | 83 | // Process the M3U8 URL 84 | if (CONFIG.DEBUG) console.log(`[Processing] ${targetUrl}`); 85 | 86 | // Fetch and validate content 87 | const { content, contentType } = await fetchContentWithType(targetUrl); 88 | 89 | // Check if content is actually an M3U8 file 90 | if (!isM3u8Content(content, contentType)) { 91 | // Not an M3U8 file, check if it's a media file 92 | if (isMediaFile(targetUrl, contentType)) { 93 | if (CONFIG.DEBUG) console.log(`[Media file detected] Redirecting to TS proxy: ${targetUrl}`); 94 | return Response.redirect(proxyTsUrl(targetUrl), 302); 95 | } else { 96 | // Not a media file, redirect to original URL 97 | if (CONFIG.DEBUG) console.log(`[Not media content] Redirecting to original URL: ${targetUrl}`); 98 | return Response.redirect(targetUrl, 302); 99 | } 100 | } 101 | 102 | // Process the M3U8 content 103 | const processed = await processM3u8Content(targetUrl, content, 0, env); 104 | 105 | // Cache the result 106 | await env.M3U8_PROXY_KV.put(cacheKey, processed, { expirationTtl: CONFIG.CACHE_TTL }); 107 | 108 | return createM3u8Response(processed); 109 | 110 | } catch (error) { 111 | console.error(`[Error] ${error.message}`); 112 | return createResponse( 113 | `Error processing request: ${error.message}`, 114 | 500, 115 | { "Content-Type": "text/plain" } 116 | ); 117 | } 118 | } 119 | 120 | /** 121 | * Check if content is a valid M3U8 file 122 | */ 123 | function isM3u8Content(content, contentType) { 124 | // Check content type header 125 | if (contentType && ( 126 | contentType.includes('application/vnd.apple.mpegurl') || 127 | contentType.includes('application/x-mpegurl'))) { 128 | return true; 129 | } 130 | 131 | // Check content for M3U8 signature 132 | if (content && content.trim().startsWith('#EXTM3U')) { 133 | return true; 134 | } 135 | 136 | return false; 137 | } 138 | 139 | /** 140 | * Check if the file is a media file based on extension and content type 141 | */ 142 | function isMediaFile(url, contentType) { 143 | // Check by content type 144 | if (contentType) { 145 | for (const mediaType of MEDIA_CONTENT_TYPES) { 146 | if (contentType.toLowerCase().startsWith(mediaType)) { 147 | return true; 148 | } 149 | } 150 | } 151 | 152 | // Check by file extension 153 | const urlLower = url.toLowerCase(); 154 | for (const ext of MEDIA_FILE_EXTENSIONS) { 155 | // Check if URL ends with the extension or has it followed by a query parameter 156 | if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { 157 | return true; 158 | } 159 | } 160 | 161 | return false; 162 | } 163 | 164 | /** 165 | * Extract target URL from request 166 | */ 167 | function getTargetUrl(url) { 168 | // Check query parameter 169 | if (url.searchParams.has('url')) { 170 | return url.searchParams.get('url'); 171 | } 172 | 173 | // Check path format: /m3u8filter/URL 174 | const pathMatch = url.pathname.match(/^\/m3u8filter\/(.+)/); 175 | if (pathMatch && pathMatch[1]) { 176 | return decodeURIComponent(pathMatch[1]); 177 | } 178 | 179 | return null; 180 | } 181 | 182 | /** 183 | * Create a standardized response 184 | */ 185 | function createResponse(body, status = 200, headers = {}) { 186 | const responseHeaders = new Headers(headers); 187 | responseHeaders.set("Access-Control-Allow-Origin", "*"); 188 | 189 | return new Response(body, { 190 | status, 191 | headers: responseHeaders 192 | }); 193 | } 194 | 195 | /** 196 | * Create an M3U8 response with proper headers 197 | */ 198 | function createM3u8Response(content) { 199 | return createResponse(content, 200, { 200 | "Content-Type": "application/vnd.apple.mpegurl", 201 | "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL}` 202 | }); 203 | } 204 | 205 | /** 206 | * Fetch content with content type information 207 | */ 208 | async function fetchContentWithType(url) { 209 | const headers = new Headers({ 210 | 'User-Agent': getRandomUserAgent(), 211 | 'Accept': '*/*', 212 | 'Referer': new URL(url).origin 213 | }); 214 | 215 | let fetchUrl = url; 216 | if (CONFIG.PROXY_URL) { 217 | fetchUrl = CONFIG.PROXY_URLENCODE 218 | ? `${CONFIG.PROXY_URL}${encodeURIComponent(url)}` 219 | : `${CONFIG.PROXY_URL}${url}`; 220 | } 221 | 222 | try { 223 | const response = await fetch(fetchUrl, { headers }); 224 | if (!response.ok) { 225 | throw new Error(`HTTP error ${response.status}: ${response.statusText}`); 226 | } 227 | 228 | const content = await response.text(); 229 | const contentType = response.headers.get('Content-Type') || ''; 230 | 231 | return { content, contentType }; 232 | } catch (error) { 233 | throw new Error(`Failed to fetch ${url}: ${error.message}`); 234 | } 235 | } 236 | 237 | /** 238 | * Fetch content with proper headers 239 | */ 240 | async function fetchContent(url) { 241 | const { content } = await fetchContentWithType(url); 242 | return content; 243 | } 244 | 245 | /** 246 | * Process M3U8 content from the initial URL 247 | */ 248 | async function processM3u8Content(url, content, recursionDepth = 0, env) { 249 | // Check if this is a master playlist 250 | if (content.includes('#EXT-X-STREAM-INF')) { 251 | if (CONFIG.DEBUG) console.log(`[Master playlist detected] ${url}`); 252 | return await processMasterPlaylist(url, content, recursionDepth, env); 253 | } 254 | 255 | // Process as a media playlist 256 | if (CONFIG.DEBUG) console.log(`[Media playlist] ${url}`); 257 | return processMediaPlaylist(url, content); 258 | } 259 | 260 | /** 261 | * Process a master playlist by selecting the first variant stream 262 | */ 263 | async function processMasterPlaylist(url, content, recursionDepth, env) { 264 | if (recursionDepth > CONFIG.MAX_RECURSION) { 265 | throw new Error(`Maximum recursion depth (${CONFIG.MAX_RECURSION}) exceeded`); 266 | } 267 | 268 | const baseUrl = getBaseUrl(url); 269 | const lines = content.split('\n'); 270 | 271 | let variantUrl = ''; 272 | 273 | // Find the first variant stream URL 274 | for (let i = 0; i < lines.length; i++) { 275 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) { 276 | // The next non-comment line should be the variant URL 277 | for (let j = i + 1; j < lines.length; j++) { 278 | const line = lines[j].trim(); 279 | if (line && !line.startsWith('#')) { 280 | variantUrl = resolveUrl(baseUrl, line); 281 | break; 282 | } 283 | } 284 | if (variantUrl) break; 285 | } 286 | } 287 | 288 | if (!variantUrl) { 289 | throw new Error('No variant stream found in master playlist'); 290 | } 291 | 292 | // Check cache first for variant 293 | const cacheKey = `m3u8:${variantUrl}`; 294 | const cachedContent = await env.M3U8_PROXY_KV.get(cacheKey); 295 | if (cachedContent) { 296 | if (CONFIG.DEBUG) console.log(`[Cache hit] ${variantUrl}`); 297 | return cachedContent; 298 | } 299 | 300 | // Recursively process the variant stream 301 | if (CONFIG.DEBUG) console.log(`[Selected variant] ${variantUrl}`); 302 | const variantContent = await fetchContent(variantUrl); 303 | const processed = await processM3u8Content(variantUrl, variantContent, recursionDepth + 1, env); 304 | 305 | // Cache the variant result 306 | await env.M3U8_PROXY_KV.put(cacheKey, processed, { expirationTtl: CONFIG.CACHE_TTL }); 307 | 308 | return processed; 309 | } 310 | 311 | /** 312 | * Process a media playlist by rewriting segment URLs 313 | */ 314 | function processMediaPlaylist(url, content) { 315 | const baseUrl = getBaseUrl(url); 316 | const lines = content.split('\n'); 317 | const output = []; 318 | 319 | let isNextLineSegment = false; 320 | 321 | for (let i = 0; i < lines.length; i++) { 322 | const line = lines[i].trim(); 323 | 324 | // Skip empty lines 325 | if (!line) continue; 326 | 327 | // Filter discontinuity markers if enabled 328 | if (CONFIG.FILTER_DISCONTINUITY && line === '#EXT-X-DISCONTINUITY') { 329 | continue; 330 | } 331 | 332 | // Handle EXT-X-KEY (encryption) 333 | if (line.startsWith('#EXT-X-KEY')) { 334 | output.push(processKeyLine(line, baseUrl)); 335 | continue; 336 | } 337 | 338 | // Handle EXT-X-MAP (initialization segment) 339 | if (line.startsWith('#EXT-X-MAP')) { 340 | output.push(processMapLine(line, baseUrl)); 341 | continue; 342 | } 343 | 344 | // Mark segment lines 345 | if (line.startsWith('#EXTINF')) { 346 | isNextLineSegment = true; 347 | output.push(line); 348 | continue; 349 | } 350 | 351 | // Process segment URLs 352 | if (isNextLineSegment && !line.startsWith('#')) { 353 | const absoluteUrl = resolveUrl(baseUrl, line); 354 | output.push(proxyTsUrl(absoluteUrl)); 355 | isNextLineSegment = false; 356 | continue; 357 | } 358 | 359 | // Pass through all other lines 360 | output.push(line); 361 | } 362 | 363 | return output.join('\n'); 364 | } 365 | 366 | /** 367 | * Process EXT-X-KEY line by proxying the key URL 368 | */ 369 | function processKeyLine(line, baseUrl) { 370 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 371 | const absoluteUri = resolveUrl(baseUrl, uri); 372 | return `URI="${proxyTsUrl(absoluteUri)}"`; 373 | }); 374 | } 375 | 376 | /** 377 | * Process EXT-X-MAP line by proxying the map URL 378 | */ 379 | function processMapLine(line, baseUrl) { 380 | return line.replace(/URI="([^"]+)"/, (match, uri) => { 381 | const absoluteUri = resolveUrl(baseUrl, uri); 382 | return `URI="${proxyTsUrl(absoluteUri)}"`; 383 | }); 384 | } 385 | 386 | /** 387 | * Apply TS proxy to a URL 388 | */ 389 | function proxyTsUrl(url) { 390 | if (!CONFIG.PROXY_TS) return url; 391 | 392 | return CONFIG.PROXY_TS_URLENCODE 393 | ? `${CONFIG.PROXY_TS}${encodeURIComponent(url)}` 394 | : `${CONFIG.PROXY_TS}${url}`; 395 | } 396 | 397 | /** 398 | * Get a random user agent from the configured list 399 | */ 400 | function getRandomUserAgent() { 401 | return CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; 402 | } 403 | 404 | /** 405 | * Extract the base URL from a full URL 406 | */ 407 | function getBaseUrl(url) { 408 | try { 409 | const parsedUrl = new URL(url); 410 | const pathParts = parsedUrl.pathname.split('/'); 411 | pathParts.pop(); // Remove the last part (filename) 412 | 413 | return `${parsedUrl.origin}${pathParts.join('/')}/`; 414 | } catch (e) { 415 | // Fallback: find the last slash 416 | const lastSlashIndex = url.lastIndexOf('/'); 417 | return lastSlashIndex > 8 ? url.substring(0, lastSlashIndex + 1) : url; 418 | } 419 | } 420 | 421 | /** 422 | * Resolve a relative URL against a base URL 423 | */ 424 | function resolveUrl(baseUrl, relativeUrl) { 425 | // Already absolute URL 426 | if (relativeUrl.match(/^https?:\/\//i)) { 427 | return relativeUrl; 428 | } 429 | 430 | try { 431 | return new URL(relativeUrl, baseUrl).toString(); 432 | } catch (e) { 433 | // Simple fallback 434 | if (relativeUrl.startsWith('/')) { 435 | const urlObj = new URL(baseUrl); 436 | return `${urlObj.origin}${relativeUrl}`; 437 | } 438 | return `${baseUrl}${relativeUrl}`; 439 | } 440 | } 441 | 442 | // Main handler using ES Modules syntax 443 | export default { 444 | async fetch(request, env) { 445 | return handleRequest(request, env); 446 | } 447 | }; 448 | --------------------------------------------------------------------------------