├── .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 |
--------------------------------------------------------------------------------