简介:面向Web自动化测试与接口调试场景,该压缩包讲解如何借助Selenium封装POST参数并提交请求,以获取异步返回的JSON对象值。内容适合熟悉Python基础、希望将Selenium从UI操作扩展到接口联调的测试或开发人员,也可作为学习fetch、execute_script与execute_async_script异步处理的入门参考。压缩包共3个文件,含txt思路说明、bak备份及java文件,整体仅5KB,轻量易读,适合快速对照核心实现。资料给出了可运行的封装方式,重点演示fetch构造请求体、execute_script触发提交并解析页面返回的JSON,同时结合java文件补充不同环境下的实现视角,并提示添加请求头、Cookie、Session和认证令牌等真实联调注意事项。由于涉及异步响应,还点出execute_async_script等待回调的价值,帮助避开回调未完成就解析的常见问题。已有3699人学习/下载,适合无法直接使用requests等专用HTTP库,又需要由浏览器侧发起并验证POST异步请求的测试场景。
1. 先说结论:Selenium 没有原生 post 方法,但可以封装得跟 requests 一样顺手
做 Web 自动化的人迟早会遇到一个尴尬:Selenium 的 API 里翻遍也找不到 post() 方法,requests 那边一行requests.post(url, data=params)就能把活干完。被测的某后台系统偏偏把批量导入、状态变更全设计成 POST 接口,你又不想为提交数据再维护一套 requests 登录态。这篇笔记记的是我给 Selenium 封装 POST 参数提交的过程:用execute_async_script在页面上下文里跑 fetch,把参数序列化、cookie 携带、超时兜底收口成一个函数。适合在写自动化脚本、又不想把接口请求割裂成两套登录逻辑的同学,也适合刚搞懂「浏览器里怎么发 POST」的新手。
2. 技术选型与整体设计:两条补 POST 的路线,为什么选了浏览器内 fetch
2.1 为什么 Selenium 原生不支持 POST:它定位就不是 HTTP 客户端
Selenium 从设计上就不是给你发任意 HTTP 请求的。WebDriver 协议里定义了导航、元素查找、脚本执行、窗口管理这些命令,唯独没有「发送任意方法请求」这一条。它模拟的是真实用户操作,而真实用户不会手工构造 POST 包——用户只会点按钮、填表单,POST 是浏览器内部替你发的。想通这一点,你就能理解为什么网上搜「Selenium POST」方案五花八门,因为官方根本没给标准答案。
常见的补法有三条路。第一条是拿 requests 独立发 POST,手动把 Selenium 会话里的 cookie 搬过去,这是很多人最先想到的,实现也快。第二条是用execute_script在页面上下文里跑 fetch 或 XMLHttpRequest,浏览器替你带上 cookie、走同一套安全策略。第三条是走 CDP(Chrome DevTools Protocol)的 Network 域,用底层命令直接发请求,这条路能力最强,但接口复杂,而且不同驱动版本对请求体的支持不一致,普通场景不建议碰。
这三条路的取舍其实很直观。cookie 搬运路线最大的好处是不受页面 CORS 限制,POST 出去的是 Python 进程里的原生请求,服务端该返什么返什么;坏处是登录态和页面里 JS 动态生成的 token 你得自己维护,有些站点每次请求前还要用页面脚本算一个签名头,你在 Python 里复现签名逻辑就是纯受罪。浏览器内 fetch 路线刚好相反:同源请求天然带 cookie,页面里算好的签名头直接复用,缺点就是跨域会被 CORS 拦,以及要多处理一层异步等待。
| 路线 | 实现成本 | 登录态来源 | 跨域支持 | 适用场景 |
|---|---|---|---|---|
| requests 搬运 cookie | 低 | 手动搬运,需处理格式 | 不受限 | 反爬弱、接口简单 |
| 页面内 fetch | 中 | 浏览器自带,无搬运 | 同源顺畅,跨域被 CORS 拦 | 同源接口、签名复用 |
| CDP Network 域 | 高 | 需自己构造头 | 受限 | 底层调试场景 |
我最后选的是以浏览器内 fetch 为主、requests 搬运为辅的混合策略。主路径解决同源场景下九成的 POST 需求,遇到跨域或者反爬太重的场景再降级到 cookie 搬运。自动化测试里 POST 的目标大多是同一个被测系统下的接口,同源是常态,跨域是例外,所以这个判断在落地时几乎没出过错。
2.2 整体结构:一个函数收口,调用方不感知异步细节
封装的目标是让调用方写起来像 requests:传 driver、url、params、headers,拿回响应文本,至于背后是 fetch、回调还是 Promise,调用方一概不用管。所以我把所有脏活收在post_data一个函数里,对外只暴露这几个参数。
| 参数 | 类型 | 说明 |
|---|---|---|
| driver | WebDriver | 已实例化的浏览器对象,需停留在目标站点的任意页面 |
| url | str | 完整 POST 地址,含协议和路径 |
| params | dict | 业务参数,会被序列化成 JSON 或表单结构 |
| headers | dict | 附加请求头,默认生成 Content-Type |
| content_type | str | json 或 form,决定 body 结构 |
| timeout | float | 秒,脚本内部兜底超时,默认 5 秒 |
这层收口的意义在于:调用方不需要知道execute_async_script的回调约定,不需要知道 fetch 的 credentials 参数,也不需要关心 JSON 序列化的转义细节。后面加功能,比如加重试、加日志、加统一异常包装,都在一个函数里加,调用方代码一行不用改。这就是封装最常见的价值——把复杂度钉死在边界内部。
2.3 依赖与目录:一个文件就够,不引额外包
依赖上只需要 selenium 一个库,Python 3.8 以上即可。requests 只在降级路线里用,算可选依赖。整个工程骨架是这样:
post_wrapper/ ├── selenium_post.py # 封装主文件,post_data / post_form / 辅助函数 ├── demo.py # 完整可跑的示例,含登录和断言 ├── requirements.txt # selenium>=4.0 └── README.md # 使用说明# requirements.txt selenium>=4.0我把封装独立成一个文件是有意的:自动化项目里这个文件会被多个脚本 import,独立成模块比塞在测试脚本里更好维护。驱动建议用和浏览器版本匹配的 ChromeDriver,版本不匹配时启动就会报session not created,这个坑看起来低级,实际遇到的人真不少。
3. 核心封装实现:execute_async_script 跑 fetch,把异步回调收口成同步返回
3.1 第一版 post_data:回调是最后一个参数
execute_async_script和execute_script最大的区别是:前者会把脚本的最后一个参数当作回调函数,脚本执行完必须调用这个回调,driver 才会把结果返回到 Python 侧。这正好用来等 fetch 的 Promise 落地。第一版代码如下:
# selenium_post.py import json def post_data(driver, url, params=None, headers=None, content_type='json', timeout=5): """在浏览器上下文里提交 POST,返回响应文本。""" payload = json.dumps(params) if params else '{}' headers_json = json.dumps(headers or {}, ensure_ascii=False) script = """ const callback = arguments[arguments.length - 1]; const url = arguments[0]; const payload = arguments[1]; const headers = JSON.parse(arguments[2]); const timeoutMs = arguments[3]; const opts = { method: 'POST', headers: headers, credentials: 'include' }; if (payload.length > 0) { opts.body = payload; } const timer = setTimeout(() => { callback(JSON.stringify({error: 'timeout'})); }, timeoutMs); fetch(url, opts) .then(res => res.text()) .then(text => { clearTimeout(timer); callback(text); }) .catch(err => { clearTimeout(timer); callback(JSON.stringify({error: err.message})); }); """ result = driver.execute_async_script( script, url, payload, headers_json, timeout * 1000 ) try: parsed = json.loads(result) if isinstance(parsed, dict) and 'error' in parsed: raise RuntimeError(f"POST 失败: {parsed['error']}") except json.JSONDecodeError: pass return result这段脚本的逻辑链是:先取出回调函数,再解包 Python 传进来的四个参数,构造 fetch 的 opts,最后把响应文本传给回调。credentials: 'include'是必须的一行——不加它,某些浏览器在子域或跨域场景下不会自动带 cookie,同源下虽然默认带,但显式声明等于给行为上了保险。timeout 在 Python 侧是秒,脚本里转成毫秒,因为 setTimeout 只认毫秒。
这里有两个细节值得展开。第一个是异常分支必须调 callback,否则 Python 侧会一直等到script_timeout默认值 30 秒,然后抛TimeoutException。第二个是返回的是响应文本而不是 JSON 对象,fetch 的res.text()拿到什么就是什么,可能是 JSON 字符串、HTML 错误页,也可能是空串;所以外层用json.loads试探,解析失败就当成纯文本返回,由调用方决定下一步。
3.2 表单格式:换一种 body 结构,代码骨架不变
很多老系统后端只认application/x-www-form-urlencoded,不认 JSON。这时候 body 不能是 JSON 字符串,得用 URLSearchParams 构造。我把这条路拆成独立的post_form函数,避免在一个函数里塞太多分支:
def post_form(driver, url, params=None, headers=None, timeout=5): """以表单格式提交 POST,用于老系统后端。""" script = """ const callback = arguments[arguments.length - 1]; const url = arguments[0]; const params = JSON.parse(arguments[1]); const headers = JSON.parse(arguments[2]); const timeoutMs = arguments[3]; const body = new URLSearchParams(); for (const key in params) { body.append(key, params[key]); } const opts = { method: 'POST', headers: Object.assign({}, headers, { 'Content-Type': 'application/x-www-form-urlencoded' }), body: body.toString(), credentials: 'include' }; const timer = setTimeout(() => { callback(JSON.stringify({error: 'timeout'})); }, timeoutMs); fetch(url, opts) .then(res => res.text()) .then(text => { clearTimeout(timer); callback(text); }) .catch(err => { clearTimeout(timer); callback(JSON.stringify({error: err.message})); }); """ return driver.execute_async_script( script, url, json.dumps(params or {}), json.dumps(headers or {}), timeout * 1000 )和post_data的差别集中在三处:参数从 JSON 字符串变成JSON.parse出来的对象;body 用 URLSearchParams 逐项 append;Content-Type 显式指定成表单格式。URLSearchParams 会自动做 URL 编码,中文和特殊字符传给后端不会乱码。注意 append 是追加语义,同一个 key 重复传就会生成key=value1&key=value2,正好用来提交多选数据。
这两个函数的脚本主体重复度不低,工程化时可以把 JS 模板抽出来,只替换 body 构造那段,用字符串模板拼一下。我这版保持两个独立函数是为了示例可读性,你也可以按自己团队的规范合并。
3.3 返回解析与超时兜底:让调用方拿到「能用的东西」
脚本返回的是文本,但调用方往往想要 dict。我在封装里补一个解析辅助函数,并把它和超时策略放在一起讲,因为这两个问题在实战里是连着出现的:
def parse_response(text, expect='json'): """把响应文本解析成 dict;解析失败返回 {'raw': text}。""" if expect != 'json': return text try: return json.loads(text) except json.JSONDecodeError: return {'raw': text, 'error': 'NOT_JSON'} import time from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout def post_data_safe(driver, url, params=None, timeout=5, **kwargs): """带 Python 侧超时兜底的提交,防止脚本卡死整个测试。""" if timeout <= 0: return post_data(driver, url, params=params, **kwargs) with ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(post_data, driver, url, params=params, **kwargs) try: return future.result(timeout=timeout + 2) except FutTimeout: future.cancel() raise RuntimeError(f"POST 超过 {timeout + 2} 秒未返回,疑似卡死")这里有两层超时:脚本内部的 setTimeout 负责让 fetch 放弃等待并返回一个 error 标记;Python 侧的future.result(timeout=...)负责兜住脚本完全卡死的情况,比如页面 JS 把 fetch 的 Promise 挂住。两层缺一不可,只靠脚本内超时解决不了「回调被吞」的问题,只靠 Python 超时则会在每次超时时留下一个悬空脚本执行,后续操作可能受影响。
parse_response 的设计原则是「解析失败也不抛异常」。接口返回 HTML 错误页、返回空串、返回纯文本,在自动化测试里都常见,抛异常只会让调用方被迫写一堆 try/except。返回{'raw': text}让调用方自己决定怎么处理。这个函数虽然小,但能让调用代码干净不少。
4. 完整示例:登录态下 POST 提交并断言返回
4.1 先真实登录,拿到页面里的会话
封装的 post_data 依赖页面上下文,所以第一步是把浏览器导航到目标站点并完成登录。登录我坚持用真实点击操作,不直接往 cookie 里塞值——有些站点有 JS 弹窗校验、滑块、或者登录后要写 localStorage 里的签名,这些只有真实交互才能触发。示例代码:
# demo.py from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium_post import post_data, parse_response driver = webdriver.Chrome() try: driver.get('https://demo.test/login') driver.find_element(By.NAME, 'username').send_keys('testuser') driver.find_element(By.NAME, 'password').send_keys('pass123') driver.find_element(By.CSS_SELECTOR, 'button[type=submit]').click() # 等登录跳转完成,别急着发 POST WebDriverWait(driver, 10).until( EC.url_contains('/dashboard') ) print('登录成功,当前地址:', driver.current_url)这一步的关键不是登录本身,而是「等登录跳转完成」。如果不加 WebDriverWait,跳转过程中调用 post_data 就可能撞上第 5 章讲的「执行上下文丢失」问题。等待条件用url_contains('/dashboard')而不是 sleep,是为了让等待时间自适应——网络快就快等,网络慢就慢等,不用拍脑袋定 sleep 秒数。
4.2 调用封装的 post_data 提交参数并断言
登录完成后,post_data 可以在任意页面上调用。注意它不要求停在某个特定页面,只要还是同一个域,fetch 就会带上会话 cookie。示例:
resp = post_data( driver, 'https://demo.test/api/order/batch', params={'order_id': 'A1024', 'status': 'shipped'}, headers={'X-Requested-With': 'XMLHttpRequest'}, content_type='json', timeout=10 ) data = parse_response(resp) assert data.get('code') == 0, f"业务失败: {data}" print('订单状态更新成功:', data.get('message')) finally: driver.quit()headers 里的X-Requested-With: XMLHttpRequest值得单独说。很多后端拦截器用这个头判断「是不是一次 Ajax 请求」,决定是否放行;直接 fetch 默认不带这个头,可能被拦截器当非法请求打回来。每个项目的后端要求不同,所以这个头留在调用方传,封装里不写死。
assert data.get('code') == 0是业务层断言,和 HTTP 状态码是两回事。很多接口无论成败都返回 HTTP 200,真正表示结果的是业务字段 code。自动化里断言写在这里,比只看状态码可靠得多。断言失败时带上data全文,服务端返回什么错误一眼可见。
4.3 给示例加上重试外壳,处理偶发网络抖动
POST 是写操作,重试是个危险动作。但网络抖动、服务端偶发 500 这类场景,不重试又会让用例直接红掉。我加了一个带指数退避的重试外壳,并把「是否允许重试」做成参数:
import time def post_with_retry(driver, url, params, retries=2, interval=1, idempotent=True, **kwargs): """带重试的 POST 提交;非幂等接口慎用重试。""" last_err = None for attempt in range(retries + 1): try: raw = post_data(driver, url, params=params, **kwargs) data = parse_response(raw) if isinstance(data, dict) and 'error' in data: raise RuntimeError(data['error']) return data except Exception as err: last_err = err if not idempotent: raise wait = interval * (2 ** attempt) print(f"第 {attempt + 1} 次提交失败,{wait} 秒后重试: {err}") time.sleep(wait) raise last_err这个外壳里藏着两条自动化测试的血泪经验。第一条是「重试只对幂等接口开放」——订单创建、文件上传这类接口重试会产生重复数据,必须把 idempotent 设为 False 并立刻抛出;查询状态、幂等更新这类接口才能放心重试。第二条是「退避间隔要递增」——第一次失败等 1 秒、第二次等 2 秒、第三次等 4 秒,避免服务端还在抖动时你持续撞击。print 日志在排障现场非常管用,至少你知道哪一步重试了、失败原因是什么。
5. 避坑指南:封装 POST 时踩过的四个典型翻车现场
下面四个问题都是我在实际跑自动化脚本时真实踩过的,每一个都让脚本红了一轮甚至几轮。按「现象 → 原因 → 解决」记下来,希望帮你省掉几小时的排查时间。
5.1 跨域请求被 CORS 拦下,fetch 直接报 Failed to fetch
现象:post_data 执行后抛TypeError: Failed to fetch,浏览器控制台里有 CORS policy 提示,服务端日志里根本看不到这次请求。
原因:fetch 是从页面上下文发出的,受同源策略约束。接口域名和当前页面域名不一致时,浏览器会先发 OPTIONS 预检请求,服务端没返回Access-Control-Allow-Origin等响应头,请求就被拦在浏览器门口,根本没到业务代码。
解决:跨域场景直接放弃浏览器内 fetch,降级到 cookie 搬运路线,用 requests 在 Python 进程里发请求。如果必须留在浏览器内,就让后端网关把接口代理成同源地址,或者给被测环境加允许跨域的配置。我的习惯是在封装里开一个use_requests开关,遇到跨域自动切换,调用方无感知。
5.2 回调函数被调了两次,返回结果变得不可预测
现象:脚本偶尔返回正常结果,偶尔卡到超时,偶尔返回一段看似无关的字符串,排查很久都找不到规律。
原因:fetch 的 Promise 里 then 和 catch 分支竞争触发,或者 timer 和 fetch 的 resolve 抢顺序,回调被调用了两次。execute_async_script的约定是只认第一次调用,第二次会被忽略,但两次调用之间的逻辑互相干扰,返回结果就会飘忽不定。
解决:在 JS 脚本里保证「同一个脚本只有一个出口」。我的写法是声明一个 done 标志位,所有分支统一走一个 finish 函数:
let done = false; function finish(result) { if (done) return; done = true; callback(result); }然后把脚本里所有callback(...)都替换成finish(...),无论成功、失败还是超时,都从同一个出口走。从那以后我再没遇到过二次回调的问题。
5.3 携带 cookie 时格式不兼容,requests 那边一直 401
现象:走 cookie 搬运路线时,requests.post返回 401,或者 requests 直接抛ValueError: invalid expiry,请求根本发不出去。
原因:driver.get_cookies()返回的每条 cookie 里有 expiry 字段,是秒级的 float;而 requests 的 cookiejar 里 expiry 要的是 datetime 对象。直接把全部字段塞进去,requests 要么解析失败,要么因为格式错乱把 cookie 当成失效状态丢掉了。domain、path 字段的预期格式和 Selenium 返回的也不完全一致。
解决:搬运时只挑 name、value、domain、path 四个字段,用session.cookies.set(...)一个个塞,expiry 干脆不传,让它按会话 cookie 处理。实测中这个做法稳定得多。需要持久化时再用http.cookiejar.Cookie显式构造并转 datetime,不要偷懒直接传 dict。
5.4 页面跳转或新开 Tab,execute_async_script 找不到执行上下文
现象:登录点击跳转后立刻调 post_data,报no such window或者target frame detached;或者页面里某个按钮window.open了新窗口,脚本就跑到一个不可见的窗口上去了。
原因:跳转过程中旧的 document 正在销毁,执行上下文不稳定;新开 Tab 后 WebDriver 的当前窗口句柄不会自动切到新窗口,脚本实际在错误的目标上执行。
解决:post_data 前先确认窗口就绪——用 WebDriverWait 等目标 URL 稳定,或者等某个关键元素出现;开过新窗口的脚本,先driver.switch_to.window(driver.window_handles[-1])切回来再提交。我的习惯是在封装内部每次调用前检查driver.current_url和接口是否同源,不同源就直接抛一个明确的错误提示,省得调用方在错误信息里绕弯。
提示:封装里加一句「同源预检」能省掉后面一大半排查时间。10 行以内的检查,比任何文档都管用。
6. 二次开发:把函数封装升级成通用 HTTP 工具层
6.1 收进一个类,绑定 driver 生命周期
函数式的 post_data 要到处传 driver,改成类之后就顺手多了。类的好处是 driver 绑定一次,后续所有请求都复用,也方便在内部统一加日志、加钩子:
class SeleniumHttp: """把 Selenium 会话包装成简易 HTTP 客户端。""" def __init__(self, driver, base_url=None): self.driver = driver self.base_url = base_url self.logs = [] def post(self, path, **kwargs): url = self.base_url.rstrip('/') + path if self.base_url else path raw = post_data(self.driver, url, **kwargs) self.logs.append((url, kwargs.get('params'), raw[:200])) return parse_response(raw) def get(self, path, **kwargs): # 注意:GET 带参数要手动拼 query,driver.get 不支持字典参数 url = self.base_url.rstrip('/') + path if self.base_url else path self.driver.get(url) return self.driver.page_source这个类最值得延伸的是 logs 列表——每一条请求的 URL、参数和响应前 200 字都被记下来,测试失败时直接翻日志就能看到完整请求链路。后面可以改成打成结构化日志,或者挂到 pytest 的 fixture 里自动输出。另一个可扩展点是 base_url 和路径拼接的逻辑,让它支持带 query 的 path。
6.2 混合 requests 的落地姿势:Selenium 登录,requests 办事
遇到反爬严格、或必须跨域的场景,我的习惯是 Selenium 负责登录、requests 负责发请求。这个混合模式既能复用 Selenium 好不容易过掉的登录风控,又能享受 requests 的灵活。核心还是 cookie 搬运的格式处理:
import requests def build_requests_session(driver): """从 WebDriver 的 cookie 构造 requests.Session,保持登录态。""" session = requests.Session() for c in driver.get_cookies(): session.cookies.set( c['name'], c['value'], domain=c.get('domain'), path=c.get('path') ) session.headers.update({ 'User-Agent': driver.execute_script('return navigator.userAgent'), 'X-Requested-With': 'XMLHttpRequest' }) return session这段代码里有两个容易被忽略的细节。第一个是 User-Agent 要从浏览器里读而不是写死,不同机器不同版本的浏览器 UA 不一样,写死后端风控一眼就能识别出「这不是真实浏览器」。第二个是session.cookies.set只挑四个字段,避开了 5.3 节说的 expiry 格式坑。用的时候先driver.get('https://被测站点')触发登录,再构造 session,后续 requests 请求就带着完整登录态了。
从那以后,我每次封装这类工具都强制自己走一遍「先确认同源还是跨域、再决定幂等重试策略、最后把日志留全」的流程,这三个问题想清楚,封装就不会在项目中期返工。这篇的例子虽然只写到工具层,但思路可以直接套到你的自动化框架里,把 post_data 接到现有的 basepage 或者 fixture 上就能用。希望帮到你。
本文还有配套的精品资源,点击获取