如何在 Selenium Grid 上识别验证码(RemoteWebDriver)

在 Selenium Grid 上识别验证码和在本地识别完全一样,只有一个差别,而大多数人恰恰栽在这里。浏览器跑在 node 上,你的测试代码不在。CapSkip 调用发生在你的测试进程里,所以识别工具必须能从你运行 pytest 的那台机器访问到,而不是从跑浏览器的那台。把这一点的方向搞对,剩下的和你对着本地 Chrome 写的代码没有任何区别。
你需要什么
- 一个你能访问到的 Selenium Grid。Standalone、hub 加 node,或者完全分布式部署,从客户端看行为都一样。
- 运行在一台 Windows 机器上、并且能从跑测试的那台机器访问到的 CapSkip。
- Grid 地址。Selenium 4 默认在 4444 端口上监听 RemoteWebDriver 请求。
- 被测站点的 sitekey 和页面 URL。
到底是哪台机器在跟识别工具通信
在动手配置任何东西之前,先把这三个框画出来,因为其中两个看着可以互换,实际上不能。
| 哪台机器 | 那里跑着什么 | 它跟 CapSkip 通信吗? |
|---|---|---|
| 你的测试运行机 | pytest、SDK 调用、RemoteWebDriver | 是。只有它会 |
| Grid 的 hub | 路由和 session 队列 | 否。它只负责路由 session |
| 浏览器 node | 真正的浏览器 | 否。它永远看不到识别工具 |
这一点被接反的情况多得出奇,通常是因为 Grid 的 node 才是跑在 Docker 里的那部分,而大家默认网络问题应该出在 Docker 上。node 上没有网络问题。token 是通过普通的 WebDriver 协议送到那里的,作为一条脚本执行命令的参数,和任何其他字符串走的路一模一样。
所以你需要哪种连接模式,取决于你的测试在哪里执行。CapSkip 提供 Local mode,它绑定 127.0.0.1,只服务本机;也提供 Server mode,它绑定你的网络地址或公网 IP,这样另一台机器、容器宿主机或 CI runner 就能通过 API 访问同一台 Windows 机器。两者都在 连接设置。Server mode 只改变识别工具运行的位置,别的什么都不变:它仍然是你自己的硬件,也仍然不计次。
| 你的测试跑在哪里 | 用哪种模式,以及 host 的值 |
|---|---|
| 在你自己的 Windows 机器上,驱动一个远程 Grid | Local mode。host 的值保持 127.0.0.1,即使浏览器在别处 |
| 在和识别工具同一网络内的构建代理机上 | Server mode。host 的值是识别工具那台机器的 LAN 地址 |
| 在你网络之外的托管 CI runner 上 | Server mode,配静态公网 IP,开启密钥校验,再加一条防火墙规则 |
第一行才是值得留意的那一行。在本地跑测试、连的是共享 Grid 的开发者,可以继续用回环地址,因为识别过程根本没离开他的桌面。
第 1 步:把 driver 指向 Grid
Selenium 4 接收 Grid 地址和一个 options 对象。旧的 hub 路径后缀已经不需要了,base URL 就够。
# pip install selenium capskip
from selenium import webdriver
GRID = "http://grid.internal:4444"
options = webdriver.ChromeOptions()
options.add_argument("--no-sandbox")
# command_executor is the Grid, not the browser. Everything you
# call on this driver is a request over the wire to the node.
driver = webdriver.Remote(command_executor=GRID, options=options)验证码相关的东西在这里没有任何变化。变化的是每一次 driver 调用现在都是一次网络往返,这一点在下面的超时小节里会变得重要。
第 2 步:先识别,再注入 token
识别是你测试进程里的一次本地函数调用。注入是在 node 上执行的一段脚本。把这两件事在脑子里分开,代码自己就写出来了。
# pip install capskip
import os
from capskip import CapSkip
# The host is 127.0.0.1 when the tests run on the solver machine,
# and the solver's address when they do not. It is never the node.
solver = CapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=8080,
)
result = solver.recaptcha(
sitekey="YOUR_SITEKEY",
url="https://example.com/page-with-recaptcha",
)
# This runs on the node. The token travels as a script argument.
driver.execute_script(
"document.getElementById('g-recaptcha-response')"
".value = arguments[0];",
result["code"],
)把 token 作为参数传进去,而不是围绕它拼出脚本字符串,这一点在 Grid 上比在本地更要紧,因为脚本会被序列化并发送到 node。字符串拼接正是引号 bug 变成一个悄无声息的空字段的地方。
reCAPTCHA 的每个变体都是同一个方法多加一个关键字参数:invisible 设为 1,enterprise 设为 1,或者 version 设为 v3 并带上 action 名称。Turnstile 和极验是各自独立的方法,形状一样,参数列表见 CapSkip API 文档.
第 3 步:会杀掉慢识别的 session 超时
下面这个失败模式是 Grid 特有的,而且很典型。node 会杀掉任何在其 session 超时时长内没有活动的 session,这个超时默认是 300 秒。而 SDK 在轮询答案期间,你的测试进程根本没在调用 driver。浏览器就那么干等着,从 node 的角度看,这个 session 像是被遗弃了。
对 reCAPTCHA v2 的勾选框来说这从来不是问题,因为答案通常远不到一分钟就回来了。它会出现在 Turnstile 的挑战页面和极验上,出现在识别工具很忙的时候,也出现在任何一次你恰好撞上 SDK 自身上限的运行里。那个上限是 300 秒,由 recaptchaTimeout 决定,正好等于 node 的默认值。在默认值下,这一局你赢不了。node 的空闲计时早在 SDK 开始轮询之前就起跑了,所以 session 会先被回收,接下来的 driver 调用报的是 invalid session id,而不是任何跟验证码有关的东西。
三个修复办法,按值得尝试的先后顺序排列。
- 用 node 的 session 超时选项把它调高,调到明显超过 300 秒。这是最实在的修法,而且不花一分钱。
- 在识别进行期间让 session 保持忙碌。同步客户端是阻塞的,所以这意味着把识别挪到一个线程里,或者改用异步客户端,然后每隔一段时间做一次开销很小的 driver 调用,把空闲计时重新归零。这招管用,代价是多出几个活动部件。
- 把 SDK 自己的上限调低,让它先放弃,然后让你的测试去重试。一个由你自己设定的上限抛出的 TimeoutException,在报告里比一个死掉的 session 好读得多。
既然说到这里,另外两个 Grid 限制也值得知道。每个 node 的最大 session 数默认等于该 node 的处理器核数,这才是你真实并行度的上限。另外,一个新建 session 的请求如果在队列里待的时间超过 session 请求超时(默认同样是 300 秒),还没等浏览器启动就会被拒绝。
完整可运行示例
一个完整的测试:打开页面、识别、注入、提交。提交紧跟在注入之后是故意的:一个 reCAPTCHA token 大约只有两分钟有效期,而 Grid 会在每一步之间加上网络往返。更多内容见指南 reCAPTCHA token 过期.
# pip install selenium capskip
import os
from selenium import webdriver
from selenium.webdriver.common.by import By
from capskip import CapSkip
from capskip.exceptions import NetworkException, TimeoutException
GRID = "http://grid.internal:4444"
PAGE = "https://example.com/page-with-recaptcha"
def test_login_through_recaptcha():
options = webdriver.ChromeOptions()
driver = webdriver.Remote(command_executor=GRID, options=options)
try:
driver.get(PAGE)
# Read the sitekey off the rendered page rather than
# hardcoding it. It is on the node, so this is a round trip.
sitekey = driver.find_element(
By.CSS_SELECTOR, ".g-recaptcha"
).get_attribute("data-sitekey")
solver = CapSkip(host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"))
result = solver.recaptcha(sitekey=sitekey, url=PAGE)
driver.execute_script(
"document.getElementById('g-recaptcha-response')"
".value = arguments[0];",
result["code"],
)
driver.find_element(By.CSS_SELECTOR, "form").submit()
except NetworkException:
raise AssertionError("CapSkip unreachable from the test runner")
except TimeoutException:
raise AssertionError("Solve did not finish before the ceiling")
finally:
driver.quit()在 Grid 上一定要在 finally 块里退出 driver。本地 Chrome 泄漏了,不过是你自己机器上多一个游离进程。而泄漏的 Grid session 会一直占着那个 node 的一个 session 槽位,直到超时把它回收;在一个按四核配置的 node 上,那就是四分之一的容量白白空转到那时候。
在并行 session 之间同时跑识别
Grid 的意义就在于同时跑很多浏览器,而每个 session 都需要自己的 token。识别工具接受并发提交,所以行得通的做法是让识别保持异步,而不是把整个测试套件串在一次阻塞调用后面。
Python SDK 为此提供了真正的异步客户端,这一点值得明说,因为 Node.js、PHP 和 .NET SDK 里对应的类只是别名,并不是独立实现。批量处理的模式写在 用 Python 并行识别验证码的指南,而且当浏览器恰好在远端时,这套做法原封不动照样成立。
多跑几个并不会更贵。真正有上限的是 Grid:每个 node 的最大 session 数,以及它前面的队列超时。测试并行度要按 Grid 来定,而不是按识别工具来定。
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| NetworkException,8080 端口连接被拒绝 | 识别工具处于 Local mode,而测试跑在另一台机器上 | 切换到 Server mode,并把 host 设为识别工具的地址 |
| 你配置了让 node 能访问识别工具,结果什么都没变 | node 从不调用 CapSkip,调用它的是你的测试进程 | 改成从测试运行机这边把 host 的值指向识别工具 |
| 一次漫长的识别之后紧接着报 invalid session id | SDK 还在轮询时,node 把这个空闲 session 回收了 | 把 node 的 session 超时调到 300 秒以上 |
| 浏览器还没启动,新建 session 的请求就超时了 | 队列满了,请求在队列里过期作废 | 增加 node,或者调高 session 请求超时 |
| 脚本跑完之后响应字段是空的 | token 被拼进了脚本字符串,引号出了问题 | 像上面的示例那样,把它作为脚本参数传入 |
| 站点拒绝了一个有效的 token | 它在识别和提交之间过期了 | 在下一条语句里就提交,中间不要有任何等待 |
| 提交时报 ERROR_GOOGLEKEY | 从页面读到的 sitekey 是空的,或者读错了元素 | 检查选择器,并确认读取时小组件已经渲染出来 |
| session 在某一个 node 上越积越多 | 某个测试在退出 driver 之前崩溃了 | 像完整示例那样,在 finally 块里退出 |
常见问题
我需要在 Grid 的 node 上装什么东西吗?
不需要。node 只负责跑浏览器,别的什么都不做。SDK 是你测试项目的依赖,识别发生在你的测试进程里,唯一到达 node 的东西是 token,作为一条脚本执行命令的参数。这也是为什么一个 Linux Docker node 配上只能在 Windows 上运行的识别工具照样没问题:它们俩从来不直接对话。
我的测试跑在 CI 里,需要暴露什么?
识别工具的端口,暴露给跑测试的那台机器。如果 runner 是托管的,那就是配静态公网 IP 的 Server mode,外加打开 API 密钥校验,以及一条比整个互联网窄得多的防火墙规则。如果你的 CI runner 是自托管在你自己网络里的,用 LAN 地址就够了,什么都不用出网。无论哪种情况 Grid 都不受影响,因为它不在这条链路上。
为什么我的 session 在 Turnstile 识别到一半时就死了?
因为 node 在其 session 超时时长内没看到任何活动,于是把它清理掉了。默认是 300 秒,而 SDK 对 Turnstile 的上限同样是 300 秒,再加上 node 的空闲计时起跑得更早,所以在这两个默认值下,node 总是抢在 SDK 放弃之前把 session 回收掉。把 node 的 session 超时调高,也可以考虑把 SDK 的上限调低,这样失败会以一个干净的异常返回,而不是变成一个死掉的 session。
这和用本地 driver 识别有区别吗?
区别只在于东西跑在哪里。识别调用和注入是完全一样的,这正是重点。差别都在运维层面:node 的空闲 session 超时、每次 driver 调用的网络往返,以及识别工具地址由你测试运行机的位置决定这一事实。单浏览器的版本见 SeleniumBase 指南 和 undetected-chromedriver 指南.
简短版结论
在 Grid 上,搬走的是浏览器,你的代码没动,所以识别工具的地址由你的测试跑在哪里决定。把 RemoteWebDriver 指向 4444 端口,从测试进程里调用 SDK,并把 token 作为脚本参数传进 node,而不是去拼字符串。把 node 的 session 超时调到 300 秒以上,这样一次慢识别不会被回收掉自己的 session;注入之后立刻提交;并且永远在 finally 块里退出 driver。
- 这个框架整体的说明见 Selenium 验证码识别页面.
- Python 客户端库的说明见 Python 验证码识别页面.
- 这个挑战本身的说明见 reCAPTCHA v2 识别页面.
在把测试套件扩大之前值得知道:CapSkip 是一款 验证码识别工具 ,运行在你已经拥有的硬件上,所以一百个并行 session 和一个 session 的花费完全相同。
