
先说一句题外话总有人问我某视频平台的 m3u8 链接怎么扒、怎么下。我每次都会先说真正带版权保护的商业平台内容不该碰也不要去碰这篇文章也只讨论你有权获取的内容比如自己上传的视频、公开的测试流、无版权素材等。搞清楚边界之后再用 Python 去处理 m3u8 加密链接才是一门正经技术活。这篇文章解决一个很具体的问题浏览器能播的 m3u8你用 requests 拉回来一堆以#开头的文本下载下来的.ts文件全是乱码甚至根本播不了。大多数情况是链接做了 AES-128 加密或者请求头缺了 Referer / User-Agent。我会把 HLS 协议的原理讲明白再给一份可以直接跑的 Python 代码最后把常见坑和排查思路整理成速查表。适合刚接触爬虫和流媒体协议的读者也适合已经写过简单下载脚本、但遇到加密流就懵的人。1. 先搞懂 M3U8 是什么再谈下载1.1 HLS 协议为什么要把视频切成一堆小片段M3U8 本质上是 HLSHTTP 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也有可能是完整 URLhttps://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:METHODAES-128,URIkey.key,IV0x00000000000000000000000000000000这行的意思是这些切片用了 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 自己就能完成“读索引 → 拉密钥 → 下载切片 → 解密 → 合并”的全流程。安装方式各平台不同Windowswinget install ffmpeg或者去官网下载静态编译版解压后把 bin 目录加入 PATH。macOSbrew install ffmpeg。Ubuntu/Debiansudo apt install ffmpeg。装好后在命令行执行ffmpeg -version能输出版本信息就算成功。2.3 为什么不直接用现成下载器非要自己写你可能会想网上不是有yt-dlp、you-get这种现成的命令行下载工具吗确实大部分公开站点的视频它们都能搞定尤其是yt-dlp更新频率高、支持站点多处理普通的 m3u8 加密流也完全没问题。但自己写代码的意义在于三点。第一这些工具对“平台自建私有加密协议”往往无能为力而很多小平台、在线教育平台、企业内训系统用的是定制过的 HLS 流程解析逻辑五花八门只能自己写脚本去适配。第二写一遍代码能真正理解 HLS 协议和 AES 解密后面遇到任何变种流都能快速定位问题而不是只会敲一个命令行。第三有些场景需要把下载逻辑嵌进自己的自动化流程里比如批量下载、定时抓取、数据归档现成工具并不好做二次开发。所以本文的定位是“理解原理 掌握手写能力”而不是单纯教你一个工具。3. Python 代码实战从 M3U8 到 MP43.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, headersheaders): resp requests.get(m3u8_url, headersheaders, timeout10) resp.raise_for_status() return resp.text这步看起来简单但请求头里的User-Agent和Referer非常关键。很多 CDN 会校验 Referer如果来源不对直接返回 403 或者返回一个假的索引文件。User-Agent不带上部分网关也会拦截。我这里顺手写了一个比较稳妥的 UA实际使用时最好换成你自己浏览器的 UA。还有一点如果页面是通过动态请求生成 m3u8 地址的你需要先用开发者工具在 Network 面板里找到真正的 m3u8 请求。如果发现 m3u8 的 URL 里带了很长的签名参数比如?signxxxexpirexxx说明这是一个带时效的临时链接过几分钟就会失效下载时必须用同一个会话速度也要快不能慢慢吞吞先分析半天再去下载。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(rMETHOD([^,]), line) if method and method.group(1) AES-128: uri_match re.search(rURI([^]), line) if uri_match: key_url urljoin(m3u8_url, uri_match.group(1)) iv_match re.search(rIV(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这段逻辑里值得注意的有两个点。第一个URIkey.key不一定是完整路径甚至可能是一个相对路径比如../key.key或者https://cdn.example.com/keys/12345.key。我统一用urljoin(m3u8_url, uri)来解析这样无论原链接是相对还是绝对都能得到正确的完整地址。第二个解析IV时要把0x前缀去掉再转字节。有些人直接line[IV0x...]整个字符串拿去转结果长度不对解密直接报错。如果 key 地址和切片地址不在同一个 CDN 域名下情况会复杂一些后面常见问题部分会讲。下载密钥并准备解密的代码很简单但有一个隐藏坑key_data requests.get(key_url, headersheaders).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, headersheaders, timeout15) 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, iviv) 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 来做不仅快而且能保证音视频轨道的正确性。先在输出目录里生成一个文件清单再调用 ffmpegdef merge_ts_to_mp4(ts_dir, output_mp4): list_file os.path.join(ts_dir, filelist.txt) with open(list_file, w, encodingutf-8) as f: for fname in sorted(os.listdir(ts_dir)): if fname.endswith(.ts): f.write(ffile {os.path.join(ts_dir, fname)}\n) cmd [ ffmpeg, -y, -f, concat, -safe, 0, -i, list_file, -c, copy, output_mp4, ] subprocess.run(cmd, checkTrue)这里用-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] 你的浏览器CookieCookie 是高度敏感的信息使用完记得及时从代码里移除。自己本地跑脚本没太大关系但如果代码要分享出去一定不要把真实 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_workers6) 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, retries3): for attempt in range(retries): try: resp requests.get(ts_url, headersheaders, timeout15) 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和Docm3u8 本身走的是 media 请求但有些播放器会先用 XHR 把 m3u8 文本拉回来再交给 MSEMedia Source Extensions处理这时候 m3u8 不在 Network 里而是在 JS 变量里。第二种过滤关键字不对。试着搜m3u8、m3u、index、.ts这几个关键词有时候链接命名完全不含 m3u8 字样比如playlist.php?idxxx这种就要在“发起程序”里找线索。第三种页面用的不是 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兼容性更好三是把解析、下载、合并封装成命令行工具支持配置文件和批量任务。无论往哪个方向做先把本文的核心逻辑吃透后面都是水到渠成的事。