先说一句题外话:总有人问我某视频平台的 m3u8 链接怎么扒、怎么下。我每次都会先说,真正带版权保护的商业平台内容,不该碰也不要去碰,这篇文章也只讨论你有权获取的内容,比如自己上传的视频、公开的测试流、无版权素材等。搞清楚边界之后,再用 Python 去处理 m3u8 加密链接,才是一门正经技术活。
这篇文章解决一个很具体的问题:浏览器能播的 m3u8,你用 requests 拉回来一堆以#开头的文本,下载下来的.ts文件全是乱码,甚至根本播不了。大多数情况是链接做了 AES-128 加密,或者请求头缺了 Referer / User-Agent。我会把 HLS 协议的原理讲明白,再给一份可以直接跑的 Python 代码,最后把常见坑和排查思路整理成速查表。适合刚接触爬虫和流媒体协议的读者,也适合已经写过简单下载脚本、但遇到加密流就懵的人。
1. 先搞懂 M3U8 是什么,再谈下载
1.1 HLS 协议为什么要把视频切成一堆小片段
M3U8 本质上是 HLS(HTTP Live Streaming)协议的索引文件。苹果当年设计 HLS,核心思路是“不要把整部电影一次性推给用户”,而是把视频切成很多个时长几秒到十几秒的小片段,每个片段是一个独立的.ts文件,然后由一个.m3u8索引文件把这些片段按顺序串起来。
你可以在电脑上打开任意一个能播 m3u8 的网页,按 F12 打开开发者工具,切到 Network 面板,刷新页面,过滤 “m3u8” 或 “ts”,基本都能看到一堆片段请求。这种设计的好处是:支持直播(可以一直往列表里追加切片)、支持自适应码率(同一个视频可以提供多个清晰度的 m3u8,播放器根据网速切换)、支持内容分发(切片可以分布在不同 CDN 节点上)。
放到下载场景里,这就意味着你面对的不是一个单一文件,而是一个“清单 + 几十上百个碎片 + 可能的密钥”。理解了这一点,整个下载脚本的轮廓就出来了:拿到清单 → 解析碎片地址 → 逐个下载 → 合并。
1.2 M3U8 索引文件里到底写了些啥
先看一个典型的不加密 m3u8:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.0, segment_000.ts #EXTINF:10.0, segment_001.ts #EXTINF:10.0, segment_002.ts #EXT-X-ENDLIST逐行说明一下:
#EXTM3U:文件头,表示这是一个 m3u 家族的文件。#EXT-X-VERSION:3:协议版本号,一般不用特别关心。#EXT-X-TARGETDURATION:10:每个切片的最大时长,单位秒。#EXT-X-MEDIA-SEQUENCE:0:切片序列号起始值,直播流的切片会持续追加,这个值会不断变大。#EXTINF:10.0,:后面紧跟的一行是一个切片的地址,10.0表示这个切片时长约 10 秒。#EXT-X-ENDLIST:文件结束标记。有这个标记说明是点播流;直播流没有这个标记,会不断刷新生成新的切片。
切片地址有可能是相对路径(segment_000.ts),也有可能是完整 URL(https://cdn.example.com/segment_000.ts),甚至可能是绝对路径但少了协议头(//cdn.example.com/segment_000.ts)。所以写代码时一定要用urljoin去拼接完整地址,这是第一个容易踩的坑。
如果 m3u8 里没有#EXT-X-ENDLIST,那就是直播流。直播流的下载逻辑和点播不一样,本文主要讲点播场景,直播流部分会在后面提一嘴思路。
1.3 常见加密方式:AES-128 与 EXT-X-KEY 标签
很多 m3u8 链接在解析时会多出一行带#EXT-X-KEY的标签,比如:
#EXT-X-KEY:METHOD=AES-128,URI="key.key",IV=0x00000000000000000000000000000000这行的意思是:这些切片用了 AES-128 加密,密钥在key.key这个文件里,初始向量(IV)是0x00000000000000000000000000000000。
AES-128 是一种对称加密算法,同一个密钥既用来加密也用来解密。流媒体常见的是 CBC 模式,每个切片解密时需要一个 16 字节的密钥和一个 16 字节的初始向量。这里有一个非常关键的细节:如果#EXT-X-KEY里没有明确写IV=,那么 IV 默认是“切片在流中的序列号”,用 16 字节大端序表示。
举个例子,如果切片序列号是 0、1、2……那么默认 IV 就是:
- 序列号 0 →
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 - 序列号 1 →
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 - 序列号 2 →
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02
在 Python 里就是seq.to_bytes(16, "big")。很多人解密失败,就是因为在代码里直接把密钥当 IV 用了,或者干脆不传 IV,结果解出来的全是花屏。
除 AES-128 之外,HLS 还有SAMPLE-AES、SAMPLE-AES-CTR等加密方式,这些通常配合 DRM(数字版权管理)使用,普通 Python 脚本搞不定,也不建议去研究绕过 DRM 的方法。下文所有代码只针对标准的 AES-128 加密。
2. 环境准备与工具选型
2.1 Python 版本与依赖库
我用的是 Python 3.10,其实 3.8 以上都没问题。需要装的库只有两个:
pip install requests pycryptodome这里特别说明为什么用pycryptodome而不是pycrypto。pycrypto这个库已经停止维护很多年了,在 Python 3 环境里安装大概率报错,而pycryptodome是它的维护分支,API 兼容,安装也顺利。导入时的模块名还是Crypto,别被这个细节误导。
另外,解密时用到的 AES 类来自Crypto.Cipher,如果from Crypto.Cipher import AES报模块找不到错,先确认是不是装了pycrypto而不是pycryptodome。
2.2 ffmpeg 的两个作用
ffmpeg 在整个方案里有不可替代的地位。第一个作用是合并 TS 切片:把一堆碎片按顺序拼成一个完整的.mp4文件;第二个作用是快速验证:拿到 m3u8 链接后,可以直接用 ffmpeg 尝试一把梭,很多时候 FFmpeg 自己就能完成“读索引 → 拉密钥 → 下载切片 → 解密 → 合并”的全流程。
安装方式各平台不同:
- Windows:
winget install ffmpeg或者去官网下载静态编译版,解压后把 bin 目录加入 PATH。 - macOS:
brew install ffmpeg。 - Ubuntu/Debian:
sudo apt install ffmpeg。
装好后在命令行执行ffmpeg -version能输出版本信息就算成功。
2.3 为什么不直接用现成下载器,非要自己写
你可能会想,网上不是有yt-dlp、you-get这种现成的命令行下载工具吗?确实,大部分公开站点的视频它们都能搞定,尤其是yt-dlp,更新频率高、支持站点多,处理普通的 m3u8 加密流也完全没问题。
但自己写代码的意义在于三点。第一,这些工具对“平台自建私有加密协议”往往无能为力,而很多小平台、在线教育平台、企业内训系统用的是定制过的 HLS 流程,解析逻辑五花八门,只能自己写脚本去适配。第二,写一遍代码能真正理解 HLS 协议和 AES 解密,后面遇到任何变种流都能快速定位问题,而不是只会敲一个命令行。第三,有些场景需要把下载逻辑嵌进自己的自动化流程里,比如批量下载、定时抓取、数据归档,现成工具并不好做二次开发。
所以本文的定位是“理解原理 + 掌握手写能力”,而不是单纯教你一个工具。
3. Python 代码实战:从 M3U8 到 MP4
3.1 第一步:拿到 M3U8 索引文件
拿到 m3u8 地址后,第一步是用 requests 把它下载下来。整个流程通用的伪代码是:
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/", } def get_m3u8_content(m3u8_url, headers=headers): resp = requests.get(m3u8_url, headers=headers, timeout=10) resp.raise_for_status() return resp.text这步看起来简单,但请求头里的User-Agent和Referer非常关键。很多 CDN 会校验 Referer,如果来源不对,直接返回 403 或者返回一个假的索引文件。User-Agent不带上,部分网关也会拦截。我这里顺手写了一个比较稳妥的 UA,实际使用时最好换成你自己浏览器的 UA。
还有一点:如果页面是通过动态请求生成 m3u8 地址的,你需要先用开发者工具在 Network 面板里找到真正的 m3u8 请求。如果发现 m3u8 的 URL 里带了很长的签名参数(比如?sign=xxx&expire=xxx),说明这是一个带时效的临时链接,过几分钟就会失效,下载时必须用同一个会话,速度也要快,不能慢慢吞吞先分析半天再去下载。
3.2 第二步:解析索引和密钥
拿到文本内容后,需要解析出三样东西:密钥地址(如果有)、切片地址列表、IV(如果有明确指定)。
import re from urllib.parse import urljoin def parse_m3u8(m3u8_url, content): base_url = m3u8_url.rsplit("/", 1)[0] + "/" key_url = None iv = None media_sequence = 0 ts_list = [] for line in content.splitlines(): line = line.strip() if line.startswith("#EXT-X-KEY"): method = re.search(r'METHOD=([^,]+)', line) if method and method.group(1) == "AES-128": uri_match = re.search(r'URI="([^"]+)"', line) if uri_match: key_url = urljoin(m3u8_url, uri_match.group(1)) iv_match = re.search(r'IV=(0x[0-9a-fA-F]+)', line) if iv_match: iv = bytes.fromhex(iv_match.group(1)[2:]) elif line.startswith("#EXT-X-MEDIA-SEQUENCE"): media_sequence = int(line.split(":")[1]) elif line and not line.startswith("#"): ts_url = urljoin(m3u8_url, line) ts_list.append(ts_url) return key_url, iv, media_sequence, ts_list这段逻辑里值得注意的有两个点。
第一个,URI="key.key"不一定是完整路径,甚至可能是一个相对路径,比如../key.key或者https://cdn.example.com/keys/12345.key。我统一用urljoin(m3u8_url, uri)来解析,这样无论原链接是相对还是绝对,都能得到正确的完整地址。
第二个,解析IV时要把0x前缀去掉再转字节。有些人直接line[IV=0x...]整个字符串拿去转,结果长度不对,解密直接报错。
如果 key 地址和切片地址不在同一个 CDN 域名下,情况会复杂一些,后面常见问题部分会讲。
下载密钥并准备解密的代码很简单,但有一个隐藏坑:
key_data = requests.get(key_url, headers=headers).content # 下面这行很重要:key 文件有时候末尾带换行符,不解会出错 key_data = key_data.strip()很多平台生成的 key 文件其实是一个十六进制字符串,明文长度 32 或 16 字节,但文本文件末尾往往带一个\n。直接把原始.content拿去 AES 解密,密钥长度变成 17 字节,AES 直接抛ValueError,你就一脸懵。所以拿到 key 之后先.strip()是基本功。
3.3 第三步:下载并解密 TS 切片
现在进入最核心的步骤。切片的下载和解密要绑在一起处理:先下载一个切片,检查它是不是加密的,如果是就用前面拿到的 key 和 IV 解密,把解密后的明文保存到磁盘。
import os from Crypto.Cipher import AES def download_and_decrypt(ts_url, key_data, iv, seq, headers, output_dir): resp = requests.get(ts_url, headers=headers, timeout=15) resp.raise_for_status() data = resp.content if key_data is not None: # 如果 m3u8 里没有显式指定 IV,就使用切片序列号作为 IV if iv is None: iv = seq.to_bytes(16, "big") cipher = AES.new(key_data, AES.MODE_CBC, iv=iv) data = cipher.decrypt(data) # 解密后就是干净的 MPEG-TS 数据,直接写入文件 ts_path = os.path.join(output_dir, f"{seq:05d}.ts") with open(ts_path, "wb") as f: f.write(data) return ts_path这里有一个很多人没注意到的逻辑:序列号的来源是#EXT-X-MEDIA-SEQUENCE加上切片在当前列表里的下标。如果流里面第一个切片的序列号是 120,而你从 0 开始计数去生成 IV,解密必然失败。所以解析函数里返回media_sequence是有讲究的,正确的计算方式是seq = media_sequence + index,其中index是当前切片在ts_list中的下标。
另外提醒一下,TS 切片是二进制文件,下载时一定要用resp.content而不是resp.text。resp.text是解码后的字符串,到最后写文件时会因为编码问题把数据写坏。
3.4 第四步:合并切片输出 MP4
切片全部下载并解密完成后,就到了合并环节。这一步用 ffmpeg 来做,不仅快,而且能保证音视频轨道的正确性。
先在输出目录里生成一个文件清单,再调用 ffmpeg:
def merge_ts_to_mp4(ts_dir, output_mp4): list_file = os.path.join(ts_dir, "filelist.txt") with open(list_file, "w", encoding="utf-8") as f: for fname in sorted(os.listdir(ts_dir)): if fname.endswith(".ts"): f.write(f"file '{os.path.join(ts_dir, fname)}'\n") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", output_mp4, ] subprocess.run(cmd, check=True)这里用-f concat协议拼接,-c copy表示流拷贝,不做转码,速度非常快。如果你有几十个切片,这个命令几秒钟就能完成。
如果你不想挨个下载切片,有个更直接的土办法:直接把 m3u8 链接丢给 ffmpeg,让它自己完成全流程:
ffmpeg -headers $'Referer: https://example.com/\r\n' \ -i "https://example.com/index.m3u8" \ -c copy output.mp4前提是 m3u8 链接没过期、密钥不需要额外鉴权、网络通畅。我经常先用这条命令试水,能一把过就不用写 Python 了。但它有个缺点:如果下载中途网络断了,无法断点续传,只能从头再来。自己写脚本的好处就在这,可以控制每个切片的下载和校验。
4. 工程化细节与常见坑
4.1 请求头伪装:Referer 和 User-Agent 不能省
我见过太多人写下载脚本,只知道带User-Agent,一遇到 403 就不知道怎么办。实际上很多流媒体的 CDN 校验逻辑是按“会话”来的:你从哪个页面进入的、你的浏览器是什么环境、你请求的 Referer 是不是本域页面。三者缺一个都可能被拒。
一个比较稳妥的请求头配置是:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/", "Origin": "https://example.com", }如果下载切片时发现部分切片 403,个别平台还会校验 Cookie。这种情况下需要把浏览器里的 Cookie 复制进请求头:
headers["Cookie"] = "你的浏览器Cookie"Cookie 是高度敏感的信息,使用完记得及时从代码里移除。自己本地跑脚本没太大关系,但如果代码要分享出去,一定不要把真实 Cookie 写进去。
4.2 并发下载怎么控制才不翻车
单线程挨个下载几十个切片,速度确实让人着急。可以上线程池并发下载,但并发数不是越大越好。我实测下来,控制在 4 到 8 个线程比较合适,太少了效率低,太多了容易触发 CDN 限流,反而全 403。
一个简单的并发下载示例:
from concurrent.futures import ThreadPoolExecutor, as_completed def download_all(ts_list, key_data, iv, media_sequence, output_dir, headers): with ThreadPoolExecutor(max_workers=6) as executor: future_map = {} for idx, ts_url in enumerate(ts_list): seq = media_sequence + idx future = executor.submit( download_and_decrypt, ts_url, key_data, iv, seq, headers, output_dir ) future_map[future] = ts_url for future in as_completed(future_map): try: path = future.result() print(f"完成: {path}") except Exception as e: print(f"失败: {future_map[future]}, 错误: {e}")注意,并发下载时建议保留原始切片的顺序信息,文件名用序列号来命名(00001.ts、00002.ts),这样合并阶段排序方便,不会出现顺序错乱的问题。
还有一点:如果你在 Windows 上跑,同时打开太多文件句柄或者遇到路径过长问题,可以考虑把输出目录放在项目根目录下,别嵌套太深。
4.3 失败重试和断点续传
网络环境再好,下载几十个切片也难保一个都不失败。最朴素的做法是给单个切片加个重试逻辑。
def download_with_retry(ts_url, headers, retries=3): for attempt in range(retries): try: resp = requests.get(ts_url, headers=headers, timeout=15) resp.raise_for_status() return resp.content except Exception: if attempt == retries - 1: raise time.sleep(2 * (attempt + 1))断点续传做起来也不复杂:下载前先检查本地文件是否存在且大小不为 0,如果存在就直接跳过。合并的时候只合并实际存在的切片。这样做的好处是,一次下载中断后,重新跑脚本不会把已经下载好的切片再下一遍。
我个人的习惯是,先把所有切片下载解密到本地目录,确认数量和大小没问题后再合并。合并完先别急着删 ts 目录,等播放器验证过输出文件没问题了,再清理临时文件。
4.4 关于协议里的其他小坑
- 有些 m3u8 使用了
#EXT-X-MAP标签,常见于 fMP4 切片(用.mp4后缀)。这类流不能直接用 TS 合并的方式处理,ffmpeg 能识别但逻辑不同,本节代码不一定适用。 - 有的 m3u8 里切片地址带中文或特殊字符,requests 会自动做 URL 编码,但如果 CDN 端校验严格,建议
urljoin之后先quote一下再请求。 - 某些 m3u8 会嵌套引用另一个 m3u8,也就是多级流。遇到这种情况,需要先判断引用的文件是切片还是另一个索引,再决定是否递归解析。
5. 高频问题排查速查表
这里整理一下我在实际操作中反复遇到的几个问题,按使用频率排个序。
5.1 Network 面板里找不到 m3u8
很多人问我:明明视频在播放,为什么我在 Network 面板里搜 m3u8 什么都搜不到?
原因主要是三种可能。
第一种,播放器把 m3u8 请求藏起来了。浏览器开发者工具默认只显示XHR/Fetch和Doc,m3u8 本身走的是 media 请求,但有些播放器会先用 XHR 把 m3u8 文本拉回来,再交给 MSE(Media Source Extensions)处理,这时候 m3u8 不在 Network 里,而是在 JS 变量里。
第二种,过滤关键字不对。试着搜m3u8、m3u、index、.ts这几个关键词,有时候链接命名完全不含 m3u8 字样,比如playlist.php?id=xxx,这种就要在“发起程序”里找线索。
第三种,页面用的不是 HLS,而是HTTP-FLV,常见于很多国内直播平台。这时你看到的是.flv请求而不是 m3u8,整个下载方案要换成 flv 流处理,就不是本文讨论的范围了。
5.2 下载下来的 TS 全是乱码 / 解密失败
先区分一下乱码的层次。如果 TS 文件用播放器打开是花屏、绿屏、黑屏,大概率是解密没对上。如果文件本身用文本编辑器打开全是奇怪的二进制符号,那可能是正常现象——TS 本来就是二进制文件,问题要看能不能播。
解密失败的时候,优先检查这几个点:
- 密钥是不是带了换行符,有没有
strip()。 - IV 是否与切片序列号对应,是否把密钥当 IV 用了。
METHOD是不是AES-128,如果是SAMPLE-AES,普通手段解不了。- key 文件和切片是否来自同一个 CDN 节点,如果 key 请求需要额外鉴权,requests 默认不会带对应 Cookie。
我用一个 Python 脚本把这些问题都兜住了,就是前面代码里的download_and_decrypt,你可以直接拿来跑一遍做定位。
5.3 合并后音画不同步或没声音
这种情况通常是某个 TS 切片下载不完整或者数据损坏。-c copy模式不做任何重编码,坏切片里的音视频帧会原样带进输出文件,导致播放器解码时出现音画不同步。
最简单的排查方法是看 ts 目录里有没有大小明显异常的文件(比如 0 字节、几十字节),单独重新下载这些文件再合并。如果重试后还是坏,那基本可以断定原始源就有问题。
还有种少见情况:原始流里有多条音轨,其中一条是空的或者有问题的。这时候播放器默认选了错误音轨,看起来像“没声音”,其实声音在其他轨道。用 ffmpeg 重新封装并指定音轨:
ffmpeg -i output.mp4 -map 0:v:0 -map 0:a:1 -c copy output_fixed.mp45.4 m3u8 链接过期,下载到一半就 403
带签名参数的 m3u8 链接通常有时效限制,可能是几分钟,也可能是几小时。如果下载到一半突然开始 403,大概率是链接过期了,这时候只能回到页面重新获取新的 m3u8,再跑一遍脚本。
为了减少“下到一半失效”的概率,可以在拿到链接后先测一下第一个切片能不能正常下载,再决定要不要开全量下载。另外,并发拉的 wolf 太高也容易触发频控,保持 6 到 8 个线程左右比较安全。
6. 关于合规,我必须说几句
6.1 什么情况下能安全使用这套脚本
HLS / m3u8 / AES-128 这套技术栈本身是中性的,它被广泛用于视频网站、在线教育、企业培训、直播等场景。你完全可以用它做这些事:
- 下载自己上传到平台的内容,备份到本地。
- 处理公开授权或无版权限制的教程、素材、宣传片。
- 学习 HLS 协议和爬虫技术,用测试流练手。
- 在自己开发的系统里,实现本地缓存和离线播放功能。
但如果你面对的链接来自商业视频平台,内容受版权保护甚至用了 DRM,那既不该去研究绕过方法,也不该用这套脚本去下载。DRM 是为了保护内容创作者和平台方的合法权益,技术上强行绕过是违法行为,也违背基本的职业道德。
6.2 个人经验与后续扩展方向
我从最早只能对着 m3u8 文本发呆,到现在可以熟练地写各种下载脚本,中间踩过不少坑。最大的体会是:遇到“播放器能播但自己下载失败”的情况,别急着改代码,先抓包观察播放器到底发了哪些请求、带了什么头、按什么顺序拿数据和密钥。把播放器的行为复现清楚了,脚本自然就通了。抓包分析的能力,比具体某段代码重要得多。
如果你想把这份代码扩展成更完整的工具,可以考虑几个方向:一是支持直播流的持续监听与切片心跳记录;二是加入 FFmpeg 转码逻辑,把 TS 直接转成 H.264 + AAC 的 MP4,兼容性更好;三是把解析、下载、合并封装成命令行工具,支持配置文件和批量任务。无论往哪个方向做,先把本文的核心逻辑吃透,后面都是水到渠成的事。