
1. 方案对比与选型逻辑先说个扎心的事实很多朋友抓短视频平台资源第一反应就是去解析DOM在HTML里翻视频标签找src或者花大精力去硬刚JS加密的签名参数。这条路不能说完全走不通只能说贼累——你要面对的是一整套持续迭代的反爬体系今天调的接口明天可能就换参数了。我自己在实际项目里试过好几条路最后发现一个“降维打击”的思路不去应用层和对方博弈直接去网络层看结果。你想啊不管前端怎么加密、怎么混淆、怎么动态渲染视频文件最终肯定要通过网络传输到浏览器里否则用户怎么看只要我们在网络层拦截到这条传输流拿到真实的媒体文件地址就等于绕过了所有花里胡哨的前端手段。常见的几种抓取方案我实际对比下来大概是这样的方案核心思路反爬对抗难度维护成本成功率requests直接请求API逆向app或web的接口签名高需要不断分析加密逻辑高接口变动频繁中取决于接口稳定性纯DOM解析从渲染后的页面取video标签中但取到往往是blob地址中需处理分片低blob地址无法直接下载selenium模拟操作控浏览器点击下载按钮中高需处理验证码、登录中高依赖页面结构不稳定操作繁琐Playwright网络嗅探拦截网络响应提取媒体流地址极低不碰前端逻辑低页面改版也不影响高只要浏览器能看就能抓用Playwright嗅探网络层其实就是把“浏览器能看到的流量”变成“我们能拿到的数据”。这个思路特别像在一栋楼里装着窃听器——不跟你在明面上动手直接从管线里捞数据。1.1 为什么嗅探网络层能“降维打击”原理其实不复杂。视频网站再怎么防爬最终也必须把视频数据从CDN分发到用户浏览器这是一个物理上绕不开的环节。浏览器要播放视频就要发HTTP请求获取媒体流这个请求和响应就是我们的突破口。用Playwright做这件事好处在于它本身就是一个真正的浏览器而且在背后做了很好的抽象。你不需要去搞明白那些加密的JS在算什么也不需要去分析页面的什么signature、token是怎么生成的——因为这些计算结果最终都会体现在网络请求上。我们直接看请求带上了什么参数照抄就是了。我用一个特别直白的类比这就像你不想搞懂超市的进货渠道但你只要站在收银台旁边看顾客拎走了什么袋子就知道什么东西在流通。网络嗅探也是这个道理站在浏览器和服务器之间的通道上看数据包来来往往把关键的那几趟货截下来。这个方案最吸引人的地方在于它的通用性。不只是douyin很多流媒体平台、短视频App的网页版都能用同样的思路去处理只要页面是通过HTTP加载视频的这个方案就有效。我后面还会提到快手、B站等平台的网页端这套思路都能一键迁移。1.2 谁适合用这个方案什么时候不推荐如果你有下列需求这个方案会很合胃口想要批量获取某个创作者发布的视频用于数据分析、素材整理需要在自己项目里集成视频下载功能不想频繁维护接口签名逻辑面对的是有加密参数、动态token的网页版平台DOM解析找不到规律想学习网络层数据包分析对HTTP协议和浏览器工作机制感兴趣但说实话也不是所有场景都适合走网络嗅探。如果平台把视频切得极碎比如几秒一个分片还需要复杂的解密流程那么嗅探得到的媒体地址再多你也得写额外的合并和解密逻辑。这种时候不如老实去研究部署在本地执行的解密算法。还有一个大前提要讲清楚抓取和下载网络资源必须遵守平台规则和相关法规。我这篇文章讲的是技术原理和实现路径主要用于学习HTTP协议、浏览器自动化和网络数据包分析的场景。你拿去做个人学习、数据分析、备份自己有权处理的内容这是技术的正常用途但拿去批量下载侵权内容用于商业传播那完全是另一回事不在本文讨论范围内。2. 核心思路拦截哪个网络层流量网络嗅探说得轻巧但具体拦什么、怎么识别这里面的门道值得好好拆一拆。不少朋友会用Playwright自带的request事件去监听请求然后在请求里翻媒体文件的URL。这个思路对一半——因为视频流的真实地址往往不是一开始就出现在请求里的而是藏在某个重定向链路的末端或者由一段JS动态计算出来后再发起的加载请求。你要是只在第一层请求里找能找到的只是一堆HTML、JS、CSS的地址。2.1 拦截response比拦截request更靠谱我的实测经验是监听response响应比监听request请求更靠谱。为什么因为request发出去的时候除非你知道最终的URL规则否则你看到的可能是一个带各种参数的接口地址根本看不出来是不是视频。而response回来的时候响应头里带着Content-Type这是服务器自己声明的文件类型几乎不会骗人。你可以在Playwright的page.on(response)回调里检查两个关键信息response.headers()[content-type]是否为video/mp4或application/octet-stream响应URL里是否包含媒体特征的关键词比如video、douyinvod、iesdouyin等我见过很多新手在这里踩坑他们监听request然后拿request.url去过滤结果发现视频确实加载了但URL是blob:https://xxx-xxx这种格式这个blob地址只在浏览器内部有效直接拿到外面根本下载不了。这正是我推荐监听response的核心理由——response里能看到的地址是浏览器真正去CDN拉流的地址这个地址以及返回的数据本身才是可以解的。至于blob的问题别急后面会专门讲怎么处理。2.2 识破“障眼法”的URL特征短视频平台很喜欢做“障眼法”把真实媒体隐藏在看起来很像接口地址的路径后面。我在实践中总结了一套比较实用的过滤规则按优先级排序Content-Type以video开头比如video/mp4、video/webm——这是最明确的信号响应头里Content-Length大于某个阈值比如超过1MB——视频文件不可能太小这能过滤掉绝大部分图片和JSON数据URL路径中包含媒体服务特征比如/video/tos/、v3/pgc、.mp4后缀——这些是CDN路径的典型特征直接上代码这一段是整个方案的核心判断逻辑import re def is_media_response(response) - bool: url response.url content_type response.headers.get(content-type, ) content_length int(response.headers.get(content-length, 0)) # 规则1content-type 明确是视频 if content_type.startswith(video/): return True # 规则2文件体积明显是媒体级别 if content_length 1024 * 1024: return True # 规则3URL 特征匹配 media_patterns [ r/video/tos/, r\.mp4(\?|$), rv3/pgc, ] for pattern in media_patterns: if re.search(pattern, url): return True return False这套规则看起来简单但实际效果特别好因为平台无论如何优化前端后端流媒体服务的路径特征很难频繁变化——换一套CDN架构的成本远远高于维护一套前端加密逻辑。2.3 blob:地址的真实身份被blob:地址搞晕的朋友不在少数。先说结论blob地址是浏览器内部的对象引用不是真实的网络地址。它在浏览器里能播但拿到curl或requests里完全没法用。出现blob地址的原理是这样的前端JS拉取到视频原始数据后往往是一个分片流通过MediaSource API把数据喂给播放器这个过程中会创建一个blob对象来充当数据容器。播放器播放的是这个容器不直接关心数据从哪来。那我们怎么处理等其“现出原形”。既然视频数据要经过MediaSource那必然有真正的HTTP请求去拉分片数据。你不需要去管blob本身只需要在你的监听器里过滤掉blob请求继续盯着真实的媒体请求。只要你在网络层看到返回大量video/mp4的响应说明真实的视频数据已经到场了。一句话总结看到blob别慌继续往下看流量真身在后面。3. 实操过程与核心实现有了前面的思路接下来就是完整的落地方案。我会从环境准备开始把每一步的关键配置都讲透确保你拿到代码后能直接跑起来。3.1 环境准备与浏览器依赖首先确认你的Python环境是3.8以上版本然后用pip安装核心依赖pip install playwright注意装完playwright还要装浏览器内核playwright install chromium这里有个提速的小技巧如果服务器在国内或者网络不稳定playwright默认从CDN下载chromium可能会卡住甚至失败。可以直接手动下载chromium的zip包放到~/Library/Caches/ms-playwright/目录下解压再运行playwright install chromium让它识别本地缓存即可。如果你用的是Windows对应的缓存目录在%USERPROFILE%\AppData\Local\ms-playwright下面。我发现很多人把精力花在环境上反而忽略了最重要的一步打开浏览器的调试视角。建议你在项目早期把headlessFalse设置上让它以有头模式跑一遍亲眼看看浏览器到底加载了什么、请求了什么、页面长什么样。等你完全摸清规律了再切回无头模式放到服务器上跑。3.2 核心代码五分钟跑通网络层嗅探下面这份代码是我在实际项目中精简出来的按标注好的步骤直接跑就行import asyncio import re from urllib.parse import urlparse import playwright.async_api as pwa MEDIA_PATTERNS [ r/video/tos/, r\.mp4, rv3/pgc, riesdouyin, ] async def try_parse_video_url(url: str) - str: # 这个函数用来清洗 URL去掉无用的追踪参数提取真正的 CDN 地址 parsed urlparse(url) path parsed.path query parsed.query # 保留关键参数过滤统计类参数 filtered_query .join( p for p in query.split() if p.startswith((mime_type, hd, rate, visit_type)) ) return fhttps://{parsed.netloc}{path}?{filtered_query} if path else url async def main(): video_urls [] video_urls_lock asyncio.Lock() async with pwa.async_playwright() as pw: browser await pw.chromium.launch( headlessFalse, args[--disable-blink-featuresAutomationControlled], ) context await browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, viewport{width: 1280, height: 800}, ) page await context.new_page() async def on_response(response: pwa.Response): headers response.headers content_type headers.get(content-type, ) is_video_type content_type.startswith(video/) is_large_payload int(headers.get(content-length, 0)) 1024 * 1024 is_media_path any(re.search(p, response.url) for p in MEDIA_PATTERNS) if is_video_type or is_large_payload or is_media_path: clean_url await try_parse_video_url(response.url) async with video_urls_lock: if clean_url not in video_urls: video_urls.append(clean_url) print(f[抓到媒体流] {clean_url}) page.on(response, on_response) # 打开目标视频页面 share_url https://www.douyin.com/video/7xxxxxxxx00 # 替换成你要抓取的视频链接 await page.goto(share_url, wait_untilnetworkidle, timeout60_000) # 滚动一下页面触发更多懒加载 await page.mouse.wheel(0, 500) await asyncio.sleep(3) await browser.close() asyncio.run(main())注意代码里有个await page.goto(..., wait_untilnetworkidle)这个参数在某些页面内容特别多时容易超时。如果遇到超时可以换成wait_untildomcontentloaded然后后面补一个固定的等待时间比如await asyncio.sleep(5)效果接近。我在实际跑的过程中发现一个有意思的现象有时候一次抓到的不是完整的MP4地址而是一堆分片地址每个几MB后缀里带part或者序号。别慌这说明该用MediaSource分片播放机制后面单独讲怎么合并。3.3 处理重定向链别漏掉藏在后面的真实地址短视频平台的分享链接往往不是直接指向视频页中间有短链跳转。你用分享链接进去或者直接在页面里跳转都会经历好几次302。很多朋友在第一步的请求地址上浪费了太多时间。我的经验是不需要主动跟进重定向直接看最终的response地址。Playwright的page.on(response)事件本身就对重定向做了透明处理——你拿到的response是一个最终的、非重定向的响应对象它的url就是目标地址。这个特性是真的省心。不过要注意某些平台会先返回一个HTML页面页面里的JS再发起视频请求这种场景下你监听response同样能捕捉到因为playwright的事件是整个页面所有网络请求的汇集点不受前端渲染逻辑影响。另外如果你发现监听response时某些请求会重复触发比如视频播放器自己循环加载分片去重逻辑是必须的。我上面代码里已经用video_urls_lock做了简单的去重避免重复输出地址。3.4 将脚本改造为批量抓取单个视频能抓到地址之后批量就是水到渠成的事。我常用的做法是循环处理多个分享链接每个链接独立生成一个任务share_links [ https://www.douyin.com/video/xxxx1, https://www.douyin.com/video/xxxx2, ] async def worker(browser, url, results): context await browser.new_context() page await context.new_page() page.on(response, lambda resp: collect_media(resp, results)) try: await page.goto(url, wait_untildomcontentloaded) await page.wait_for_timeout(4000) finally: await context.close()这里要注意控制并发数量同时开的页面太多容易被限制或导致机器过载。我习惯用信号量控制并发不超过3个。每抓完一个就关闭上下文确保内存及时释放跑几百个视频稳得很。4. 常见问题与排查技巧实录这一节的每一个问题都是我实际踩过坑之后整理出来的。先看速查表再展开讲排查思路。现象很可能的原因解决办法抓到0个媒体流页面需要登录或验证码先手动登录一次保存storage_state再用抓到一堆blob:地址平台走MediaSource分片不要用blob继续监听后续分片请求抓到多个webm分片无法合并分片播放机制没有完整mp4用ffmpeg合并详见4.3页面加载超时networkidle等待不到活动结束改用domcontentloaded加固定等待抓到的地址无法下载地址带时效签名如XOR、token必须在下落时间窗口内请求随抓随下4.1 无头模式抓不到数据有头模式就行这是最多人问的问题也是最容易忽略的一个点。很多网站会对无头浏览器做特征检测导致无头模式下页面渲染行为跟有头模式截然不同——最典型的就是根本不去加载视频或者加载逻辑直接走另外一套分支。我自己测下来的几个有效对策把headlessFalse先跑通逻辑确认能抓到如果必须无头添加args[--disable-blink-featuresAutomationControlled]去掉webdriver特征使用真实的user-agent字符串不用Playwright默认的HeadlessChrome标识必要时启动浏览器后先检测页面上的webdriver标记有则JS注入修改其实随着Playwright维护力度加大无头模式在被检测这一点上已经改善很多了但为了稳妥我还是建议核心调试期始终用有头模式。4.2 抓到的视频地址有签名参数马上过期怎么办短视频平台的CDN地址经常带着签名参数这些参数有时效性少则几分钟、多则半小时。你隔了一晚上再拿这个地址去下载大概率已经失效返回403或者HTTP 200但内容是错误JSON。排查思路很清晰抓到地址后立刻下载不要攒批量。我在脚本里会这样处理收集到URL的同时起一个异步下载任务用HTTP头里的Content-Length校验完整性下载完成后校验本地文件大小如果远小于Content-Length说明被截断或页面变了你甚至可以不等页面关闭就下载边抓边下。尤其是那种一次能抓到很多分片的场景页面上所有分片地址被收集齐之前第一批分片已经在本地躺着等合并了。4.3 处理分片播放把webm分片合并成完整视频用PC端网页播放器看的视频很多不是传统的单文件MP4而是通过MediaSource连续加载多个video/webm的分片每个分片可能只有几秒到几十秒。这种视频下载下来是一堆碎片直接播放是不行的。遇到分片不要慌ffmpeg一条命令就能合并前提是你得先把所有分片文件下载到本地并且按顺序给它命名ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4其中list.txt内容是file part_001.webm file part_002.webm注意-c copy是直接复制流不重新编码速度极快但要求所有分片编码参数一致。如果拼接报错去掉-c copy让它重新编码即可代价是慢一些。我发现还有一个常见情况有些平台返回的分片地址不带序号而你拿到的顺序跟播放顺序不一定一致。这种情况下你需要在监听response时把每个分片的URL和它对应的请求时间顺序绑定再用响应头的Range参数辅助判断它在整体文件中的位置。写起来会稍微复杂一点但原理还是上面那套。4.4 页面里视频由iframe内层发起请求拦截不到有些平台把播放器做成嵌套的iframe页面本身和播放器不在同一个文档流里默认的page.on(response)可能漏接过一些来自iframe内部的请求。之前在Playwright旧版本里处理跨iframe的请求确实很麻烦。好在现在新版本对frame的事件支持已经比较完善了你可以直接监听context.on(response)这个是context级别的事件会把主页面和所有iframe里的请求都收进来context await browser.new_context() context.on(response, on_response) # 监听整个context而非单个page替换成context监听后iframe内部发起的媒体请求也能拦截到。还有一个笨办法是直接遍历所有frame给每个frame注册同样的监听函数但既然context方案就能解决没必要给自己添堵。4.5 登录墙和验证码先过一道门再进网络层部分视频页面会要求登录才能播放尤其是那种需要会员权限的内容。网络嗅探再厉害也拿不到你没权限看到的东西。我的建议是不要试图在代码里硬破解登录逻辑。更务实的做法是本地先用Playwright打开浏览器并手动登录一次然后将登录状态保存下来之后每次运行都加载这个状态。# 保存登录状态只需要跑一次 await context.storage_state(pathdouyin_state.json) # 后续每次启动 context await browser.new_context(storage_statedouyin_state.json)这种做法在生产环境很常见既省去了繁琐的验证码交互处理稳定性也高。你要维护的只是后台定期刷新一次cookie别让它整体失效。有一个细节容易被忽略保存出来的storage_state是明文JSON里面包含登录凭证和会话信息等同于一把钥匙。千万不要把它提交到公开的Git仓库里务必加入.gitignore。5. 进阶优化让抓取更稳更快基础版本能跑通之后下面这几个方向的优化会明显提升稳定性和实用性。5.1 用CDP原生监听Media事件Playwright本身是基于Chrome DevTools Protocol的理论上你可以直接通过CDP会话去订阅目标页面上的所有事件包括媒体相关的。相比在Python层监听responseCDP拿到的信息更底、更全而且不受页面iframe结构的限制。做法是这样的cdp await context.new_cdp_session(page) await cdp.send(Network.enable) def handle_loading_finished(params): url params.get(requestId, ) # 这里拿到的url可能是请求的最终地址 cdp.on(Network.loadingFinished, handle_loading_finished)不过说实话对普通需求来说在context层监听response已经够用。只有当你要抓的数据量特别大、或者遇到极端反爬时才值得去接触CDP。这个方向了解一下需要时再深入不迟。5.2 同时抓取视频页面下方的推荐流短视频平台的页面往下滑动会加载下一个视频而每个视频加载的过程都会触发新的媒体网络请求。如果你不只是想抓当前打开的视频还想把推荐流里的视频一并抓下来可以模拟滑动操作for _ in range(5): await page.mouse.wheel(0, 1200) await asyncio.sleep(2)每滑动一次页面就会触发新的网络请求你的监听器会自动把这些新视频的地址记录下来。这个方案还有个额外的好处你采集到的视频都是带播放入场顺序的后续分析用户画像、推荐算法都具有更高的数据价值。滑动速度不要调太快太快容易触发风控而且页面来不及真正加载视频流。我实测每2秒滑一次、步长1000像素左右是兼顾效率和稳定性的合理区间。5.3 视频下载与断点续传最后把下载环节也完善一下。理想状态是这样捕获到URL后立即交给下载器下载器支持断点续传避免网络抖动导致前功尽弃。import requests def download_video(url, filename): headers {User-Agent: Mozilla/5.0} with requests.get(url, streamTrue, headersheaders) as r: r.raise_for_status() total int(r.headers.get(Content-Length, 0)) downloaded 0 with open(filename, wb) as f: for chunk in r.iter_content(chunk_size8192): f.write(chunk) downloaded len(chunk) return total, downloaded需要注意很多CDN的URL是做了链接签名的不支持Range断点续传——你只要中途断开重新请求时服务器可能直接返回新的过期时间或校验失败。这种情况下你只能一次性下完或者保留原始页面不关闭用浏览器的缓存机制去拿剩余数据分片后者实现成本就上去了。能支持下就支持不支持也别强求完整下载一次不过几秒钟的事。