做爬虫这么多年,我越来越觉得一个道理:真正卡住你的往往不是反爬有多强,而是你把自己锁死在“解析网页”这个维度里出不来。就拿抓douyin短视频来说,很多人一上来就钻进页面里找加密的JSON、动态加载的script、以及动不动就变的class名,折腾半天连视频真实地址的影子都没见着。其实换个思路,用Playwright把浏览器变成一个受控的代理,直接在网络层嗅探它发出的每一个请求,像视频这种资源类数据根本藏不住。这篇文章就把完整的实战链路走一遍:从环境准备、监听原理、代码实现到常见坑位,最后把合规边界说清楚。适合有Python基础、用requests写过爬虫但没搞过动态页面抓取的朋友。
1. 为什么说这是“降维打击”:不解析页面,直接看请求
1.1 传统爬虫思路的死胡同
我经常在爬虫交流群里看到有人问“抖音视频怎么抓”,下面的回答五花八门:有的说要逆向JS、有的说要模拟滑动验证、有的说要用模拟器抓包。问题是这些方案学起来成本极高,而且平台前端代码几乎每个月都在变,今天能跑的逆向脚本,下个月大概率失效。
传统思路之所以痛苦,是因为大家默认了一个前提:数据一定藏在DOM里、藏在某个接口的参数里、藏在某个加密算法之后。于是你不得不去读混淆后的JS、去补环境、去扣签名,整个过程像是在跟平台玩军备竞赛。
1.2 浏览器自己会“说实话”
但你退一步想:不管前端怎么加密、渲染方式怎么变化,用户要看到视频,浏览器就一定要向服务器请求视频流文件。这个请求必然经过网络层,必然会被浏览器内部的网络栈记录。Playwright恰好提供了一种“在浏览器内部监听网络事件”的能力。
所以“降维打击”的意义在于:我们不做逆向、不碰混淆代码,只是让浏览器按照正常用户流程走一遍,然后在旁边记录它发出的所有请求。视频地址是你主动请求服务器时,服务器明文返回给你的。你不需要破译任何协议,只需要做一个安静的观察者。
拿我自己的经历来说,第一次用Playwright打开一个视频详情页,然后看到控制台里出现m3u8地址的时候,确实有点“迎来黎明”的感觉。整个过程我没有调用任何加密函数,没有解析任何动态数据,只是看着浏览器正常播放,网络层的真实请求就暴露在response事件里了。
1.3 这个思路能做和不能做的事
需要说清楚边界。网络层嗅探能帮你拿到媒体资源地址、接口返回的JSON结构、以及页面依赖的静态资源列表。它不能帮你突破登录权限、不能拿到未加密前的内容(如果服务器在传输层就开始加密)、也不能绕过账号体系去访问私密数据。
这一点很重要——如果后台接口返回的是加密的业务数据,你在网络层看到的仍然是密文,嗅探解决不了“解不开密文”的问题。但视频流不在这个范畴里:它要被播放器解码播放,就必须是浏览器能理解的明文媒体流,所以它会以真实资源的形态出现在网络请求里。
2. 环境准备:Playwright装好只是第一步,配套环境要跟上
2.1 安装Playwright和浏览器内核
在干净环境里装Playwright非常简单:
pip install playwright playwright install chromium第一行是装Python包,第二行是下载对应版本的Chromium浏览器内核。很多新手只装了Python包就开跑,结果报错找不到浏览器可执行文件,就是因为少了第二行。Playwright不是驱动你电脑上现有的Chrome,而是管理它自己下载的那份浏览器内核,这样做的好处是版本可控、环境一致。
如果下载浏览器内核时因为网络原因失败,可以考虑使用国内镜像源,或者找一台网络更宽松的机器提前把浏览器缓存拉下来。
2.2 系统依赖和headless模式
如果你在Windows或Mac本地跑,前面两步基本就够了。但如果到了Linux服务器上,还需要安装一批系统级依赖库,否则启动Chromium时会报缺so文件。Playwright专门准备了对应的命令:
playwright install-deps这个命令会把Chromium运行所需的系统库一次性装齐。建议在服务器上装完Playwright之后立刻执行,不要等到跑挂了再补。
再说headless模式。Playwright默认可以在无头模式下运行,也就是不显示浏览器窗口。抓视频这种场景通常用无头模式就够了,但有风控的站点偶尔会通过UA、浏览器指纹等特征识别自动化脚本,这个时候把浏览器窗口显示出来排障会更直观。启动方式很简单:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://www.douyin.com", timeout=30000) print(page.title()) browser.close()在Windows上实测,这一段能正常打开抖音首页并打印页面标题。但如果你直接拿它去访问具体视频页,大概率会触发滑块或验证码。
2.3 为什么选Playwright而不是Selenium
这里非常关键,也是我强烈推荐Playwright做网络层嗅探的原因。
| 能力 | Playwright | Selenium |
|---|---|---|
| 监听网络请求 | 内置page.on("request" | "response")事件 |
| 自动等待元素 | 很完善 | 较完善 |
| 多浏览器支持 | Chromium、Firefox、WebKit | Chrome、Firefox、Edge等 |
| 移动端模拟 | 内置设备描述符 | 依赖外部配置 |
| 执行速度 | 较快 | 较慢 |
Selenium当然是老牌工具,社区成熟度高,但它监听网络请求的能力很弱。你想知道页面调用了哪些接口、返回了哪些资源,在Selenium里要么用性能日志、要么中间挂一层抓包代理,配置成本都不低。而Playwright直接暴露了浏览器内核的网络事件,你可以像写回调函数一样轻松处理每一个请求和响应,这才是做网络嗅探的正确姿势。
2.4 初始化一个“看起来正常”的浏览器上下文
直接启动的裸浏览器,UA和指纹都太“标准”,容易被平台的风控系统识别。经验做法是手动设置UA和viewport:
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36") ctx = browser.new_context( user_agent=UA, viewport={"width": 1920, "height": 1080}, locale="zh-CN" )viewport设成1920x1080是为了让页面按桌面端布局渲染,因为很多平台的移动端页面和桌面端接口调用路径完全不同。locale设成zh-CN能让部分接口返回更贴合本地习惯的内容,也更容易命中中文环境下的正常逻辑。
3. 网络层嗅探的原理:浏览器自己就是最好的抓包工具
3.1 request和response事件到底能拿到什么
Playwright给每一个Page对象都提供了两个核心事件:request和response。request事件在浏览器准备发请求时触发,response事件则发生在服务器响应到达时。
在response的回调里,你主要关注这么几个字段:
- response.url:最终响应的URL
- response.status:HTTP状态码
- response.headers:响应头字典
- response.request:对应的请求对象,里面包含请求方法、请求头、POST数据等
有一点要注意:不要直接在回调里调用response.body()去读响应体,尤其是当响应体是几十MB的视频文件时,这会把整个文件加载进内存,轻则拖慢程序,重则直接触发超时或OOM。我们做嗅探的目标通常是拿URL和元数据,不需要把响应体吞下来。
3.2 为什么视频地址一定藏不住
视频播放的标准链路是这样的:页面加载时,JS先发起XHR或fetch请求,带着一串签名参数去向服务器要视频地址;服务器返回一个JSON或重定向;然后video标签拿到真实媒体URL开始拉流。
即使前面的签名、加密做得再花哨,最后一步浏览器必须向真实媒体地址发起GET请求。这个真实地址就是网络层能看到的那个资源URL。你可以把整个过程理解成点外卖:页面菜单只是展示,真正把餐送到你手上的配送路线是另一个体系。我们的监听设备就装在配送路线的必经之路上。
3.3 如何判断哪条请求是视频流
网络层会有一大堆请求:图片、CSS、JS、接口JSON、埋点上报……要从里面挑出视频流,最直观的线索是响应头:
def is_video_response(response): ctype = response.headers.get("content-type", "") if "video/" in ctype: return True if ".mp4" in response.url or ".m3u8" in response.url: return True return False这里没有写死只判断content-type,是因为有些CDN节点返回的MIME类型是application/octet-stream,光靠类型判断会漏。再把URL里有没有.mp4或.m3u8作为补充条件,命中率更高。
3.4 需要留意重定向链
很多视频地址并不是最终文件地址,而是先返回302,让浏览器跳转到CDN临时节点。好在requests库下载时会自动跟随重定向,一般不用太担心。但如果下载下来的文件损坏,或者大小跟预期差很多,就要考虑是不是拿到了一个跳转地址而不是最终文件。
用requests下载时,可以通过resp.history查看跳转历史,也可以把allow_redirects设为False自己手动控制跳转,再结合Playwright里拿到的response.url判断哪一个才是真正可用的媒体地址。多次实测下来,Playwright的response.url在大多数情况下已经是重定向后的最终地址,所以直接把那个URL传给requests通常没问题。
4. 完整代码:监听视频请求、识别媒体流、下载到本地
4.1 先写一个监听器
把监听逻辑封装成函数,回调里只要发现疑似视频响应,就把URL保存到全局变量里:
video_url = None def handle_response(response): global video_url ctype = response.headers.get("content-type", "") if "video/" in ctype or ".mp4" in response.url or ".m3u8" in response.url: print(f"[命中] status={response.status} url={response.url}") if video_url is None: video_url = response.url这里用了一个“只保存第一个命中地址”的思路。实际页面里可能同时加载封面图、预加载贴片、视频主文件等,第一个命中的未必就是目标视频。稳妥的做法是先把所有疑似地址都收集到一个列表里,等页面跑完再人工挑。
4.2 打开页面并触发视频加载
接下来是核心流程:初始化浏览器、创建上下文、绑定监听器、打开视频详情页、等待播放触发。
from playwright.sync_api import sync_playwright video_page_url = "https://www.douyin.com/video/你的视频id" UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36") with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context( user_agent=UA, viewport={"width": 1920, "height": 1080}, locale="zh-CN" ) page = ctx.new_page() page.on("response", handle_response) page.goto(video_page_url, timeout=60000) page.wait_for_timeout(8000) browser.close() print("最终捕获地址:", video_url)这里有个非常重要的细节:事件绑定必须发生在page.goto之前。否则页面加载初期的请求全部漏掉,视频地址往往就是在最前面几个请求里出来的。我最初自己写的时候就犯过这个错,goto之后再绑监听器,等了半天一个事件都没有。
4.3 把视频下载到本地
拿到URL之后,下载这一步用requests就能完成。但一定要带上正确请求头,尤其是Referer:
import requests def download_video(url, save_path): headers = { "User-Agent": UA, "Referer": "https://www.douyin.com/", } with requests.get(url, headers=headers, stream=True, timeout=30) as resp: resp.raise_for_status() with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 1024): if chunk: f.write(chunk) print(f"已保存: {save_path}")Referer为什么不能省?因为很多存储平台和CDN会根据Referer做防盗链校验:请求头里没有合法的页面来源,服务器直接返回403。浏览器正常播放视频时会自动带Referer,我们用requests模拟时也补上,行为更接近真实用户。
4.4 完整脚本的工程化改造
上面几段代码拼在一起能跑通,但正式用还需要做几处加固。
第一,把等待策略从固定sleep改成条件等待。视频地址被赋值后,监听器一定会打印[命中],同时全局变量video_url不再为None。我们可以用一个循环轮询,最多等15秒:
import time deadline = time.time() + 15 while video_url is None and time.time() < deadline: page.wait_for_timeout(200)这样比盲等8秒可靠得多,而且在视频地址提前出现时能立刻进入下载流程,节省时间。
第二,要处理m3u8分片流的情况。如果捕获到的地址是.m3u8结尾,那它只是一个播放列表文件,真正要下载的是列表里多个.ts分片。可以额外调一次requests拿到m3u8文本,找到分片地址后逐个下载再合并。虽然抖音网页端很多视频能拿到mp4直链,但也不排除某些接口返回的是m3u8,所以代码里最好做一个分支判断。
第三,下载前确认目标文件完整性。mp4文件开头通常是ftyp标记,下载完成后可以用下面前三行做快速校验:
with open(save_path, "rb") as f: head = f.read(8) if head[4:8] != b"ftyp": print("警告:文件头不是ftyp格式,可能下载到了错误内容")实测中,这个校验曾经帮我抓出过一个问题:保存下来的文件只有几KB,打开一看是JSON格式的报错信息,说明请求地址虽然命中了视频关键字,但并不是真实视频文件。
4.5 运行效果示例
正常跑一次,控制台输出大致长这样:
[监听] GET https://www.douyin.com/aweme/v1/web/aweme/detail/ 200 [监听] GET https://www.douyin.com/aweme/v1/web/aweme/post/ 200 [命中] status=200 url=https://xxx.zjcdn.com/.../video/tos/...?a=0&biz_id=0 已保存: output.mp4第一次看到output.mp4出现在本地的瞬间,你会很清楚:这条路是对的。你不需要跟平台上那些加密字符串纠缠,只需要等浏览器自己把资源地址送出来。
5. 踩坑记录:这些问题在文档里不会写
5.1 事件绑定晚于页面跳转,等于白干
这个问题我前面提过,但值得单独再说一次。Playwright的page.on("response", ...)不是历史的、可以回放的事件管道,它只绑定之后触发的响应才能被回调收到。所以启动页面之前就要绑定好监听器。如果绑定晚了,页面加载前期发出的那些核心请求就被你错过了。视频地址往往就在首屏阶段出现,漏掉之后后续再怎么等都没有意义。
5.2 页面是SPA,不能用传统的等待方式
抖音这类平台是典型的单页应用,页面内容和视频播放请求不是HTML一返回就有的,而是JS执行到一定阶段才触发。用wait_for_timeout去猜时长,写短了漏请求,写长了拖慢效率。
更工程化的做法是先轮询等video_url被赋值,再执行后续逻辑。如果轮询超时,可以再考虑滚动页面强制触发懒加载:有的视频地址是在用户滚动到可视区域时才请求的,页面我打开时并没有自动播放第一个视频。我处理这种场景的方法是,轮询等待期间每隔一段随机滚动页面,模拟真实用户浏览,触发更多请求事件。
5.3 风控与验证码:心平气和处理,不要总想着硬碰硬
跑了几次之后,很容易遇到滑块验证。headless模式的浏览器指纹和真实Chrome有差异,这是平台识别自动化脚本的主要依据。遇到验证码,我的建议是先停下代码,手动打开浏览器看看到底是哪一步触发的。
有一类做法是修改浏览器指纹参数、注入JS混淆环境,但这类手段可能违反平台条款,我这里不展开。正常、可持续的操作方式是把访问频率降下来,抓取的视频数量控制在一定范围内,不要让服务器感觉到你在批量拉取。爬虫不是不能做,而是要对目标平台保持基本尊重。
5.4 防盗链和403
下载阶段最常见的错误是403。前面说了补Referer能解决大多数403,但还不够的话,试着把整个浏览器上下文的请求头都带上:
ctx = browser.new_context( user_agent=UA, extra_http_headers={"Referer": "https://www.douyin.com/"} )另外,有些CDN节点有IP级别的并发限制或频率限制。如果下载到一半被断开,可以在请求头里带上Range参数,用断点续传的方式在本地追加写入,业务上能节省不少时间。
5.5 下载文件损坏的排查链路
文件下载完打不开,第一个动作不是重下,而是检查文件类型。如果文件只有几千字节到几万字节,打开后里面是JSON或HTML文本,说明你下载的不是视频,而是接口返回的错误提示或验证码页面。这种响应虽然URL可能带有.mp4字样,但实际内容是需要重新请求的临时校验。
如果文件很大但播放器还是打不开,就要考虑是不是m3u8分片没合并,或者某个分片下载失败。合并ts分片时,我用ffmpeg的concat分离器比较多:
ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4list.txt里按行写分片文件路径。这个方法在本地实测稳定,速度也很快。
5.6 不同接口返回的URL参数会带上不同的水印信息
看网络层的URL参数时,你会发现有些地址带有watermark相关字段,有些没有。我是建议在处理时保持一个原则:不要手动去篡改参数,因为某些参数参与签名校验,改坏了会导致地址失效。你应该做的是观察,看到哪一类接口返回的地址本身就不带水印,就优先用那一类。
举个例子,同样一个视频,移动端分享链路生成的下载地址通常和网页端播放器使用的地址是两套接口,参数结构不一样。网页端抓到的地址往往能直接播放,而且水印信息相对更少。实际使用中,优先保留那些文件名和参数里都没有watermark字段的直链,最省事。
6. 合规边界与更多落地场景:技术是中性的,用法要克制
6.1 网络层嗅探还能用在哪些正经地方
这套思路的价值绝不只是下载视频。我后来在内部工作中频繁使用它,其中几个典型场景很实用:
- 前端性能分析:监听页面加载的每一个资源URL和耗时,找出明显拖慢首屏的巨型文件。
- 直播流监控:自动检测直播拉流地址是否切换,CDN节点是否正常返回视频流。
- 静态资源版本核查:巡检自家前端发布的资源版本号,看新版本是否真正被加载。
- 防篡改测试:确认自己的页面有没有被注入额外脚本或第三方资源。
这些场景的共同点是用网络层事件做观察和验证,而不是突破权限去获取不该获取的内容。
6.2 几条实践红线
我自己的经验是,技术能做什么是一回事,应该做什么是另一回事。在网络爬虫这个领域,尤其要注意:
- 不要批量下载他人的创作内容用于二次发布或商业用途。
- 如果视频作者本身是内容创作者,而你拿到了无水印地址,默认只能用于个人学习或获得明确授权后的剪辑备份。
- 控制请求频率,避免给目标站点造成压力。
- 尊重平台的账号体系和权限边界,不绕过登录授权获取私密内容。
- 不要把自己的采集逻辑封装成公开服务对外出售,这条线很容易踩到法律风险。
6.3 最后分享一点个人体会
我最早做这个爬虫项目,是为了帮一个做剪辑的朋友把自己账号下的作品批量归档。他需要一份不带平台水印的原始素材用于比赛投稿,但平台没有提供一键导出的功能。用Playwright嗅探网络层之后,问题被很优雅地解决了:不需要逆向、不需要碰签名算法,只是让浏览器自己把播放过的视频地址交出来。
每次用这个方案之前,我都会确认一遍素材的授权边界和使用目的。技术本身就是一把工具,你用它对的事情,它就是效率倍增器;你用它对错的事情,它就会变成风险来源。希望这篇实战记录能在你手里成为一套干净、可控、合法的自动化能力,而不是一把随便乱砍的刀。