Python爬取加密M3U8视频:从抓包到AES解密合并MP4完整指南 先说说我为什么写这篇东西。前段时间想缓存几集剧到本地通勤看结果腾讯视频客户端的缓存文件是加密的换播放器根本放不了。我第一反应是直接抓流媒体链接打开开发者工具一瞧果然是标准的M3U8分片索引但链接并不是明文MP4而是带着一串加密参数的分片地址。既然客户端能播说明解密链路就在眼前用Python走一遍完全可行。这篇文章会把完整链路拆开怎么从浏览器里翻出M3U8索引、怎么拿到密钥KEY、怎么批量下载TS分片、怎么AES解密再合并成能随便拖进度条的MP4。同时把我在实操中踩过的坑都记下来比如Network面板里找不到m3u8、分片下载报403、KEY解密报错这些典型问题。适合已经会基础Python、想搞懂在线视频缓存原理的读者也适合想自己写一个下载工具的人参考。1. M3U8为什么能成为主流从视频分片到加密保护1.1 一个M3U8文件到底长什么样M3U8本质就是个文本索引文件按行描述视频片段的信息。它不存视频数据本身只告诉你第一个分片在哪、第二个分片在哪、每个分片播多久。浏览器或播放器读到这个索引后会按顺序去拉分片然后无缝播放。打开一个典型的M3U8文件内容大概是这样的#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-KEY:METHODAES-128,URIkey.key,IV0x00000000000000000000000000000000 #EXTINF:10.0, segment_00001.ts #EXTINF:10.0, segment_00002.ts #EXTINF:10.0, segment_00003.ts#EXTINF后面跟的是分片时长秒下一行是对应的分片文件名。#EXT-X-KEY是加密声明METHODAES-128表示分片用AES-128加密URI指向密钥文件IV是初始向量。分片文件名可能是相对路径也可能是完整URL需要根据M3U8所在的目录拼接。腾讯视频这类平台会把视频切成几秒一个的小分片每个分片都是独立的TS格式文件单独加密。这样做的直接好处有两个一是用户拖动进度条时只需要加载对应时间段的几个分片省流量二是就算有人拿到了分片文件没有密钥也拼不出能播的视频。1.2 分片加密与KEY的关系AES-128-CBC是HLS加密最常用的方式。它的工作逻辑我尽量说得通俗一点把每个TS分片按16字节一组切块前一组加密后的密文参与下一组的加密运算环环相扣。正因为这种链式依赖解密时必须知道两样东西——密钥和初始向量IV。#EXT-X-KEY标签就是干这个的。METHOD指定加密算法URI指向密钥地址IV是初始向量。如果M3U8里没写IV按规范默认用全0的16字节。我见过不少人在这一步栽跟头明明密钥下载对了解密却报错就是因为没把IV按16字节长度处理。腾讯视频的密钥接口通常还带防盗链参数比如需要特定的User-Agent或者Referer才能返回真实密钥否则给你个空内容或者401。这一点后面在实操部分细说。1.3 腾讯视频的加密链路特点腾讯视频的M3U8索引拿到的链接里分片URL本身就带一串token参数过期时间往往只有几十分钟到几小时。更关键的是M3U8里可能同时存在多条#EXT-X-KEY对应不同时段的加密密钥。有些剧集还穿插了广告分片广告分片的密钥和正片分片的密钥不是同一个。这就带来了一个非常实际的坑你不能只下载一个KEY就完事得在解析M3U8时把所有KEY都收集起来然后按分片的URI匹配对应的密钥去解密。否则就会出现前十分钟能播后面全花屏的诡异现象。2. 动手前的准备环境与依赖清单2.1 Python版本与虚拟环境我建议直接用Python 3.8以上版本3.10、3.11都行。原因很简单新版本对bytes、bytearray的内存操作更稳定处理二进制分片时不容易出幺蛾子。创建虚拟环境这步别省虽然这个项目依赖不多但隔离环境能避免系统里其他项目的包版本冲突python3 -m venv m3u8_env source m3u8_env/bin/activate # Windows用 m3u8_env\Scripts\activate2.2 核心依赖清单只装三个库就够了其他都用标准库库名用途安装命令requests发送HTTP请求、下载分片和密钥pip install requestspycryptodomeAES-128-CBC解密pip install pycryptodomem3u8解析M3U8索引可选pip install m3u8m3u8这个库不装也能干用正则加字符串处理一样能解析。但我要说一句公道话m3u8库对#EXT-X-KEY、#EXT-X-DISCONTINUITY等复杂标签的处理比手写正则稳健得多尤其是处理广告分片切换时它能准确分辨每个分片对应的密钥。所以我推荐直接用它。2.3 浏览器开发者工具的准备工作下载视频的关键是先拿到M3U8链接这步基本靠浏览器开发者工具。用Chrome或Edge都行但有一点要注意腾讯视频网页版的M3U8请求可能在桌面端UA下不出现改成手机端UA后反而更容易抓到。我自己实测在开发者工具里按CtrlShiftP输入network conditions然后把User-Agent改成iPhone的UA刷新页面再播放M3U8请求通常就出现了。另外提前说一句别在开发者工具里老老实实翻半天直接用Network面板的过滤框输入m3u8关键词很快就能定位。找不到十有八九是过滤条件不对而不是页面没发这个请求。3. 拿索引和KEY从浏览器到Python请求的完整链路3.1 Network面板没有m3u8两个排查方向这是被问得最多的问题。我先说结论页面只要在播视频必然发M3U8请求关键是它藏得比较深。第一个排查方向是过滤标签。M3U8请求往往不是纯.m3u8后缀而是带了一长串query参数比如video_.m3u8?platform11txp...。在Network面板里直接搜m3u8关键词是对的但不要只搜m3u8可以试试搜m3u8?或者index.m3u8。还有的请求干脆连m3u8字样都没有就显示为videom3u8之类这类要找名称里带video的请求。第二个坑是请求类型。在Chrome的Network面板里M3U8请求的Type列往往显示为xhr或fetch但也可能是media。如果你默认过滤了media类型就会直接漏掉。我自己习惯把Network面板的类型筛选取消全量看按名称排序后再搜关键词。如果页面走的是Media Source ExtensionsMSE播放M3U8内容会被JS拉取后转成blob:URL喂给播放器这种情况下Network面板里一样能看到原始请求只是多了一步不必担心。3.2 拷贝为cURL转成Python脚本找到M3U8请求后右键单击该请求选择Copy→Copy as cURL。这一步能省掉大量手动构造请求头的工作。然后我一般用curlconverter这种在线工具或者本地Python库把cURL转成requests代码但如果你不想多装东西手动把关键请求头抄出来也够用。关键是这三个头User-Agent必须和浏览器保持一致。Referer通常指向视频播放页面的URL。Cookie登录态和平台风控参数有时KEY接口校验它。复制出来的cURL里Cookie可能很长建议转成字典结构headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1, Referer: https://v.qq.com/, Cookie: xxx, # 从cURL里复制 }3.3 解析索引文件筛选真正的视频分片拿到M3U8内容后先用m3u8库解析。这里有一个必须处理的点M3U8里的分片地址可能是相对路径需要基于M3U8的URL做拼接。m3u8库提供了base_uri参数解析时会自动拼好import m3u8 m3u8_url https://example.com/path/index.m3u8?tokenxxx resp requests.get(m3u8_url, headersheaders) obj m3u8.loads(resp.text, urim3u8_url)obj.segments是分片列表每个分片对象有uri、duration、key属性。key对象里包含method、uri、iv正好对应#EXT-X-KEY。如果分片属于广告段分片对象会带有discontinuity标记可以根据这个字段决定是否跳过。实战里腾讯视频的分片命名经常是不连续的比如跳过广告后正片分片编号从0重新开始或者号码中间有一段跳变。所以按顺序遍历obj.segments不要自己用range生成分片号否则一旦遇到跳号就下载一堆不存在的文件。3.4 获取KEY密钥接口的防盗链参数KEY的获取是整个流程里最容易翻车的一步。M3U8里EXT-X-KEY标签的URI指向一个密钥文件URL直接用requests去GET就行。但有几个细节第一KEY接口对请求头非常敏感必须带上和第3.2节一致的User-Agent和Referer否则极有可能返回403或空响应。第二密钥返回的格式可能是裸的16字节二进制也可能是十六进制字符串。需要判断一下返回值长度二进制密钥是16字节十六进制字符串则可能是32个字符。处理逻辑这样写最稳key_data resp.content if len(key_data) 32 and all(c in b0123456789abcdefABCDEF for c in key_data): key bytes.fromhex(key_data.decode()) else: key key_data第三有些加密流的KEY会按时间段轮换M3U8里可能出现两条甚至更多EXT-X-KEY。正确做法是建立一个key_uri - key_bytes的缓存字典遍历分片时按分片的key.uri去取密钥避免重复下载也避免取错密钥。4. 下载、解密与合并把分片变成能播的MP44.1 并发下载的误区和正确姿势很多新手一上来就上线程池想着20个线程同时下载会快很多。实测下来对于TS分片这种动辄几十KB到几百KB的小文件并发开太大反而容易触发平台的限流策略结果就是下载到一半开始大量403或者超时。我踩过几次坑之后的稳定方案是ThreadPoolExecutor开4到6个线程配合requests.Session复用连接。把分片下载任务丢给线程池同时把下载结果按分片序号保存到列表里最后统一写入磁盘from concurrent.futures import ThreadPoolExecutor, as_completed import requests session requests.Session() session.headers.update(headers) def download_ts(seq, uri): resp session.get(uri, timeout10) resp.raise_for_status() return seq, resp.content results {} with ThreadPoolExecutor(max_workers5) as executor: futures {executor.submit(download_ts, i, seg.uri): i for i, seg in enumerate(segments)} for future in as_completed(futures): seq, data future.result() results[seq] data为什么不直接边下边写文件因为分片可能乱序返回边下边写会导致合并顺序错乱。统一收集到内存里再按序写实现简单对几百MB的视频内存占用也在可接受范围。4.2 AES-128-CBC解密实操解密的核心代码不长但细节决定成败。pycryptodome的用法如下from Crypto.Cipher import AES def decrypt_ts(data: bytes, key: bytes, iv: bytes) - bytes: cipher AES.new(key, AES.MODE_CBC, iv) return cipher.decrypt(data)关键在IV的取值。M3U8里#EXT-X-KEY如果显式写了IV0x...就按它来如果没写按HLS规范使用16字节全0。m3u8库解析后key.iv已经是二进制形式直接传给decrypt_ts就行省去手动转换。还有个小坑TS分片本身不是完整的AES密文块对齐结构吗其实#EXT-X-KEY加密时分片开头有4字节的0x47同步字节解密后这部分是TS包头。有些教程让你解密前先跳过17个字节那是针对旧格式的做法新版HLS规范下不要跳过直接整个分片解密即可。4.3 二进制合并与格式转换所有分片解密完成后合并就非常简单了按序号顺序写入同一个文件with open(output.ts, wb) as f: for i in range(len(segments)): f.write(results[i])这里有个认知要纠正TS分片合并后得到的是.ts文件MPEG-TS格式不是标准MP4。但绝大多数播放器VLC、PotPlayer、IINA等都能直接播.ts文件所以如果你只是自己看扩展名不改也行。如果非要用手机相册或者某些只能识别MP4的播放器就需要做一次转封装。用FFmpeg一行命令ffmpeg -i output.ts -c copy output.mp4-c copy是流复制不重新编码速度极快画质无损。前提是视频编码是H.264/H.265音频编码是AAC腾讯视频的TS分片基本都满足这两个条件。4.4 一张表总结完整流程步骤输入核心操作输出1. 抓索引浏览器Network面板过滤m3u8拷贝cURLM3U8 URL2. 构造请求cURL提取UA、Referer、Cookieheaders字典3. 解析索引M3U8文本m3u8库解析分片和KEYsegments列表4. 下载密钥KEY URL带防盗链头GETkey字节5. 下载分片分片URL列表线程池并发下载分片列表6. 解密分片分片数据、密钥AES-128-CBC解密后TS数据7. 合并输出解密后分片按序二进制写入TS文件8. 转封装可选TS文件FFmpeg流复制MP4文件5. 打包工程一个可复用的M3U8下载脚本5.1 脚本主流程设计把前几章的思路整合起来就是下面这个脚本。它的结构很清晰parse_m3u8负责解析download_key负责拿密钥download_all_segments负责并发下载decrypt_and_merge负责解密和合并。每个函数职责单一方便你按自己的需求改。脚本入口接收M3U8地址和输出文件名执行完会在本地生成对应的TS文件。我用的是argparse做命令行参数这样在终端里跑起来更像一个正经工具。5.2 请求头、超时与重试的细节处理在写完整脚本前有几个细节先交代清楚第一requests.Session必须复用。Session会自动保存Cookie还会复用TCP连接对分片下载这种高频请求场景能明显降低延迟。第二超时和重试要配套。视频分片下载最常见的失败模式是某个分片卡住或者超时。我的经验是timeout10配合Retry机制对连接错误和502/503这类状态码做最多3次重试。分片下载失败不要直接崩溃收集到失败列表里最后统一处理更符合实际需求。第三内存控制。如果一个视频有几百个分片全存内存再写文件虽然可行但在低配机器上可能吃力。折中方案是边下载边把分片写入独立的临时文件合并时再按序读取。代码可读性会下降一点但内存占用从几百MB降到几十MB。5.3 完整代码示例下面这个脚本是我实际在用的版本简化后分享出来的完整跑通没问题import argparse import os import time import requests import m3u8 from Crypto.Cipher import AES from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin class M3U8Downloader: def __init__(self, m3u8_url, headersNone, max_workers5): self.m3u8_url m3u8_url self.headers headers or {} self.max_workers max_workers self.session requests.Session() self.session.headers.update(self.headers) def parse(self): resp self.session.get(self.m3u8_url, timeout10) resp.raise_for_status() self.m3u8_obj m3u8.loads(resp.text, uriself.m3u8_url) self.segments self.m3u8_obj.segments def get_key(self, key_uri): if not key_uri: return None key_url urljoin(self.m3u8_url, key_uri) resp self.session.get(key_url, timeout10) resp.raise_for_status() data resp.content if len(data) 32 and all(c in b0123456789abcdefABCDEF for c in data): return bytes.fromhex(data.decode()) return data def download_one(self, seq, seg): uri urljoin(self.m3u8_url, seg.uri) try: resp self.session.get(uri, timeout10) resp.raise_for_status() except Exception: # 简单重试一次实际可以结合Retry机制 time.sleep(1) resp self.session.get(uri, timeout15) resp.raise_for_status() return seq, resp.content def download_all(self): self.raw_data {} with ThreadPoolExecutor(max_workersself.max_workers) as executor: futures { executor.submit(self.download_one, i, seg): i for i, seg in enumerate(self.segments) } for future in as_completed(futures): seq, data future.result() self.raw_data[seq] data def decrypt_and_merge(self, output_path): with open(output_path, wb) as out: for i, seg in enumerate(self.segments): data self.raw_data[i] if seg.key and seg.key.method AES-128: if not hasattr(self, _key_cache): self._key_cache {} key_uri seg.key.uri if key_uri not in self._key_cache: self._key_cache[key_uri] self.get_key(key_uri) key self._key_cache[key_uri] iv seg.key.iv if seg.key.iv else bytes(16) cipher AES.new(key, AES.MODE_CBC, iv) data cipher.decrypt(data) out.write(data) print(f保存至: {output_path}) def main(): parser argparse.ArgumentParser(descriptionM3U8下载器) parser.add_argument(m3u8_url, helpM3U8索引地址) parser.add_argument(-o, --output, defaultoutput.ts, help输出文件路径) parser.add_argument( -u, --user-agent, defaultMozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1, helpUser-Agent ) parser.add_argument(-r, --referer, defaulthttps://v.qq.com/, helpReferer) parser.add_argument(-c, --cookie, default, helpCookie) args parser.parse_args() headers { User-Agent: args.user_agent, Referer: args.referer, Cookie: args.cookie, } downloader M3U8Downloader(args.m3u8_url, headersheaders) print(解析M3U8...) downloader.parse() print(f发现 {len(downloader.segments)} 个分片) print(下载分片中...) downloader.download_all() print(解密并合并...) downloader.decrypt_and_merge(args.output) if __name__ __main__: main()使用方式python m3u8_downloader.py https://example.com/index.m3u8?tokenxxx -o movie.ts -c 你的cookie这个脚本最大的改进是把密钥缓存和IV处理都内化了实际跑腾讯视频的加密流没问题。对不加密的普通M3U8seg.key为None自动跳过解密直接合并兼容性很强。6. 踩坑实录视频转换失败、KEY失效、分片4036.1 m3u8视频转换失败一个容易被忽略的分片丢失问题m3u8视频转换失败这个热搜词背后最常见的根因不是转换工具的问题而是分片文件本身缺失或损坏。具体表现是FFmpeg转封装时报错muxer does not support non-video streams或者合并后视频播放到某个时间点突然卡住、黑屏。排查思路是这样先检查下载的分片数量是否和M3U8里的#EXTINF数量一致。如果少了文件多半是某个分片的URL请求超时静默失败了。我的脚本里raise_for_status会直接抛异常不会静默丢失但如果你自己手写下载逻辑时用了try...except: pass就容易埋这个雷。另外有些M3U8索引里会夹杂#EXT-X-MAP标签它指向的不是视频分片而是fMP4格式的初始化段。这种属于HLS的新变体类似fMP4不能用老方法直接合并TS得用FFmpeg处理。如果你解析出的索引里出现了#EXT-X-MAP就别自己写合并逻辑了直接存成文件后用FFmpeg转封装最省事。6.2 KEY解密报错IV参数的坑解密时报ValueError: IV must be 16 bytes long这个错一出现基本可以确定是IV没传对。m3u8库解析后的seg.key.iv已经是二进制16字节直接传给AES就行不需要再做处理。但还有一个隐蔽情况M3U8里的IV写的是IV0x00000000000000000000000000000000解析库会正确转成16字节全0如果某些平台不写IV字段seg.key.iv会是None这时要手动用bytes(16)补上不要传None进AES构造函数。还有一类报错是Data must be padded to 16 byte boundary。这个通常意味着你下载的分片数据不完整被截断了。解决方法是重新下载该分片而不是去查解密逻辑。6.3 分片403Referer和UA必须和抓包时一致403是最让人头疼的错误因为明明浏览器能访问Python就是不行。这几乎都是防盗链校验的锅。平台会检查请求的Referer是否来自合法的播放页面以及User-Agent是否像真实浏览器。复制cURL时带上的头在Python里一个都不能少。还有一个细节分片URL里的token可能绑定IP和User-Agent。如果你最开始抓M3U8时用的桌面UA下载分片时也要保持一致不要中途换成别的UA否则token校验直接失败。6.4 网络断点续传下载到一半怎么办视频越大下载中途断掉的可能性越大。我的建议是分阶段处理先下载全部TS分片到本地临时目录全部下载成功后再统一解密合并。这样即使某个分片下载失败重启脚本后可以只补下失败的那几个不用从头再来。脚本级别的断点续传要复杂一些我目前的做法是记录分片下载状态到一个JSON文件每次启动时先读状态已经下载过的分片直接跳过。对于一次性缓存几集剧的需求这个强度基本够了。如果以后做更大型的批量下载再考虑接数据库存任务队列。7. 版权提醒与工程化扩展思路写到这里必须正面说一句技术本身没有立场但使用技术的方式要有边界。这套M3U8下载解密方法适用于分析自己合法获取的视频流、研究HLS协议原理、备份自己购买的内容。拿去批量下载平台需要付费或会员才能观看的影片既违反平台服务条款也可能触碰版权红线不建议这么做。我在写这个脚本时也刻意保持了最小可用原则不对任何平台的防盗链做对抗性绕过。回头看整个项目最有价值的部分其实不是脚本本身而是排查问题的思路先看请求、再验证加密参数、最后才是并发优化。这个顺序颠倒了往往会事倍功半。我早期就犯过这个毛病——一上来就搞高并发结果被限流打到怀疑人生回头看才发现是请求头没对齐。后续如果你想扩展有几个方向可以参考一是支持多清晰度选择解析M3U8时对比EXT-X-STREAM-INF里的带宽字段自动选最高码率二是做一个简单的Web界面粘贴M3U8地址就能看下载进度三是把下载任务做成异步队列对接Celery之类的任务系统支持批量缓存剧集。这些都是在这个脚本基础上的自然延伸原理已经相通了。