Selenium捕获网络请求:Selenium Wire与CDP实战
2026/9/13 10:04:30 网站建设 项目流程

你有没有遇到过这种情况:Selenium脚本里所有元素都定位到了,点击也执行了,但页面就是没有反应;或者接口偶发报错,你在浏览器F12里看得一清二楚,脚本却像“瞎子”一样,什么都拿不到。Selenium本身暴露的是页面操作层,它不会主动告诉你浏览器发送了哪些请求、请求体带上了什么参数、响应到底返回了什么内容。可实际排查问题时,很多答案恰恰就藏在这些网络请求里。

“selenium 捕获网络请求”这个话题,说白了就是给自动化脚本装上一双“F12”的眼睛。做自动化测试、爬虫采集、前端性能分析的人,只要遇到接口报错、页面异步加载失败、下载文件无法断言、上传文件异常这类问题,都会需要这个能力。这篇我把我实际用过的方案、踩过的坑、可以直接抄的代码都整理出来,希望能帮你少走弯路。

1. Selenium默认看不到请求,但这几个场景又非常需要

1.1 纯页面操作看不出的问题

Selenium的设计哲学是模拟用户操作:打开页面、点击、输入、断言元素。它把浏览器当成一个黑盒,DOM暴露给你,但浏览器在背后发出的HTTP请求、收到的响应、状态码、耗时这些网络层信息,默认不在它的能力范围内。

这就导致了一个很尴尬的局面:页面元素明明出现了,但页面里的表格是空的;点击提交后提示“上传失败: 网络请求错误”,但你不知道是参数格式不对、接口返回了500,还是文件太大直接被网关拦了。你再怎么用WebDriverWait等元素,等到超时也等不出答案——因为元素可能永远都不会出现,或者错误提示本来就是动态拼接的。

我之前维护过一套商城前端的自动化用例,有个用例是“注册后自动登录”。元素倒是都点到了,但十次里有三次登录失败,测试报告里只有“元素未找到”。后来把网络请求抓出来一看,原来是注册接口的userId字段偶尔会返回空字符串,前端拿空ID去请求登录接口,直接被后端拒了。这种问题,不抓请求根本没法定位。

1.2 捕获请求能解锁的场景

把网络请求捕获下来,等于在Selenium和浏览器之间加了一个“中间人”,你可以拿到这么几类信息:

  • 所有请求的URL、Method、请求头、请求体;
  • 所有响应体的状态码、响应头、响应体;
  • 请求的时间序、耗时,甚至可以按接口名做性能分析;
  • 动态修改请求或响应,实现接口mock,比如把某个接口的响应直接替换成指定JSON;
  • 等待“某个接口真正返回成功”后再继续下一步,而不是傻等页面元素。

这套能力在自动化测试里特别有用。比如你想验证文件下载成功,不能只等downloaded.pdf出现,还得确认后端返回了200;想排查上传为什么会失败,直接把上传接口的响应体打印出来,错误码一目了然;想在测试环境模拟后端异常,可以拦截响应,强行返回500,看前端有没有做容错。这些需求用Selenium自带的API是做不到的,必须补上一层“网络日志”。

2. 四条主流方案怎么选?我踩出来的对比结论

2.1 四种方案一句话概括

当前社区里比较主流的实现路径有四种,我逐个说下它们到底是怎么回事。

  • BrowserMob Proxy:一个独立的Java代理程序,Selenium把浏览器代理设置为它,它负责转发并记录所有HTTP/HTTPS请求。这个方案很成熟,但对Python使用者来说需要额外启动Java进程,配置偏重。
  • Selenium Wire:一个Python库,扩展了Selenium的webdriver,内部自动起了一个代理,所有请求都会经过它,直接通过driver.requests就能拿到请求和响应。这是我用得最多的方式,简单直接。
  • Chrome DevTools Protocol(CDP)的Network事件:通过CDP协议开启Network域,浏览器会把网络事件推送给脚本。Selenium 4支持execute_cdp_cmd执行CDP命令,但完整的事件监听需要自己解析日志,门槛稍高。
  • Performance Log:在Chrome options里开启performance日志,然后通过driver.get_log("performance")获取。这个本质是CDP日志的导出,优点是代码量少,缺点是不能改响应,且日志格式解析略微麻烦。

2.2 适用场景和性能开销对比表

方案是否需要代理可否修改响应对环境的要求性能开销适合场景
BrowserMob Proxy可以需要Java环境中高Java技术栈、老项目
Selenium Wire可以pip安装即可Python居多、需要快速拿到请求体/响应体
CDP Network事件需要额外做拦截实现依赖浏览器版本轻量监控、实时性要求高
Performance Log不能Chrome/Edge事后分析、性能统计

这里重点说下“能否修改响应”的区别。BrowserMob Proxy和Selenium Wire都是在代理层做文章,所以可以拦截并伪造响应;CDP虽然也提供了Fetch.enable来做请求拦截,但需要一套过滤逻辑,比Selenium Wire的response_intercept复杂不少。Performance Log则纯粹是“只读”的,只能看,不能改。

2.3 我的选型建议

如果是在Python项目里做自动化测试,我的建议是优先用Selenium Wire。理由很现实:它把代理、证书、会话管理都封装好了,你只需要pip install selenium-wire,然后from seleniumwire import webdriver,其他用法和普通Selenium几乎一样,上手成本最低。它唯一让我头疼的地方是版本偶尔跟不上最新浏览器,但只要固定浏览器大版本,基本没问题。

如果你想做的是一个轻量级的“接口耗时巡检”,不希望引入代理产生额外网络转发开销,那就用CDP或者Performance Log。CDP方式更接近浏览器底层,性能最高,也最适合嵌入到已有的Selenium 4脚本里,不过要接受它的事件回调和日志解析这层学习成本。

3. 实操:用Selenium Wire拿到请求与响应全文

3.1 安装与初始化:代理注入方式

Selenium Wire的安装和Selenium一样简单:

pip install selenium-wire

需要注意,导入时不是from selenium import webdriver,而是from seleniumwire import webdriver。它会自动启动一个本地代理,并把浏览器的流量指向这个代理。初始化Chrome的方式如下:

from seleniumwire import webdriver options = webdriver.ChromeOptions() options.add_argument("--headless=new") # 如果访问的是自签名证书的测试环境,建议打开,避免SSL证书报错 options.accept_insecure_certs = True driver = webdriver.Chrome(options=options) driver.get("https://example.com/login")

这里有一个细节:accept_insecure_certs在不少自动化环境里都容易忽略。代理模式下浏览器访问HTTPS站点时,证书链会被替换成代理自己的证书,如果脚本没有信任这个证书,页面可能直接显示“您的连接不是私密连接”,Selenium自然也就操作不下去了。加上这一句之后,我基本没有再遇到证书拦截的问题。

3.2 拦截、筛选与读取请求体/响应体

跑完driver.get之后,所有请求都会存放在driver.requests这个列表里。遍历一下就能看到完整信息:

for request in driver.requests: print(request.method, request.url) print(request.headers) print(request.body) if request.response: print(request.response.status_code) print(request.response.headers) print(request.response.body)

注意“请求体”和“响应体”都是字节串,直接打印可能会看到b'...'这种格式。如果是JSON接口,最好做一次解码和解析:

import json for request in driver.requests: if "api/login" in request.url and request.method == "POST": body = request.body.decode("utf-8", errors="ignore") print("请求参数:", body) if request.response: resp_body = request.response.body.decode("utf-8", errors="ignore") try: data = json.loads(resp_body) print("接口返回:", data) except json.JSONDecodeError: print("非JSON响应:", resp_body[:500])

实际使用中,一个页面会发起十几个请求,我们不能全部打印,否则日志会被刷爆。更常见的做法是写一个过滤器:

def find_request(driver, keyword, method=None): for req in driver.requests: if keyword in req.url and (method is None or req.method == method): return req return None

这条工具方法我几乎在所有的自动化项目里都会写。等到定位问题时,只要传接口关键字,就能拿到对应请求和响应,非常方便。

3.3 修改请求与响应:mock接口的正确姿势

Selenium Wire最香的功能之一是response_intercept。你可以在浏览器真正接收响应之前,把响应内容替换掉,用来模拟后端错误、超时、空数据等场景。比如我想让某个查询接口永远返回空列表:

from seleniumwire import webdriver driver = webdriver.Chrome() def interceptor(request): if "api/user/list" in request.url: request.create_response( status_code=200, headers={"Content-Type": "application/json"}, body='{"code":0,"data":[]}' ) driver.response_intercept = interceptor driver.get("https://example.com/user")

这样前端拿到的就是空列表,我可以接着断言页面有没有显示“暂无数据”这个兜底文案。这种玩法非常适合做异常分支的自动化覆盖——不用改后端,不用造数据,纯粹在前端层模拟,稳定性非常高。

还有一个使用细节:driver.requests这个列表会一直保留到驱动关闭。如果你执行了多步操作,请求越积越多,内存也会慢慢上涨。我在长时间运行的用例中,会在关键步骤开始前先清空一次:

del driver.requests

清空之后再执行点击或提交,这样driver.requests里存的就是当前步骤相关的请求,筛选起来更干净,也不容易误判。

4. 实操:基于CDP的Network事件监听,不依赖代理

4.1 开启Network域:代码骨架

如果你不想额外装selenium-wire,或者你已经在用Selenium 4的原生webdriver,那可以走CDP路线。Selenium 4里有一个execute_cdp_cmd方法,可以直接向浏览器发送CDP命令。首先开启Network域:

from selenium import webdriver capabilities = { "goog:loggingPrefs": {"performance": "ALL", "browser": "ALL"} } options = webdriver.ChromeOptions() options.set_capability("goog:loggingPrefs", capabilities) driver = webdriver.Chrome(options=options) driver.execute_cdp_cmd("Network.enable", {}) driver.get("https://example.com")

开启Network.enable之后,浏览器会记录网络事件,但这些事件不会直接推到Python里,而是写入浏览器的performance日志。所以要取数据,需要用driver.get_log("performance")

logs = driver.get_log("performance") for entry in logs: print(entry["message"])

每条message是一个JSON字符串,里面包含methodparams,比如Network.requestWillBeSentNetwork.responseReceivedNetwork.loadingFinished等。

4.2 捕获请求/响应事件的字段解析

拿到日志之后,解析是重头戏。我习惯写一个简单的事件解析函数,把关键字段抽出来:

import json for entry in logs: message = json.loads(entry["message"]) method = message["method"] params = message.get("params", {}) if method == "Network.requestWillBeSent": request = params["request"] print("请求URL:", request["url"]) print("请求方法:", request["method"]) if "postData" in request: print("请求体:", request["postData"]) elif method == "Network.responseReceived": response = params["response"] print("响应URL:", response["url"]) print("状态码:", response["status"]) print("响应头:", response["headers"])

这个方式不需要代理,浏览器直接暴露数据,性能损耗最小。但也有一个很直观的问题:你只能拿到“事件参数”里暴露的字段,response body本身并不在Network.responseReceived里。如果关心接口返回的具体内容,还需要发Network.getResponseBody命令,并且要传入请求ID:

request_id = params["requestId"] body = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": request_id}) print(body.get("body"))

需要注意的是,并不是所有响应都能body。如果请求刚发出还没结束,或者资源已经被回收,会抛异常。所以我在封装时会加一个try/except,或者放在Network.loadingFinished事件之后再调用。

4.3 等待指定接口返回后再执行下一步

CDP方式最大的优势是“实时性”。你可以通过轮询performance日志,判断某个接口是否已经返回。比如我想在点击搜索按钮后,等待/api/search接口返回再断言结果区,可以这样写:

driver.find_element(...).click() def search_finished(driver): logs = driver.get_log("performance") for entry in logs: message = json.loads(entry["message"]) if message["method"] == "Network.responseReceived": url = message["params"]["response"]["url"] if "/api/search" in url: return True return False WebDriverWait(driver, 10).until(search_finished)

注意一个坑:driver.get_log("performance")在调用之后会清空日志缓冲。也就是说,第二次调用不会返回第一次已经读过的内容。上面这个写法没问题,因为WebDriverWait每次循环都会调用一次search_finished,每次都会拉取新日志。但如果你用的是driver.requests这类累积式接口,就别混着用,否则容易把日志“读丢”。

5. 拿捕获结果解决真实问题:下载等待、上传失败、慢请求定位

5.1 下载文件等到后端返回成功再断言

很多人在自动化里处理下载,是用os.path.exists去轮询下载目录。这个方法太脆了:文件还没落盘时,前端可能已经开始写临时文件;下载中断时,判断也会误报成功。更稳的做法是捕获下载请求,确认HTTP层面真的返回了200,再检查文件。

用Selenium Wire实现很直接:

from seleniumwire import webdriver import os, time download_dir = "/tmp/downloads" def download_success(driver, keyword): for req in driver.requests: if keyword in req.url and req.response and req.response.status_code == 200: return True return False driver.find_element(...).click() wait = WebDriverWait(driver, 30) wait.until(lambda d: download_success(d, "/download")) # 再等文件落盘 deadline = time.time() + 10 while time.time() < deadline: if any("report" in f for f in os.listdir(download_dir)): break time.sleep(0.5)

请求层面的成功配合文件系统层面的存在,双条件都满足,下载断言基本不会误报。

5.2 定位“上传失败: 网络请求错误”到底是谁的锅

我在排查上传问题时被“上传失败: 网络请求错误”这种提示坑过很多次。前端只给一段含糊文案,既没有状态码,也没有后端错误信息。很多人的第一反应是改脚本重试,但只有把上传请求抓出来,才能知道是请求本身的问题还是服务端的限制。

有一次,我在做小程序的自动化上传流程,脚本里点击“上传”后,前端一直弹“上传失败: 网络请求错误”。用Selenium Wire打印了上传请求的响应,发现返回的联系是413 Request Entity Too Large,后端明确提示代码包大小超过限制。这才发现问题根本不在Selenium脚本,而是资源包体积超了阈值。

围绕上传场景,我一般会重点看三个东西:

  • 上传URL是否发送成功,请求Method是不是POST/PUT
  • 请求体是否有完整的文件流或form-data;
  • 响应体的状态码和后端错误信息。

用代码来定位也很简单:

for req in driver.requests: if req.method == "POST" and "upload" in req.url: print(req.url) print(req.body[:200] if req.body else "body为空") if req.response: print(req.response.status_code) print(req.response.body.decode("utf-8", errors="ignore")[:500])

很多时候“网络请求错误”不是网络问题,而是请求参数异常、响应超时或文件体量过大。抓一次请求,这个问题就水落石出了。

5.3 通过时间戳统计每个接口的耗时

做性能分析的时候,我想知道一个页面上哪些接口最慢。Selenium Wire并没有直接给出每个请求耗时,但我可以在操作前后清理请求列表,再记录一个整体时间。更精细的做法是走CDP日志,把Network.requestWillBeSentNetwork.loadingFinished的时间戳配对,算出每个请求的完整耗时。

import json logs = driver.get_log("performance") time_by_url = {} for entry in logs: message = json.loads(entry["message"]) method = message["method"] params = message.get("params", {}) if method == "Network.requestWillBeSent": url = params["request"]["url"] time_by_url[url] = params["timestamp"] # 用事件时间近似开始时间 elif method == "Network.loadingFinished": url = None # 不能只靠URL,因为同一个URL可能有多次请求,更严谨的是用requestId做关联 request_id = params["requestId"] # 这里仅演示思路,实际需要把requestId和URL建立映射 time_by_url[request_id] = params["timestamp"] - time_by_url.get(request_id, 0)

这里必须提醒:同一次会话中同一个URL可能被请求多次,不能单纯用URL做key。最严谨的做法是在requestWillBeSent时把requestIdURL存成映射,再用loadingFinishedrequestId去回查。我在实际脚本中会维护一个字典,key是requestId,value是开始时间,最后再归并到URL上。

6. 绕不开的坑:证书、端口、并发、兼容性

6.1 代理模式下的SSL证书信任

只要走代理模式,就一定会遇到HTTPS证书问题。Selenium Wire内置了一个CA证书,但如果你在Docker容器里跑脚本,容器里没有安装这个CA,Chrome就会拦截所有HTTPS请求。我踩过这个坑之后,在初始化配置里强制加了两项:

options.add_argument("--ignore-certificate-errors") options.accept_insecure_certs = True

这样能解决大部分问题。如果还不行,可以手动指定Selenium Wire的CA路径,把证书塞进系统信任链,不过比较麻烦。我一般直接建议用CDP方案来绕开代理,尤其是跑在容器里的定时任务,CDP模式不用处理代理证书,省心很多。

6.2 端口冲突与并行测试的隔离

Selenium Wire启动时会自动选择一个空闲端口,但当你用pytest-xdistconcurrent.futures开多个浏览器实例时,端口分配偶尔会乱。我见过最诡异的现象是登录态的cookie被串到另一个浏览器实例上,因为两个driver共用了一个代理端口。解决办法是手动给每个实例指定独立的端口:

from seleniumwire import webdriver options = { "port": 8090, # 每个线程用不同端口 } driver = webdriver.Chrome(seleniumwire_options=options)

另外,driver.requests是绑在实例上的,多线程千万别共用一个driver去读请求列表,否则数据会互相污染。我之前在写并发下载用例时,就是因为全局共享driver,导致请求列表里的URL对不上实例,排查了半天才发现是线程安全问题。

6.3 浏览器版本与CDP协议的兼容性

CDP协议是跟着浏览器走的。Chrome每次大版本更新,都可能有字段调整。比如Network.requestWillBeSent里的request.postData在旧版本叫postData,新版本也可能变成postDataEntries;某些内部请求(比如chrome://newtab)也会被记录,过滤时必须排除chrome://devtools://开头的URL。

Selenium Wire对Chrome版本的兼容性也没有想象中好。它底层依赖某个版本的ChromeDriver协议,如果Chrome自动升级到新版,可能导致Selenium Wire无法建立代理会话。我的习惯是锁定浏览器版本,或者在CI里用固定版本镜像,而不是每次都拉最新版。生产环境稳定,比追新更重要。

另外,不管用哪种方案,请求日志都会占内存。页面越复杂,网络事件越多,长时间跑用例时最好定期清理一下历史请求。Selenium Wire里可以用del driver.requests,CDP日志则会在每次get_log之后自动清空,这两种方式都能防止内存膨胀。

我个人在项目里的习惯是:功能测试、上传下载断言这类场景优先用Selenium Wire,因为它把请求/响应体封装得最好读;性能巡检、接口耗时统计这类轻量任务,就切到CDP日志,减少代理这一层转发,数据也更接近真实用户体验。两种方式各有各的脾气,但只要把上面这些坑都填平,它们都能成为Selenium自动化脚本里非常可靠的那双“F12”眼睛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询