资讯动态

HLS视频流下载实战:从m3u8解析到反爬识别

发布时间:2026/10/17 15:59:02 来源:尧图企业网站定制
一年前我接到一个内部视频平台的迁移任务要把一批历史录像从旧存储桶搬到新的对象存储里。原以为不过是多线程下载加归集结果抓包一看平台压根不给任何直链所有接口返回的都是同一个后缀的文本文件——m3u8。从那天起我开始系统梳理HLS视频流的获取链路和反爬识别方案这篇就是这个系列的第一篇。我会用一篇能落地实操的方式讲清楚三件事m3u8到底是个什么东西、如何把一个m3u8流完整地落到本地磁盘、以及常见的反爬校验遇到后该怎么正确识别和处理。适合刚接触流媒体数据处理、想搞懂HLS下载原理的开发者或者正在做视频自动化测试、素材归档管理的朋友。先划一条界线本文只讨论HLS协议本身的机制和调试自己有权访问的媒体源不涉及任何版权内容的盗取也不碰DRM破解。1. 扒开m3u8的面纱它根本不是一种视频格式1.1 它不是冷门格式而是一份“播放菜单”很多人第一次看到m3u8第一反应是“这又是什么冷门格式”然后到处问播放器能不能直接打开。其实m3u8的准确身份是HLSHTTP Live Streaming的播放列表文件本质是一个UTF-8编码的纯文本清单。它本身不包含任何画面和声音里面的每一条记录要么指向一个ts分片要么指向另一个m3u8文件。我用一个生活化类比来说明m3u8相当于餐厅里的菜单真正的“菜”是那些ts分片播放器拿到菜单之后按照顺序把每一道菜端上桌视觉上就变成了流畅的视频流。理解这一点后面所有的操作都有方向了我们要做的事情就是“拿到菜单、按序号取菜、最后摆成一桌”。很多站点之所以选择HLS而不是直接给MP4直链核心原因是HLS天然支持自适应码率和多路复用。用户网络差的时候播放器可以平滑切换到低码率分片不会中断网络恢复后又能自动切回高清。这个优势在移动端尤其明显所以你会看到大量视频App和Web播放器底层都在用HLS。1.2 从一行行字段读懂一个媒体清单打开一个最简单的m3u8文件内容看起来差不多是下面这样#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.0, https://example.com/video/seg-0001.ts #EXTINF:10.0, https://example.com/video/seg-0002.ts #EXTINF:10.0, https://example.com/video/seg-0003.ts #EXT-X-ENDLIST看起来像乱码其实每行都有明确含义。我整理了一张常用字段的速查表解析时重点关注加粗的几个字段含义处理时的关注点#EXTM3U文件头声明这是一个m3u播放列表判断文件是否有效的第一依据#EXT-X-VERSIONHLS协议版本号老版本播放器可能不支持过高版本#EXT-X-TARGETDURATION单个分片的最大时长单位秒估算总时长和分片数量的关键值#EXT-X-MEDIA-SEQUENCE第一个分片的序列号直播流中经常用于判断是否有新分片#EXTINF分片时长下一行为分片地址解析时注意“两行为一组”#EXT-X-KEY分片加密信息如加密方法和密钥地址遇到加密流必须处理这个字段#EXT-X-ENDLIST结束标记没有它说明是直播流直播流不会结束需要循环拉取这里有个最容易踩的坑#EXTINF和其他标签不同它不是单行结束而是“一行标签 下一行地址”的组合。很多人写解析脚本时习惯按行跳结果把时长和地址错位导致下载了错误的分片。正确做法是把整个文件读进来之后用状态机的方式逐个标签处理或者用正则直接匹配#EXTINF:([0-9.]),\n(.)这种两行模式。另外如果列表里出现了#EXT-X-KEY:METHODAES-128,URIhttps://example.com/key说明分片是加密的。HLS最常见的是AES-128 CBC加密密钥通过URI指定的地址获取。想正常播放和下载必须先取得这个keyffmpeg会自动处理但如果手动逐片下载这一步就要自己实现。1.3 主播放列表与媒体播放列表先分清你要抓哪一层实际项目里返回的m3u8经常不是上图中那种“一步到位”的分片列表而是一个主播放列表master playlist。它不直接指向ts而是指向多个不同码率的子播放列表。每一路子流用#EXT-X-STREAM-INF标识里面会写明带宽和分辨率示例#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH1280000,RESOLUTION1280x720 https://example.com/hls/720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH2560000,RESOLUTION1920x1080 https://example.com/hls/1080p/index.m3u8如果直接把主播放列表丢给播放器播放器会按当前网络带宽自动选一路。但如果我们自己控制下载逻辑就必须先解析出所有子列表再决定选哪一路。我的经验是网络不是瓶颈时直接选BANDWIDTH最大的那一档如果只是想快速预览或做素材归档选720p这一档往往性价比最高——文件体积小画面细节又够用。还要注意有些特殊实现会把RESOLUTION字段写得很夸张但实际分片依然是低分辨率单看字段容易判断失误。稳妥的做法是下载个子列表看一眼分片大小再配合ffprobe确认真实分辨率。2. 熟悉整套链路工具准备与抓包观察2.1 一套能打的基础工具链我没有用现成的“一键下载工具”直接操作而是用底层工具把整条链路打通了一遍。好处是以后再遇到变种格式或者更复杂的反爬心里有底不会一换场景就抓瞎。基础工具清单如下工具角色常用命令/入口浏览器开发者工具抓包、看请求头、定位m3u8真实地址F12Network面板curl命令行发请求、验证Header、保存原始文件curl -I、curl -offmpeg最终合并、解密、转封装ffmpeg -i xxx.m3u8ffprobe探测视频信息、验证时长、编码格式ffprobe -show_format xxx.tsPython编写解析脚本、批量下载、异常重试requests、re、ThreadPoolExecutor如果你是在图形界面环境也可以先用VLC播放器验证一个m3u8能不能正常播放。VLC自带HLS解析能力很多“能不能播”的问题能快速定位到是否加密、分片地址是否可访问。但VLC只适合验证不适合批量操作所以脚本化还是得靠curl/Python/ffmpeg这条路线。工具链的安装不属于本文重点但我建议尽量装新版本ffmpeg老版本对HLS新特性的支持不够好特别是遇到#EXT-X-MAP这种使用fMP4分片的HLS变种时版本太旧会直接报Invalid data found when processing input。2.2 先判断返回的是不是可解析的m3u8拿到一个URL之后第一步不是急着下载而是先判断返回内容到底是什么。用curl看响应头curl -I https://example.com/path/index.m3u8重点关注三样东西HTTP状态码200说明请求成功403/404说明大概率被拦截或路径不对Content-Type标准HLS返回的是application/vnd.apple.mpegurl或者application/x-mpegURLContent-Lengthm3u8是文本文件体积通常只有几KB到几十KB不会很大不过不要过度依赖Content-Type。我在实际项目中遇到过好几台服务器故意不按标准返回Content-Type而是给一个application/octet-stream看着像未知二进制其实内容仍然是文本。所以最可靠的方法还是直接把文件拉下来查看第一行是不是#EXTM3U。第一行是#EXTM3U它就是一个可解析的播放列表不管扩展名和Content-Type怎么标。还有一种情况服务端返回200但响应正文是一段JSON里面写着类似{code:-1,message:invalid signature}。这种说明你当前请求缺少签名参数或者签名已经过期根本不是返回了m3u8。如果脚本没做内容校验直接把JSON当文本解析后面就会得到一堆莫名其妙的“分片地址”。2.3 反爬之前先看清请求头里有什么很多刚接触反爬的同学一上来就研究加密算法和签名逻辑反而忽略了一个最基础的事实服务端通常先看请求头请求头不过关根本轮不到后面的参数校验。常见的请求头校验有三类User-Agent标记客户端类型。普通浏览器的UA长一大串而curl默认的UA是curl/x.x.x很多服务端看到这个UA直接拒绝。Referer告诉服务端请求是从哪个页面跳过来的。不少视频站要求Referer必须匹配页面域名否则返回403。Cookie维持登录态或会话信息。有些站点允许匿名访问m3u8但分片请求仍需带上同一个Cookie否则分片地址重新生成旧地址立即失效。我的做法是每次抓包时把浏览器Network面板里请求m3u8的完整请求头复制出来保存成一个初始模板。之后所有分片请求都基于这个模板只改动URL其他都保持一致。这样能最大程度避免因为请求头不一致导致莫名其妙被拒。有一点要说明复制请求头不是“伪装身份去攻击别人”而是让一个有权的合法客户端保持行为一致。做接口调试、素材归档、自动化测试时请求头稳定是非常基本的要求。2.4 本地建立一个原始请求副本为了不反复打开浏览器抓包我习惯在拿到一次完整请求后直接把它记录成一份可复用的请求文件。最简单的办法是浏览器开发者工具里右键请求选择“Copy as cURL”保存成一个bash脚本后面需要用的时候直接在脚本基础上改地址和参数。保存好的cURL脚本还有一个好处可以快速验证m3u8是否过期。比如脚本里带了一个签名参数执行一次发现返回403就说明签名过期了需要回到页面重新获取新的m3u8地址。这种“先验证、再写自动化”的方式能避免在无效地址上重复浪费时间。3. 完整实操把一个m3u8流落成本地视频3.1 拉取主播放列表并选定码率先拿命令行工具把主播放列表拉下来curl -H User-Agent: Mozilla/5.0 (compatible; custom) \ -H Referer: https://example.com/ \ -o master.m3u8 \ https://example.com/path/master.m3u8然后把文件打开找到所有#EXT-X-STREAM-INF行。如果主列表内容很多可以用grep看个大概grep -n EXT-X-STREAM-INF\|^https master.m3u8手动选择时我一般按下面顺序决定目标是高清收藏选择BANDWIDTH最大且RESOLUTION不虚标的那一路目标是快速归档或转码选择720p档位只想验证链路通不通选择最低码率档位省流量选定子列表地址后再单独把它拉下来保存为playlist.m3u8。注意子列表地址可能是相对路径比如/hls/720p/index.m3u8需要拼上协议和域名或者和主列表URL做一次urllib.parse.urljoin拼接这点后面解析脚本里也会遇到。3.2 提取所有TS分片地址拿到媒体播放列表后核心工作就是把分片地址全提取出来。我用一个简单Python脚本处理import re def parse_m3u8_segments(m3u8_text, base_url): segments [] pattern re.compile( r#EXTINF:([0-9.]),?\n(.) ) matches pattern.findall(m3u8_text) for duration, seg_url in matches: if not seg_url.startswith(http): seg_url base_url.rstrip(/) / seg_url.lstrip(/) segments.append({ duration: float(duration), url: seg_url.strip() }) return segments with open(playlist.m3u8, r, encodingutf-8) as f: content f.read() segments parse_m3u8_segments(content) print(f共发现 {len(segments)} 个分片) for item in segments: print(item[duration], item[url])有两个细节必须处理地址可能是相对路径也可能是带了query参数的完整地址。相对路径拼接时直接用/拼接容易出错因为有些子列表路径里还带目录层级比如https://example.com/hls/720p/index.m3u8里的分片地址是segment/0001.ts实际完整地址应该是https://example.com/hls/720p/segment/0001.ts不是简单拼在域名后面。用urllib.parse.urljoin(base_url, seg_url)更可靠。排序时不要依赖文件名里第几位数字而要根据#EXT-X-MEDIA-SEQUENCE或者#EXTINF在文件中出现的顺序。有的系统分片命名是0001.ts、0002.ts还好但遇到命名带日期或者随机串的情况按字典序排序会把逻辑顺序打乱。3.3 用ffmpeg一键合并推荐路线如果只是想把整个流完整落盘又不关心逐片下载过程ffmpeg是最省事的方式ffmpeg -y \ -headers User-Agent: Mozilla/5.0\r\nReferer: https://example.com/\r\n \ -i https://example.com/path/playlist.m3u8 \ -c copy \ -bsf:a aac_adtstoasc \ output.mp4这里解释几个关键参数-headers给ffmpeg的所有HTTP请求注入自定义请求头解决UA/Referer校验-c copy流复制不重新编码速度快、没有二次画质损失-bsf:a aac_adtstoasc某些TS流里的AAC音频是ADTS封装直接封装进MP4会出现音频异常这个参数会把音频流转成MP4支持的格式ffmpeg会自动读取m3u8里的#EXT-X-KEY动态请求密钥并完成AES-128解密整个过程不需要手动处理密钥文件。实测中只要m3u8的请求头能过密钥请求通常也能过因为同一个服务端对同一会话的校验规则是一致的。如果担心下载过程因网络波动中断可以在命令里加-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5让ffmpeg在网络中断时自动重连。不过要注意-reconnect参数在部分编译版本里不可用遇到ffmpeg报Unrecognized option reconnect时说明你用的版本没有启用这个特性可以换一种思路用下面要讲的手动分片下载方案。3.4 手动下载分片再合并断点续传需求需要精细控制下载过程或者要做断点续传时我选择逐片下载。流程很简单# 逐个下载分片保存为统一前缀 原序号 cat playlist.m3u8 | grep ^https | nl -ba | while read idx url; do curl -H User-Agent: Mozilla/5.0 \ -H Referer: https://example.com/ \ -o seg_$(printf %04d $idx).ts \ $url done # 生成concat列表 for f in seg_*.ts; do echo file $f concat.txt; done # 用ffmpeg concat demuxer合并比直接cat可靠 ffmpeg -f concat -safe 0 -i concat.txt -c copy output.ts手动方案看起来麻烦但它有三个不可替代的价值每个分片独立下载哪个分片失败可以单独重试下载进度可控可以随时暂停恢复可以和自己的并发调度逻辑结合控制请求频率而不是一把梭全量拉取合并时建议用ffmpeg -f concat而不是cat file*.ts out.ts。后者只是把二进制拼接起来遇到分片之间时间戳不连续时合成文件可能会出现播放卡顿甚至进度异常而ffmpeg的concat demuxer会在分片交接处做时间戳归一化兼容性更好。3.5 理论时长与实际时长的交叉求证下载合并完成后不要急着认为“文件能打开就算成功”。我习惯用ffprobe验证一遍ffprobe -show_format -show_streams output.mp4重点看两个值duration总时长nb_frames视频帧数然后和m3u8里的理论时长对比。比如TARGETDURATION是10秒分片数量180个理论总时长应该在1800秒附近如果实际输出只有1500秒说明中间有分片下载失败或者被服务器返回了空内容播放时也许看不出明显断点但进行转码、剪辑或字幕对齐时一定会出问题。这一步强烈建议做成固定流程。我把“下载完就看一眼时长”当成习惯之后排查效率提升了很多很多“玄学花屏”问题都是在这个阶段被发现的。4. 反爬识别服务端到底在防什么4.1 三板斧UA、Referer、Cookie做视频流获取时最先被拦截的地方不是加密算法而是最简单的HTTP请求头。服务端常见的判定逻辑是User-Agent里是不是常见浏览器或播放器客户端Referer是否匹配当前页面的域名Cookie是否存在且未过期这三样全过了基本就是一个“正常访客”的样子。如果直接用默认的curl去请求UA那关就会被拦掉很多新手拿到的报错就是403 Forbidden。处理方式是用Python requests建立可持续会话import requests session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/, Accept: */*, }) r session.get(https://example.com/path/playlist.m3u8)统一维护一个session最大的好处是Cookie会被自动保存并在后续请求中带上不会出现m3u8能访问但密钥接口又需要重新登录的情况。4.2 签名URL与动态Key比请求头再难一点的是签名URL。这种URL不是固定的而是由服务端动态生成通常会带上时间戳、过期时间和签名值比如https://example.com/hls/index.m3u8?expires1710000000tokenabc123def456服务端会校验expires是否大于当前时间、token签名是否合法。这类签名一般很难直接篡改生成逻辑正确做法是回到页面获取新的播放列表地址再继续后续流程。写脚本时我建议把“获取m3u8”和“下载分片”拆成两个独立函数每次下载前先刷新一次地址再基于这个地址去取分片。这样即使地址有有效期也能在有效窗口内完成大部分下载工作。动态Key则更复杂一点。有的系统不仅给分片做AES-128加密而且密钥接口每次返回不同内容或者密钥URI里面带会话标识。这种情况如果还沿用旧的m3u8里的key地址下载下来发现密钥早就失效了。解决思路是宁可多请求一次m3u8把最新分片列表和最新key地址一起拿到也不要复用几小时前的旧列表。4.3 限频、超时与半开连接服务端最容易做的另一层防护是限频。短时间内同一IP请求分片几百上千次触发阈值后就开始返回429或503。想做批处理时这个限制比签名还让人头疼因为签名刷新一次能解决但限频是持续性的。我实际使用的策略是“顺序下载 限量并发 失败退避”不追求极限速度。示例import time from concurrent.futures import ThreadPoolExecutor, as_completed import threading sem threading.Semaphore(3) # 控制最大并发数 def download_one(segment): with sem: for attempt in range(3): try: r session.get(segment[url], timeout10) if r.status_code 200 and len(r.content) 0: # 保存分片返回成功 return segment[url], True except Exception: pass time.sleep(1.5 * (attempt 1)) return segment[url], False with ThreadPoolExecutor(max_workers5) as pool: futures [pool.submit(download_one, s) for s in segments] for future in as_completed(futures): url, ok future.result() if not ok: print(失败:, url)这里把并发数控制在3单分片超时10秒失败最多重试3次。速度上确实比全量并发慢一些但稳定性能好很多而且不容易触发服务端限流。做后台素材归档这类任务稳定比短时快更重要。4.4 没有ENDLIST的直播流怎么处理有一种特殊情况m3u8里没有#EXT-X-ENDLIST代表这是一个直播流分片地址会不断新增。处理直播流时不能一次性拉完而是要用“轮询”的方式每隔一段时间重新请求一次m3u8把新增的分片补下载下来直到手动停止。直播流的分片地址往往带有递增序列号或者新的时间点处理逻辑上要注意去重避免重复下载同一个分片。实践上我用一个集合保存已下载的分片URL每次轮询只处理新增项。这样既不会漏也不会重。5. 避坑手册实测踩过的坑5.1 403和416最常出现在哪个环节这几个状态码大概是所有做m3u8下载的人见得最多的我把它们的典型原因和处理方式整理成一张速查表状态码典型场景处理思路403 Forbidden请求头校验不过或者签名过期检查UA/Referer/Cookie模板重新获取m3u8地址404 Not Found相对路径拼接错误用完整URL访问分片检查是否漏了子目录416 Range Not Satisfiable请求里带了错误的Range头去掉手动Range头或确认分片范围是否合法429 Too Many Requests并发过快触发了限流降低并发数增加延时重试退避排查403时最有效的手段是回浏览器里手动访问这个分片地址。如果浏览器里能访问脚本里不行那就是请求头的问题如果浏览器访问也403那就是URL签名或者有效期的问题不必在请求头上继续折腾。5.2 分片不连续导致花屏花屏问题最隐蔽因为m3u8解析和下载都可能成功但合成后画面局部花掉。常见原因有三个分片缺失、分片顺序错乱、分片内容被服务端换成了提示图片。排查步骤我按顺序做统计分片数量和m3u8中#EXTINF行数对比数量不符说明有遗漏。检查文件大小普通视频分片一般都有几百KB以上如果发现某个分片只有几百字节基本是被重定向到了一个错误页面。用ffprobe检查单个ts是否能正常解析先排除源文件本身损坏。ffprobe seg_0100.ts 21 | grep -E Duration|Video|Audio如果某个分片探测不出Video流就说明它不是视频数据直接删掉并用正确地址重新下载。5.3 下载到一半被断连批量下载时最怕半途中断尤其是已经下了几百个分片的时候。我做法很朴素每个分片落盘后立即写入一个已完成记录文件下次启动时先读取记录跳过已下载的分片。记录文件格式就三列序号、URL、状态。脚本被中断后重新跑一次就能无缝续传。这只是一种简单的容错设计但对大多数不算大的素材归档需求已经足够。要做得更专业可以设计任务队列把待下载分片放进持久化队列加入失败重试和延迟队列但这套方案放到有几十万分的场景时才值得上。5.4 本地合并后音画不同步音画不同步通常出现在手动cat合并的场景。TS分片中音频和视频的时间戳各自独立直接按二进制拼接播放器读取时容易把时间戳错乱放大。解决方式是统一使用ffmpeg的concat demuxer它在每个分片交接处会重新校准时间戳基线。另一个经验是如果源文件本身就是TS封装尽量不要二次转成MP4再归档TS对时间戳的宽容度比MP4高。需要最终交付MP4时才用-c copy做一次流复制封装转换不要轻易用重编码参数重编码会引入新的音画同步问题。6. 从一次性脚本到日常小工具6.1 设计一个可复用的抓取器把上面的逻辑整理成一个内部小工具我按下面这个结构组织代码hls_fetcher/ ├── config.py # 请求头模板、并发数、超时时间 ├── fetcher.py # 主流程获取m3u8、解析、下载、合并 ├── parser.py # m3u8解析模块 ├── downloader.py # 分片下载与重试逻辑 ├── merger.py # ffmpeg合并封装 ├── verifier.py # ffprobe校验封装 └── logs/ # 运行日志与已下载记录模块化之后遇到新站点只需要改config和fetcher里的地址获取逻辑解析和下载模块都是通用的。这样做最大的感受是以后遇到换一个平台或者换一种加密方式不用重新写一套脚本而是在现有框架上局部扩展。6.2 并发控制和进度感知工具里我加了一个简单的进度感知层。每个分片下载完成后回调函数更新进度计数并把中间日志输出到文件[12:01:03] 已下载 102/180耗时 36秒约剩 27秒 [12:01:05] 已下载 103/180耗时 38秒约剩 27秒进度感知不是摆设。下载几百个分片时没有进度提示会让人很焦虑有了进度就能估算出剩余时间决定是继续等还是调整并发。实现上用一个计数器加一个锁就够不需要引入复杂指标系统。6.3 日志、重试与自动化日志是整个链路里最容易被忽略但最回报的部分。我把每次下载的m3u8地址、状态码、失败分片URL全部记录到日志文件后面排查时直接翻日志不用重新跑一遍全流程。日志格式朴素一点没问题关键是完整。有些项目需要定时拉取新内容就再加一层定时任务调度每晚固定时间运行增量抓取。增量判断依据m3u8列表内容变化如果分片地址变了就重新抓取没变就跳过。自动化运行时一定要把失败通知接出来比如下载完校验时长不对就发一条告警到消息群否则一个小错误可能累积很久才发现。写这类脚本的初衷往往是理解协议和数据处理流程但我还是要强调一遍请确保你有权获取源地址遵守平台服务条款不对正常业务造成压力。技术能力归技术能力边界意识才是长期走得稳的前提。最后说一点个人体会。最开始我觉得m3u8这东西特别神秘又是分片又是密钥又是签名。自己完整走过一遍“抓包—解析—下载—合并—校验”之后发现它本质上就是“一份文本菜单 一堆HTTP请求”所谓反爬无非是给这些HTTP请求加了若干道门槛。把这些门槛一个一个识别清楚整套链路就清晰了。这个系列后续我还会接着聊DASH和动态加密这类更进阶的方向那些场景的工程复杂度会再上一个台阶但核心思路仍然是一样的先把协议读明白再谈应对策略。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑