1. 从"能播就能下"说起:视频资源抓取的真实边界
很多人第一次动了"把某看视频网的视频存到本地"的念头,往往是因为遇到了很具体的场景:通勤路上信号不稳,想提前缓存却发现App只允许在会员有效期内观看;或者某个教学视频过段时间就下架了,想留个备份;又或者单纯想研究一下这类站点的播放链路是怎么设计的。不管动机是什么,动手之前有一件事必须先想清楚——你能抓到的,永远只是浏览器能拿到的东西。这句话听起来像废话,但它决定了整个项目的技术路线和天花板。
所谓"某看视频网",通常指的是那些以短剧、影视剪辑、用户上传内容为主的在线播放平台。这类站点的共同特征是:页面本身不直接暴露视频文件地址,而是通过一层播放器逻辑,把真实媒体流藏在接口请求或者动态拼接的URL后面。你右键"另存为"能拿到的往往只是一个几十KB的播放器壳子,真正的视频数据在另一个地方。所以这个项目的核心,不是"下载",而是还原播放器获取媒体资源的完整链路。
从技术定位上讲,这属于典型的网络资源采集与媒体流解析范畴,涉及HTTP协议分析、浏览器开发者工具的使用、请求参数逆向、以及媒体分片(如HLS的m3u8、DASH的mpd)的合并处理。它适合有一定Python基础、了解requests库、并且愿意花时间读网络请求的人。完全零基础也能跟着做,但需要你对"请求—响应"这个模型有基本概念,否则后面看到一堆加密参数会直接懵掉。
我先把结论摆在前面:绝大多数这类站点的视频,走的是HLS分片方案,也就是把一个完整视频切成无数个几秒的小片段(.ts文件),再用一个索引文件(.m3u8)告诉播放器按顺序去取。你要做的,就是找到这个索引文件,然后把所有分片拉下来拼成一个完整文件。听起来简单,但中间会卡住很多人的地方在于——索引文件的地址是动态生成的,而且往往带着时效性签名。这就是为什么"复制一个链接过十分钟就失效了"。
提示:本文讨论的所有操作,仅用于个人学习网络协议与媒体流原理,请勿用于任何侵犯版权或违反平台服务条款的用途。抓取前请确认目标内容的授权状态,尊重内容创作者的劳动成果。
2. 抓包之前先想明白:播放器到底在跟服务器聊什么
2.1 一次完整的播放请求会经历哪几个阶段
要抓视频,先得理解播放器的工作流程。你可以把它想象成去餐厅吃饭:你(播放器)先看菜单(页面HTML),然后点菜(发起播放请求),服务员(接口服务器)告诉你后厨在哪个窗口出餐(返回真实媒体地址),最后你去窗口把一道道菜端回来(下载分片)。这个过程中,菜单、点菜、出餐窗口是三个完全不同的环节,对应到技术上就是:
- 页面加载阶段:浏览器请求HTML,拿到播放器容器的结构,里面通常有一个
<video>标签或者一个JS初始化的播放器实例。 - 鉴权与取址阶段:播放器向某个API接口发送请求,带上视频ID、用户凭证、时间戳等参数,服务器返回一个包含真实播放地址的JSON。
- 媒体流拉取阶段:播放器拿到地址后,如果是HLS,会先请求m3u8索引,再根据索引里的分片列表逐个下载ts文件。
很多人一上来就打开开发者工具,在Network面板里翻半天找不到视频,就是因为只盯着第1阶段看。真正的地址在第2阶段的响应体里,而第2阶段的请求往往藏在XHR/Fetch过滤器下面。
2.2 为什么直接搜".mp4"经常一无所获
新手最常犯的错误,是在Network面板的搜索框里输入.mp4,然后发现啥也没有,就以为这个站"防得很严"。其实不是防得严,是它压根不用mp4这种整文件格式。HLS方案下,你看到的是成百上千个.ts请求,每个只有几百KB。搜索.m3u8或者.ts才是正确的起手式。
还有一种情况是,站点用了blob协议。你在<video>的src里看到的是blob:https://xxx/xxxx这样的地址,这代表视频是通过MediaSource Extensions(MSE)动态喂给播放器的,真实数据来自JS内存。这种情况下,Network面板里依然能看到分片请求,只是src被隐藏了。别被blob吓到,它只是播放器的一种"喂数据"方式,不影响你从网络层抓包。
2.3 开发者工具里必须打开的几扇窗
工欲善其事,先把工具配好。打开浏览器的开发者工具(F12),重点用这几个面板:
- Network(网络):核心战场。记得勾选
Preserve log(保留日志),否则页面一跳转,之前的请求全没了。过滤器选Fetch/XHR能快速定位接口请求,选Media能直接看到媒体流请求。 - Sources(源代码):用来给JS打断点,追踪加密参数的生成逻辑。
- Application(应用):查看Cookie、LocalStorage,很多鉴权信息藏在这里。
我个人的习惯是,先清空Network,然后点击播放按钮,观察点击瞬间新增了哪些请求。那个返回JSON、里面带着一长串URL的请求,十有八九就是取址接口。点开它的Response,如果看到类似"url":"https://xxx/xxx.m3u8?sign=..."的结构,恭喜你,找到入口了。
3. 定位m3u8索引文件:从一堆请求里捞出那根线头
3.1 用请求特征快速筛选目标接口
取址接口通常有几个明显特征:请求方法是GET或POST,响应类型是JSON,URL里往往带着play、video、getUrl、source这类关键词。但不同站点命名千奇百怪,靠关键词不一定准。更可靠的办法是看响应体大小和结构——取址接口的响应通常不大(几KB),但里面一定有一个或多个以.m3u8或.mp4结尾的字符串。
如果你实在找不到,可以用一个笨但有效的办法:在Network里按大小排序,把那些响应体里包含m3u8字样的请求逐个点开看。或者直接在全局搜索(Ctrl+Shift+F)里搜m3u8,能直接定位到包含该字符串的响应。
3.2 索引文件里的签名参数是怎么回事
找到m3u8地址后,你会发现它通常长这样:
https://cdn.example.com/video/abc123/index.m3u8?auth_key=1699999999-0-0-abcdef1234567890那个auth_key就是签名,一般由过期时间戳+随机串+密钥的哈希组成。它的作用是防止地址被长期盗用。这意味着你复制出来的这个地址,过一段时间(可能是几分钟到几小时)就会失效。所以抓包要趁热,拿到地址后尽快下载,别磨蹭。
有些站点的签名更复杂,还会绑定IP或User-Agent。这种情况下,你用Python的requests去请求时,必须把浏览器里的请求头原样带上,尤其是User-Agent、Referer、Origin这几个。少一个都可能返回403。
3.3 二级索引与多码率选择
打开m3u8文件,你可能会看到两种结构。一种是直接列出.ts分片,这叫媒体播放列表;另一种是列出多个不同清晰度的子m3u8地址,这叫主播放列表。后者长这样:
#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=1280x720 720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=640000,RESOLUTION=854x480 480p/index.m3u8这时候你要先选一个清晰度,再请求对应的子索引,才能拿到真正的分片列表。想省流量就选低的,想收藏就选高的。我一般会写个脚本自动选最高码率,省得手动挑。
注意:有些站点的m3u8内容是加密的,分片本身也是AES-128加密的。索引文件里会有
#EXT-X-KEY:METHOD=AES-128,URI="..."这一行,指向密钥地址。这种情况下,你必须先下载密钥,再对每个分片解密,否则拼出来的文件无法播放。这是进阶内容,后面单独讲。
4. 分片下载与合并:把几百个小文件拼成完整视频
4.1 为什么不能直接顺序下载就完事
理论上,你解析出所有ts地址后,用requests逐个下载,再按顺序拼接,就能得到完整视频。但实际操作中会遇到几个坑:
- 并发问题:一个视频可能有几百甚至上千个分片,顺序下载慢得让人抓狂。用多线程或异步能大幅提速,但并发太高又容易被服务器限流甚至封IP。
- 顺序问题:分片必须严格按m3u8里的顺序拼接,错一个位置,视频就会花屏或音画不同步。
- 失败重试:网络抖动导致某个分片下载失败是常态,必须有重试机制,否则前功尽弃。
我的经验是,并发数控制在8到16之间比较稳妥,既能提速又不容易触发风控。每个分片下载后先存到临时目录,全部下完再统一合并,这样即使中途失败,也能断点续传。
4.2 一个可复用的下载脚本骨架
下面这段代码是我常用的骨架,基于requests和concurrent.futures,逻辑清晰,改改就能用:
import os import re import requests from concurrent.futures import ThreadPoolExecutor, as_completed HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/", } def fetch_m3u8(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.text def parse_ts_list(m3u8_text, base_url): lines = m3u8_text.strip().splitlines() ts_list = [] for line in lines: line = line.strip() if line and not line.startswith("#"): if line.startswith("http"): ts_list.append(line) else: ts_list.append(base_url.rstrip("/") + "/" + line) return ts_list def download_ts(args): idx, url, save_dir = args path = os.path.join(save_dir, f"{idx:05d}.ts") if os.path.exists(path): return path for attempt in range(3): try: r = requests.get(url, headers=HEADERS, timeout=15) if r.status_code == 200: with open(path, "wb") as f: f.write(r.content) return path except Exception: continue raise RuntimeError(f"分片下载失败: {url}") def merge_ts(save_dir, output): files = sorted(os.listdir(save_dir)) with open(output, "wb") as out: for name in files: if name.endswith(".ts"): with open(os.path.join(save_dir, name), "rb") as f: out.write(f.read())这段代码的关键点在于:分片文件名用零填充的序号(00001.ts),这样sorted()出来的顺序就是正确的播放顺序。如果你用原始URL里的随机文件名,排序就乱了。
4.3 合并后的文件为什么有时播不了
拼出来的ts文件,本质上是MPEG-TS流,很多播放器(比如某些系统自带的)不认这个格式。解决办法有两个:一是用ffmpeg转封装成mp4,命令很简单:
ffmpeg -i merged.ts -c copy output.mp4-c copy表示不重新编码,只换容器,速度极快,几秒就能完成。二是直接用支持ts的播放器,比如VLC,拖进去就能播。
如果转出来发现音画不同步或者时长不对,多半是分片顺序错了,或者漏下了某个分片。回头检查m3u8里的分片数量和实际下载数量是否一致。
5. 加密分片的处理:AES-128解密并不神秘
5.1 识别加密标记与密钥地址
当m3u8里出现这一行时,说明分片被加密了:
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin",IV=0x1234...URI指向密钥文件(通常是16字节),IV是初始化向量。如果没有显式给出IV,默认用分片序号作为IV。解密逻辑是标准的AES-128-CBC,Python里用pycryptodome库几行就能搞定:
from Crypto.Cipher import AES from Crypto.Util.Padding import unpad def decrypt_ts(data, key, iv): cipher = AES.new(key, AES.MODE_CBC, iv) return unpad(cipher.decrypt(data), AES.block_size)5.2 IV的两种来源与常见错误
IV的处理是解密环节最容易出错的地方。有两种情况:
- 显式IV:m3u8里直接写了
IV=0x...,直接用。 - 隐式IV:没写IV,此时用分片的序号(从0开始)作为IV,需要转成16字节的大端序。
我踩过的坑是:有些站点的分片序号不是从0开始,而是从1开始,或者中间有跳号。这时候如果无脑用循环下标当IV,解密出来就是乱码。正确的做法是从m3u8里解析每个分片对应的真实序号,或者干脆用分片在列表中的位置索引,但要和站点实际使用的规则对齐。遇到解密失败,先打印几个分片的IV和密钥,对比一下就能发现问题。
5.3 密钥请求也要带对请求头
密钥文件的请求同样需要带上Referer和User-Agent,否则服务器可能返回403或者一个假的密钥。下载密钥后,建议打印一下长度,正常是16字节。如果长度不对,说明请求被拦截了,回去检查请求头。
6. 那些让人抓狂的意外:实战中常见的坑与应对
6.1 请求返回200但内容是空的
这种情况通常不是服务器的问题,而是你的请求缺少关键头。很多站点会校验Referer,如果Referer不是它自己的域名,就返回一个空响应或者一段错误提示,但HTTP状态码依然是200。解决办法就是把浏览器里那个请求的完整头信息复制过来,一个都别漏。我习惯用curl命令直接从开发者工具里"Copy as cURL",然后转成Python代码,这样最保险。
6.2 下载到一半突然全部403
这是典型的触发风控。原因可能是并发太高、请求频率太快、或者IP被临时限制。应对策略:
- 降低并发数,加随机延时(比如每个请求间隔0.1到0.3秒)。
- 如果站点对IP敏感,可以放慢速度,分批次下载。
- 检查是不是签名过期了,重新抓一次地址。
提示:不要用多线程疯狂轰炸服务器,这既不道德也容易把自己搞进黑名单。温和一点,细水长流。
6.3 分片数量对不上或时长异常
有时候m3u8里列了500个分片,但你只下到480个,拼出来视频少了结尾。原因可能是某些分片请求失败但被静默跳过了。我的做法是下载完成后校验文件数量,如果对不上,就重新跑一遍,脚本会自动跳过已存在的文件,只补缺失的。另外,m3u8里如果有#EXT-X-ENDLIST标记,说明是点播流,分片列表是完整的;如果没有,可能是直播流,分片会动态增加,那就得另做处理了。
6.4 关于"某看视频网"这类站点的特殊性
这类以短剧、剪辑为主的站点,往往有几个特点:视频短(几分钟到十几分钟)、分片少(几十个)、但更新频繁、接口变动快。这意味着你今天写的脚本,可能下周就失效了。所以不要把脚本写死,把取址接口的URL、参数名、请求头都做成可配置的,方便快速调整。另外,这类站点的鉴权往往比较松,但版权风险高,再次提醒,仅用于学习研究,别拿去传播。
7. 从能跑到好用:几个提升效率的工程化习惯
7.1 把抓包结果结构化保存
每次手动去开发者工具里翻请求,效率太低。我的习惯是,一旦定位到取址接口,就用Python模拟这个请求,把返回的JSON存下来,从中提取m3u8地址。这样整个流程就能串起来:输入视频页面URL,自动请求接口,拿到m3u8,下载分片,合并输出。中间任何一步出问题,都能单独调试。
7.2 日志比print好用得多
调试这种多步骤流程,print很快就会让终端刷屏。用logging模块,把关键信息(请求URL、响应状态、分片进度、错误原因)分级记录下来,出问题时翻日志一目了然。尤其是分片下载阶段,记录每个失败分片的URL,方便重试。
7.3 断点续传是刚需
一个视频下到90%断网了,如果重头再来,心态会崩。所以脚本必须支持断点续传:分片文件按序号命名,下载前先检查文件是否存在且大小合理,存在就跳过。合并前再校验一遍数量。这个习惯能省下大量重复劳动。
7.4 关于工具选型的一点个人看法
有人喜欢用现成的下载工具,比如某些浏览器插件或者命令行工具。这些工具确实省事,但遇到稍微特殊一点的站点就歇菜了,而且你学不到底层原理。我的建议是,先用现成工具快速验证可行性,再自己写脚本深入理解。自己写一遍,以后遇到任何变种都能从容应对。至于具体的库,requests+concurrent.futures+pycryptodome+ffmpeg这套组合,覆盖了95%以上的场景,没必要追求花哨的框架。
最后分享一个我自己的小习惯:每次成功抓取一个视频后,我会把这次用到的请求头、接口URL格式、m3u8结构特征简单记一笔。积累下来,就形成了一套自己的"站点特征库",下次遇到同类站点,几分钟就能判断出该用什么策略。这个习惯比任何教程都管用。