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

selenium grid captcha - How to Solve CAPTCHAs on 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 机器上,驱动一个远程 GridLocal 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 idSDK 还在轮询时,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。

在把测试套件扩大之前值得知道:CapSkip 是一款 验证码识别工具 ,运行在你已经拥有的硬件上,所以一百个并行 session 和一个 session 的花费完全相同。