腾讯视频m3u8加密流解析:从抓包到FFmpeg合并完整指南

发布时间:2026/9/19 10:35:10
腾讯视频m3u8加密流解析:从抓包到FFmpeg合并完整指南 先聊一个我最近遇到的实际场景朋友把自己录制的一套教学视频传到了腾讯视频想把原片存一份到本地做备份但视频页没有“下载”按钮网页里也找不到直接的MP4地址。作为习惯性研究网络请求的人我第一反应是打开开发者工具看播放链路果然在请求列表里看到一长串以m3u8结尾的索引文件——这就是腾讯视频的加密流播放体系。顺着这条线往下走整个过程涉及解析播放页面拿vid、请求播放接口拿索引、下载分片、处理AES-128密钥、最终用FFmpeg合成MP4。先说清楚边界以下内容只针对你有权下载的视频比如自己上传的原创内容、企业内部的授权课程、平台允许缓存的公开资源。腾讯视频这类平台的内容受版权保护本文讲的是技术原理和通用方法不是用来收割付费或独播内容的。你如果拿这套东西去下载《庆余年》那性质就变了别怪我事先没提醒。1. 先搞懂腾讯视频的分片加密逻辑别急着写代码很多初学者一听到“加密流”就觉得很高深其实流媒体的加密和区块链没半点关系本质就是“把完整视频切成很多小块每个小块单独传输再按顺序拼回来”。这里的关键在于切块之后别人没法直接拿到一整段MP4只能拿到一堆零散的TS文件而TS文件本身可能还被AES加密过播放器必须拿到密钥才能还原画面。这套设计本来是为了解决网络弱网环境和版权保护问题结果也成了爬虫工程师常打交道的对象。1.1 一个m3u8文件里到底藏着什么m3u8是HTTP Live StreamingHLS协议里的索引文件通俗点说它就是一张“地图”告诉播放器视频被分成了多少片、每片去哪下载、有没有加密、密钥在哪。我随便找一个标准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_0000.ts #EXTINF:10.0, segment_0001.ts #EXTINF:10.0, segment_0002.ts #EXT-X-ENDLIST#EXT-X-KEY这一行就是加密信息METHODAES-128表示分片用AES-128对称加密URI指向密钥文件IV是初始向量。#EXTINF后面跟的是分片时长下面一行就是分片文件的相对地址。当我们用播放器打开这个m3u8时播放器会先下载key.key再逐个下载TS分片并用密钥解密最后按顺序播放。理解了这一层爬虫要做的其实就是“手动代替播放器做一遍”下载索引、下载密钥、下载分片、解密、合并。腾讯视频和标准HLS略有差异。部分清晰度下它会返回TS分片但也有可能返回自定义封装的wemb或wmp4格式这些分片虽然改名了本质还是视频数据解密后按顺序拼起来就能喂给播放器。开头看到m3u8别高兴太早还要看分片后缀和加密信息才能决定下一步怎么做。1.2 腾讯视频的“加密流”加密在哪个环节腾讯视频的加密不是单点加密而是多层叠加。最外层是播放接口的鉴权参数接口URL往往带一长串platform、charge、defn、vid、guid之类的参数服务端会校验这些参数是否合法第二层是密钥分发密钥接口可能要求特定的Cookie、Referer和User-Agent缺一个就返回403第三层才是分片本身的AES-128加密。也就是说就算你绕过第一层拿到m3u8地址直接去下载TS分片也会拿到一堆加密后的乱码没有密钥根本拼不出画面。这就解释了很多人“明明拿到m3u8却下不了”的现象——索引文件确实可以下载但真正难的是让后面所有请求都像合法播放器一样带着正确的身份信息。另一个容易忽略的点是密钥时效性。腾讯部分内容的m3u8地址和密钥都有有效期短则几分钟长则几十分钟过期后密钥接口返回的内容可能变了分片地址也可能失效。因此写下载器时最好“先快速拉索引和密钥再批量下载分片”不要索引放那儿半天再回来下。1.3 vid、播放页与流媒体URL之间的依赖关系腾讯视频每个视频都有唯一标识一般在播放页URL里能看到形如vidm00366...也可能藏在页面meta标签的videoInfo里。它的作用就像电影的ID号播放器拿这个ID去后台换真正的播放地址。流程是播放页 - 拿到vid - 请求播放信息接口 - 接口返回不同清晰度对应的m3u8地址 - 下载。所以要写爬虫第一步不是写下载器而是先搞清楚从播放页到m3u8之间的“链路”。这一步通常需要打开浏览器开发者工具切到Network面板刷新播放页过滤m3u8或getinfo等关键词观察哪些XHR请求返回流媒体地址。抓包拿到真实接口和参数后再用Python模拟请求。这个过程不复杂但也很容易被带偏有些教程直接贴一个写死的接口地址过几天平台一改参数就全废了。正确思路是掌握“链路怎么找”而不是记“接口长什么样”。2. 完整下载链路从播放页面到MP4文件前面把原理讲清楚了接下来走一遍完整链路。这个流程不是我想当然设计出来的而是对照“播放器实际做的事”一步步拆出来的你跟着做一遍就能体会到爬虫和逆向工程师的工作方式。2.1 整体流程梳理下载一个m3u8加密流大体分五步从视频URL或页面源码中提取vid。模拟请求播放信息接口拿到指定清晰度的m3u8地址。下载m3u8索引文件解析出密钥URL和全部分片地址。下载密钥再逐个下载分片如果分片被AES-128加密则用密钥解密。把所有明文分片合并成MP4或MPEG-TS文件。一句话总结拿到视频的唯一标识 - 换播放列表 - 批量拉片 - 解密合并。很多人喜欢上来就写一大段下载代码最后卡在“怎么拿到m3u8地址”上所以步骤2才是真正的技术分水岭。先把浏览器里的请求链路盘明白了代码反而不难。2.2 准备开发环境建议使用Python 3.9以上版本安装三个核心库就够了pip install requests m3u8 pycryptodome另外合并分片最方便的工具不是Python本身而是FFmpeg。它一站式解决协议转换和封装问题在官网下载对应系统的可执行文件后确保ffmpeg命令能被终端识别。requests负责HTTP请求m3u8负责解析索引pycryptodome负责AES-128解密。分片下载可以不用额外库requests循环拉取就行但要控制并发的话可以后续加concurrent.futures。这里有一点要注意pycryptodome和pycrypto不能同时存在你要是装了老旧的pycrypto先卸掉再装pycryptodome否则from Crypto.Cipher import AES会报奇怪的错。2.3 第一步拿vid腾讯视频的播放页URL通常是https://v.qq.com/x/page/{vid}.html这种格式或者带了?vidm00366...查询参数。最省事的正则如下import re def extract_vid(url_or_html: str) - str: # 优先从URL参数里取 m re.search(r[?]vid([0-9A-Za-z]), url_or_html) if m: return m.group(1) # 再从页面源码里取 m re.search(rvid[:\s\]([0-9A-Za-z]), url_or_html) if m: return m.group(1) m re.search(r/([0-9A-Za-z])\.html, url_or_html) if m: return m.group(1) raise ValueError(没有找到vid)页面源码里的vid有时候会被转义或拆成多段常规正则搞不定时直接搜索vid关键词把附近内容打印出来人工分析比瞎猜正则快得多。另外腾讯的旧版URL里vid不一定等于id有的页面是cid和vid两个字段cid是频道IDvid才是视频ID别搞混。2.4 第二步构造播放请求拿playlist拿到vid后浏览器会向播放信息接口发请求返回结果里通常包含一个playlist或video_info列表每个元素对应清晰度 播放地址。这里的接口地址和请求参数会随着平台升级变化我建议你打开抓包工具找到真实请求后再填进代码不要照抄一份过期配置。下面给出一个通用的请求模板重点是把关键位置做成可配置项import requests def get_stream_url(vid: str, quality: str hd, headers: dict None, getinfo_api: str ) - str: getinfo_api需要替换为抓包得到的真实地址 if not getinfo_api: raise ValueError(请先抓包确定播放信息接口地址) params { vid: vid, platform: html5, charge: 0, defn: quality, # 清晰度标识 otype: json, } resp requests.get(getinfo_api, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() # 不同版本接口返回结构不同下面只是最常见的字段示例 for item in data.get(playlist, []): if item.get(name) quality or item.get(defn) quality: return item[url] # 部分接口会直接返回主视频流 return data.get(url) or data.get(playUrl) or data[playlist][0][url]真实接口返回的是嵌套很深的JSON里面不仅有URL还有width、height、duration等。为了熟悉结构建议先写一句print(json.dumps(data, indent2, ensure_asciiFalse))把字段名看清楚了再写解析逻辑。记住接口参数永远以抓包为准代码里的占位符只是帮你搭建骨架。2.5 第三步下载m3u8索引与密钥拿到m3u8地址后先下载索引文件再用m3u8库解析import m3u8 def parse_playlist(m3u8_url: str, headers: dict): resp requests.get(m3u8_url, headersheaders, timeout10) resp.raise_for_status() playlist m3u8.loads(resp.text, urim3u8_url) return playlistm3u8.loads第二个参数uri很关键它的作用是补全相对地址。比如索引文件在https://example.com/path/index.m3u8里面分片地址如果是segment_0.ts库会自动拼接成https://example.com/path/segment_0.ts。不传uri的话你拿到的分片地址就是相对路径后来下载必然失败。解析结果里playlist.segments是所有分片对象每个对象有uri、duration、key等属性。playlist.keys是密钥信息集合通常只有一个元素。segment.key.uri指向密钥文件segment.key.method表示加密方式AES-128或NONE。拿到密钥后下载并预处理成16字节的密钥内容供解密使用from Crypto.Cipher import AES def get_aes_key(key_url: str, headers: dict) - bytes: resp requests.get(key_url, headersheaders, timeout10) resp.raise_for_status() return resp.content # AES-128要求16字节标准HLS里如果IV没写默认是全0。腾讯部分内容会在密钥URL里带上IV参数那就必须从参数中解析出16字节IV并传给解密器不处理的话画面出来后是花的或者直接黑屏。2.6 第四步解密分片并合并成MP4分片内容下载下来后如果是AES-128加密的解密按照CBC模式来做def decrypt_segment(encrypted_data: bytes, key: bytes, iv: bytes) - bytes: cipher AES.new(key, AES.MODE_CBC, iv) return cipher.decrypt(encrypted_data)注意CBC模式不要求去除填充因为TS分片通常按16字节对齐加密解密结果本身就是完整的TS流。如果你解出来之后播放器提示数据损坏检查一下是不是把填充多删了。分片全部解密后有两种合并方式。分片不多且顺序固定时用FFmpeg最省心ffmpeg -y -f concat -safe 0 -i list.txt -c copy output.mp4list.txt是分片清单格式如下file seg_0000.ts file seg_0001.ts file seg_0002.ts-c copy表示不重新编码纯拼接速度最快。如果分片本身就是MP4碎片FFmpeg也能自动处理但拼接前最好确保所有分片编码参数一致。还有一种情况是分片没解密就直接丢给FFmpeg结果自然失败所以合并前先单独拿一个分片解码验证一下不要等全部跑完才发现思路有问题。如果是TS分片也可以直接用Python把二进制内容按顺序写进同一个文件得到.ts文件再用FFmpeg转封装成.mp4ffmpeg -y -i output.ts -c copy output.mp4个人建议路径解密后的分片统一命名为seg_%05d.ts先合并成TS再转MP4。这样中途任一分片挂了你从报错序号能快速定位不需要重跑全部。3. 完整代码可以直接跑通的下载器骨架这一节把上面所有流程串成一个完整的Python下载器。骨架遵循“通用m3u8下载器 腾讯视频接入层”分离的思路前者可以复用于任何标准HLS流后者负责把腾讯视频的视频ID换成m3u8地址。代码我尽量写全但请记住关键接口地址要换成你自己抓包的。3.1 通用m3u8下载器首先是一个不依赖平台逻辑的下载器类只需要传入m3u8地址、请求头、保存目录即可。import os import re import requests from urllib.parse import urljoin from Crypto.Cipher import AES import m3u8 class M3U8Downloader: def __init__(self, m3u8_url: str, headers: dict, save_dir: str download): self.m3u8_url m3u8_url self.headers headers self.save_dir save_dir os.makedirs(save_dir, exist_okTrue) self.session requests.Session() self.session.headers.update(headers) def _download(self, url: str) - bytes: resp self.session.get(url, timeout15) resp.raise_for_status() return resp.content def parse(self): content self._download(self.m3u8_url).decode(utf-8, errorsignore) self.playlist m3u8.loads(content, uriself.m3u8_url) def download_segment(self, seg, idx: int): seg_url urljoin(self.m3u8_url, seg.uri) encrypted self._download(seg_url) if seg.key and seg.key.method AES-128: key_url urljoin(self.m3u8_url, seg.key.uri) key self._download(key_url) iv self._get_iv(seg) encrypted self._decrypt(encrypted, key, iv) ext os.path.splitext(seg.uri)[1] or .ts path os.path.join(self.save_dir, f{idx:05d}{ext}) with open(path, wb) as f: f.write(encrypted) return path def _get_iv(self, seg) - bytes: if seg.key and seg.key.iv: iv_hex seg.key.iv.replace(0x, ) return bytes.fromhex(iv_hex) # 标准HLS没写IV时默认全0 return b\x00 * 16 staticmethod def _decrypt(data: bytes, key: bytes, iv: bytes) - bytes: if len(key) ! 16: raise ValueError(f密钥长度不正确: {len(key)}) cipher AES.new(key, AES.MODE_CBC, iv) return cipher.decrypt(data) def run(self): self.parse() segment_paths [] for idx, seg in enumerate(self.playlist.segments): path self.download_segment(seg, idx) segment_paths.append(path) print(f[{idx 1}/{len(self.playlist.segments)}] {path}) return segment_paths_get_iv这个方法很多开源的m3u8下载器根本没处理但腾讯部分旧接口确实会在key里显式指定IV缺失会导致解密结果整体偏移图像花屏。写成独立函数后以后遇到特殊IV直接在这里改就行。3.2 腾讯视频接入层接下来写腾讯视频的接入层负责拿到m3u8地址。最终效果是download_tencent_video(vid)一步到位。def get_tencent_m3u8(vid: str, headers: dict, getinfo_api: str ) - str: if not getinfo_api: raise RuntimeError(请先通过抓包工具获取播放信息接口地址) params { vid: vid, platform: html5, charge: 0, defn: hd, otype: json, } resp requests.get(getinfo_api, paramsparams, headersheaders, timeout10) resp.raise_for_status() info resp.json() # 这里需要根据抓包结果打印info结构自行调整字段 print(json.dumps(info, ensure_asciiFalse, indent2)[:2000]) playlist info.get(playlist) or [] if not playlist: raise RuntimeError(接口返回中没有playlist) return playlist[0][url] def download_tencent_video(vid: str, headers: dict, getinfo_api: str ): m3u8_url get_tencent_m3u8(vid, headers, getinfo_api) downloader M3U8Downloader(m3u8_url, headers, save_dirfvideo_{vid}) segment_paths downloader.run() print(分片下载完成开始合并) merge_with_ffmpeg(segment_paths, fvideo_{vid}.mp4)看到这里你可能会问这代码不完整啊getinfo_api和headers怎么填这正是我写文章想强调的一点流媒体下载的难点永远在链路不在分片复制。getinfo_api必须由你自己在浏览器里抓包得到不同地区、不同账号、不同清晰度都可能不同。3.3 合并函数与进度显示合并函数内部调用FFmpeg并顺手处理分片清单的生成import subprocess def merge_with_ffmpeg(segment_paths, output_path: str): list_file concat_list.txt with open(list_file, w, encodingutf-8) as f: for path in segment_paths: abs_path os.path.abspath(path).replace(, \\) f.write(ffile {abs_path}\n) cmd [ ffmpeg, -y, -f, concat, -safe, 0, -i, list_file, -c, copy, output_path, ] proc subprocess.run(cmd, capture_outputTrue, textTrue) if proc.returncode ! 0: print(proc.stderr[-3000:]) raise RuntimeError(FFmpeg合并失败请检查分片是否完整) print(f合并完成: {output_path})这段在生产环境里很有用因为很多分片文件名包含特殊字符不加-safe 0和引号会直接报错。FFmpeg的stderr信息比较啰嗦报错时只看最后3000个字符基本上错误原因都在尾部。进度显示我故意没做复杂处理用最简单的print就够了。实际下载量大时可以考虑用tqdm做控制台进度条同时把错误分片记录到日志文件里断点续传也方便。3.4 使用代码前的配置说明启动脚本前你需要准备三样东西浏览器登录态Cookie也就是headers里的Cookie字段。真实播放信息接口地址getinfo_api。目标视频的vid。headers至少包含User-Agent和Referer作者建议直接从浏览器复制完整的请求头别手工精简。有些接口会校验Sec-Fetch-Site、Origin这些看起来“无用”的字段缺一个就返回鉴权失败。把浏览器里的请求头原封不动粘进来是最省事的办法。4. 实战中一定会踩的坑m3u8网络面板看不到、key失效、分片403这一节全是经验之谈。我整理了几个最常遇到、也最容易让人当场崩溃的问题每一个我都见过不止十次。4.1 为什么Network面板里搜不到m3u8很多人拿着教程准备抓m3u8地址结果在浏览器开发者工具里一搜m3u8发现什么都没有第一反应是平台把m3u8藏了。其实不是藏了而是你没有抓到正确的请求。排查顺序如下在Network面板里切换资源类型不要只搜关键词。很多站点把XHR请求命名为getinfo、play、stream返回的JSON里才带m3u8地址。过滤域名。腾讯视频的播放接口域名可能不是v.qq.com而是pd.qq.com、apd-...之类的CDN域名。搜索时用m3u8搜不到就改成搜vid值。关注fetch/xhr标签而不是media标签。浏览器会把ts分片归入Media类型但索引文件的XHR请求不一定归入Media。部分场景下m3u8内容不是明文JSON而是经过一层字符串拼接或Base64编码。搜索不到时先把所有XHR响应体看一遍找http开头且包含.m3u8的字段。还有种情况是播放器用了blob:地址播放。如果发现播放器的src是blob:https://v.qq.com/uuid说明实际m3u8被JavaScript处理后才交给播放器这时候可以直接在Network里搜index.m3u8或m3u8?来进行精确匹配大概率能在某个script或fetch请求里找到真实地址。4.2 密钥抓取与AES-128解密失败的典型原因密钥下载成功不代表解密一定成功最常见的问题有四个密钥长度不是16字节。AES-128要求密钥严格等于16字节但很多接口返回的密钥是Base64编码后的字符串需要base64.b64decode()不处理就拿去解密长度一定不对。IV被忽略。前面提过没指定IV时默认全0。如果分片解密出来花屏优先检查IV是否应该解析密钥URL中的IV0x...参数。密钥过期。HLS的密钥地址通常带时效签名过期后请求会返回错误页面或空内容。解决办法是缩短索引解析到分片下载之间的间隔。CDN节点不返回完整密钥。某些情况下请求密钥接口会得到302跳转最终落到另一个域名requests默认会跟随跳转但如果你手动allow_redirectsFalse就会拿到一堆空内容。验证密钥是否正确的最直接方法拿第一个分片解密后的数据用file命令看文件类型。如果输出是MPEG transport stream或ISO Media说明解密没问题如果显示data或乱码毫无疑问是密钥或IV出错。4.3 分片下载403和限速怎么办分片403通常不是IP被封而是请求头不完整。TS分片地址由CDN下发CDN会校验请求头里的Referer和User-Agent。很多人下载m3u8时用了带Cookie的请求头但下载分片时换了一个裸的requests请求自然被拒。解决办法全程复用一个Session并保证headers里始终有Referer: https://v.qq.com/。限速这个事更玄学。同一个IP同时在下载多个分片时CDN可能会根据QPS做动态限速表现就是分片越下越慢甚至连续超时。我的经验是两个思路第一控制并发数在5以内不要一次性开50个线程去抢第二分片下载失败后不要无脑重试采用指数退避第一次等1秒第二次等2秒第三次等4秒超过5次就把失败分片记录下来最后统一补下。不然你疯狂重试只会触发更严格的风控。4.4 m3u8转MP4失败的常见原因FFmpeg合并报错通常不是FFmpeg的问题而是分片数据本身有问题。最常见的报错是Invalid data found when processing input这说明FFmpeg从某个分片开始读不到有效的视频流原因大概率是那一分片没有解密或者下载时就被截断了。这时候你检查一下对应序号的分片大小如果明显小于其他分片直接重下。另外m3u8转mp4失败还有可能是分片列表里有重复或缺失的序号导致拼接顺序错乱时间轴就废了。我用过一个土办法下载完成后先对比一下playlist.segments的数量和实际文件数量不一致就说明中间漏片了。还有一种情况是分片是wemb或wmp4格式FFmpeg不认识这个扩展名。处理方法是先改成.bin再用ffmpeg -f h264强制指定编码格式或者干脆用VLC播放器打开m3u8验证一下能不能播。VLC能播但FFmpeg不能大概率是封装格式问题不是数据损坏。5. 这套东西的边界哪些合法哪些千万别碰技术本身没有立场但使用技术的人要看清边界。我说几句比较实在的经验。5.1 我建议的合法使用场景对绝大多数开发者来说m3u8下载器的价值在于处理“自己有权限但平台不给下载入口”的内容。常见合法场景包括备份自己上传到平台的原创视频防止原始素材丢失。企业内网培训视频的离线归档前提是你在内部系统有下载权限。学习HLS协议和流媒体知识用公开测试视频或平台公开的预告片做实验。二次创作时需要引用已授权或开放许可的素材。在这些场景下本文的代码可以直接用成本低、不折腾。文章开头提到的朋友备份自己的教学视频就属于第一种。拿到原片后他顺手把码率、分辨率信息补到自己的素材库里没有任何版权问题。5.2 反爬本身不是用来对抗的而是用来读懂的每次看到有人兴冲冲地说“终于绕过了腾讯的反爬”我心里都会咯噔一下。平台做加密流、动态签名、CDN校验这些机制的首要目的是控制访问权限不是专门为了防爬虫。我们做技术研究时理解这套机制如何运转是有价值的但“绕过”付费墙或访问控制去拿独播剧属于侵权行为轻则封号重则惹上法律问题。我自己现在的习惯是研究任何平台时只处理公开页面、无版权限制或者明确允许抓取的资源。例如抓取一个播放页我只分析请求流程和加密原理不会把完整下载器跑在一个VIP付费视频上。这样既满足技术好奇心也不会给自己留隐患。5.3 可以继续深入的方向如果你对HLS协议本身感兴趣下面几个方向能让你进一步打开思路学习标准HLS的#EXT-X-DISCONTINUITY等进阶标签理解多码率自适应切换。把下载器改造成支持并发分片下载和断点续传对大规模下载任务非常实用。用yt-dlp这类开源项目对照自己的实现看看成熟项目如何处理密钥、代理、限速。研究DRM加密的边界但只做科普级别的了解就够了别去碰真正的版权保护方案。另外可以给下载器加一个简单的Web管理页面输入腾讯视频链接就能自动解析并下载这对非技术用户会比较友好。日志系统也要跟上把每个分片的大小、耗时、重试次数记录下来方便调优。回到开头的那个场景我最终帮朋友把这个下载器跑通了整个过程最花时间的反而不是代码而是抓包和调试请求头参数。所以我也想提醒你两件事第一遇到问题先按链路顺序排查别一上来就怀疑是自己代码写错第二赶紧把你自己抓到的真实接口地址、请求头注释在代码里下次遇到平台改版你的经验就是最值钱的资产。如果只是照抄教程里的固定接口那这套代码大概率活不过三个月。