如何在 Nightwatch.js 中识别验证码(命令队列)

在 Nightwatch 里做验证码识别,必须让它跑在命令队列内部,而不是队列旁边。Nightwatch 并不会在你写下浏览器命令的位置执行它们。它先把命令排进队列,等测试函数返回之后再把队列跑完,所以写在两条浏览器命令之间的识别调用会立刻执行:那时页面还没有加载,小组件也还不存在。解决办法是 browser.perform,再加上一个必须调高的全局配置,因为一次识别的耗时超过了 Nightwatch 留给异步回调的十秒。
你需要什么
- Nightwatch 3,以及一个能正常工作的驱动:本地 chromedriver 或者一个远程 WebDriver 端点。
- 在一台 Windows 机器上运行的 CapSkip,并在测试所在的同一个项目里装好 Node 客户端。
- sitekey 和页面 URL。sitekey 要从小组件上读取,不要硬编码,因为预发布环境和生产环境很少共用同一个。
- 只要测试不是跑在识别工具自己那台机器上,就要用服务器模式,所有 CI runner 都属于这种情况。它就是连接设置里的一个选项。
# npm install capskip npm install --save-dev nightwatch npm install capskip
第 1 步:为什么直接写的识别调用会跑得太早
Nightwatch 测试里的每一条浏览器命令,都是往队列里追加的一条指令。测试函数会先从头到尾跑一遍,把队列建起来,之后 Nightwatch 才开始执行这个队列。夹在中间的普通 JavaScript 不属于队列,所以它在建队列的那一趟里就执行了。整个问题一句话就说完了。
// WRONG. The solve starts while the queue is still being
// built, so it runs before browser.url() has navigated.
module.exports = {
"signup form": function (browser) {
browser.url("https://example.com/page-with-recaptcha");
solver.recaptcha(sitekey, pageUrl).then((r) => {
// fires first, against a page that is not open yet
});
browser.click("#submit");
},
};症状之所以让人困惑,是因为没有任何报错。识别成功了,测试有时也通过,而那个 token 对应的是一次根本没有发生过的页面加载。Nightwatch 给了你一个有文档说明的办法,把你自己的代码也放进队列:browser.perform,它的回调在文档里被描述为作为队列的一部分运行的函数。
// RIGHT. perform() queues the callback, so it runs in
// sequence with the commands either side of it.
browser.url("https://example.com/page-with-recaptcha");
browser.perform(async function () {
const { code } = await solver.recaptcha(sitekey, pageUrl);
return code;
});
browser.click("#submit");另一个选择是写成异步测试。把测试函数声明为 async 之后,API 命令会返回 promise,逐个 await 就能在不用 perform 的情况下保持顺序。两种写法都可行。错就错在把它们混着用:一个被 await 的外部 promise 夹在一堆没有 await 的浏览器命令中间,你就又回到了第一个例子。
第 2 步:调高 asyncHookTimeout,否则识别会在十秒时超时
这一条能让人白白搭进去一个下午。perform 内部的异步执行受 asyncHookTimeout 这个全局配置限制,而它的默认值是 10000 毫秒。一次 reCAPTCHA 识别通常要十五到四十五秒。于是回调在识别工具还在干活的时候就被杀掉了,你拿到的报错说的是超时,而不是验证码。
在你的 Nightwatch 配置里调高它,全局设置或按环境设置都可以。
// nightwatch.conf.js
module.exports = {
globals: {
// Default is 10000, which is shorter than most solves.
asyncHookTimeout: 120000,
// waitFor commands default to 5000. The widget is not
// the slow part, but give it room on a cold CI runner.
waitForConditionTimeout: 15000,
},
};客户端这边要设在它之下,这样才能明确知道哪个上限真正生效。Node 客户端有自己的轮询节奏,从 250 毫秒开始,逐步回退到 pollingInterval,这也是它通常比手写轮询更早返回的原因。
// npm install capskip
const { CapSkip } = require("capskip");
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST || "127.0.0.1",
port: 8080,
// Seconds. Keep this under the 120s asyncHookTimeout above.
recaptchaTimeout: 90,
pollingInterval: 3,
});第 3 步:用 execute 注入 token,而不是 setValue
reCAPTCHA 读取的那个响应字段是一个隐藏的 textarea。WebDriver 不会去操作它认为不可交互的元素,所以对它调用 setValue 会报 element not interactable 错误。通行做法是通过页面脚本注入,Nightwatch 把这个能力暴露为 execute,它接收一个函数体、一个参数数组和一个可选回调。
browser.perform(async function () {
const { code } = await solver.recaptcha(sitekey, pageUrl);
// The function is serialized and run in the page, so it
// cannot close over anything. Pass values in the array.
await browser.execute(
function (token) {
document.getElementById("g-recaptcha-response").value = token;
},
[code]
);
});如果表单不是在提交时读取该字段,而是在完成时调用一个回调,那就在同一段脚本里把它调用掉。隐形 reCAPTCHA 几乎总是这种方式,而不同的小组件变体只改变你传入的选项:invisible 或 enterprise 设为 1,version 设为 v3 并带上 action,或者用 turnstile、geetest 代替 recaptcha。完整的参数列表见 Node.js 验证码识别页面.
第 4 步:完整的测试
客户端在模块作用域里只构造一次,放在测试之外,这样就不会每个用例都重建一遍。sitekey 从页面上读取而不是硬编码,正是这一点让同一个 spec 既能跑预发布环境也能跑生产环境。
// npm install capskip
const { CapSkip } = require("capskip");
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST || "127.0.0.1",
port: 8080,
recaptchaTimeout: 90,
});
const PAGE = "https://example.com/page-with-recaptcha";
describe("signup", function () {
it("submits through the reCAPTCHA", async function (browser) {
await browser.url(PAGE);
await browser.waitForElementPresent(".g-recaptcha", 15000);
// Read the sitekey off the widget that is actually there.
const sitekey = await browser.getAttribute(
".g-recaptcha",
"data-sitekey"
);
await browser.perform(async function () {
const { code } = await solver.recaptcha(sitekey.value, PAGE);
await browser.execute(
function (token) {
document.getElementById("g-recaptcha-response").value = token;
},
[code]
);
});
// Submit straight after. The token is not a long-lived value.
await browser.click("#submit");
await browser.assert.textContains(".result", "Thanks");
});
});识别要尽量晚做,做完立刻提交。一个 reCAPTCHA token 的有效期大约两分钟,如果套件在 before 钩子里识别,过了三个用例才提交,那提交的就是一个已经过期的 token。这个有效期窗口值得读一遍: reCAPTCHA token 能维持多久.
第 5 步:在 CI 上运行,以及这需要哪种连接模式
Nightwatch 测试很少一直待在写出它们的那台机器上。一旦挪到 CI runner、容器或 Selenium Grid 节点上,回环地址就不再指向你桌上那台电脑了。连接模式有两种。本地模式绑定 127.0.0.1,只响应本机。服务器模式绑定你的内网地址或公网 IP,这样 CI runner、容器或 Grid 节点就能通过 API 访问到同一台 Windows 机器。两种模式都位于同一处: 连接设置,而服务器模式只改变识别工具监听哪个地址。它依然是你自己的硬件,也依然不按次计费。
| 测试进程跑在哪里 | 用哪种连接模式 |
|---|---|
| 你自己的机器,本地 chromedriver | Local 模式。127.0.0.1 在这里确实是对的 |
| 你内网里的一台构建代理 | Server mode,填识别工具的内网地址 |
| 托管的 CI runner | Server mode,配一个固定公网 IP 加一条防火墙规则 |
| 容器,识别工具在宿主机上 | Server 模式。容器内部的回环地址指向的就是容器本身 |
在 Grid 上有一个区分常常把人绊住:调用识别工具的是测试进程,不是浏览器。所以真正重要的地址是 Node 进程能访问到的那个,Grid 节点的网络状况与它无关。这种拆分的完整讲解见 Selenium Grid 指南,而两者底层共同依赖的驱动层则见 Selenium 验证码识别页面.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 识别的日志出现在页面跳转之前 | 该调用在队列之外,因此在队列构建期间就会执行 | 把它包进 browser.perform 里 |
| 几乎正好在十秒时超时 | asyncHookTimeout 还是默认的 10000 | 在 globals 里调高它,设得比客户端超时更大 |
| 响应字段报 element not interactable | 它是一个隐藏的 textarea,WebDriver 不会往里输入 | 用 browser.execute 给它赋值 |
| 测试通过了,token 却被拒绝 | 它在识别和提交之间过期了 | 在提交前一刻识别,不要放在钩子里 |
| 套件挪到 CI 上之后出现 NetworkException | 识别工具不在 runner 上 | 用服务器模式,并在 runner 上设置 CAPSKIP_HOST |
| ApiException 里带着 ERROR_GOOGLEKEY | sitekey 取自错误的小组件,或者取自 iframe 的 URL | 从你要识别的那个元素上读取 data-sitekey |
| 手写轮询时返回 CAPCHA_NOT_READY | 结果还没完成就被读取了 | 交给客户端轮询,它会自己退避 |
最后那个响应就是这么拼的,少掉的那个字母不是我们这边的笔误,因为 API 返回的确实就是这个写法。完整解释见 一篇关于 CAPCHA_NOT_READY 响应的完整说明.
常见问题
做这件事需要专门的 Nightwatch 插件吗?
不需要。识别工具就是一个普通的 Node 包,在 spec 文件里 require 进来就行,既不用注册自定义命令,也不用往 plugins 数组里加任何东西。如果你真想写一个,唯一值得封装的是 perform 加 execute 这一对,大约八行代码,能省掉在各个 spec 里重复写。
我可以在全局 before 钩子里识别一次,然后复用这个 token 吗?
不行,有两个各自独立的原因。token 大约两分钟就过期,稍有规模的套件都会跑得比它久。而且它和产生它的那次页面加载绑定,所以第二个用例重新加载页面时需要它自己的 token。每个用例各识别一次,并且尽量放在用例靠后的位置。由于识别工具不按次计费,这样做除了多花几秒真实时间之外没有任何代价。
我的测试是并行跑的,这会有什么影响吗?
只影响数量。每个 worker 有自己的队列和自己的识别调用,所以四个 worker 就是四个并发识别。不需要做任何协调,也不会有请求排在一个共享余额后面等着,因为上限是跑 CapSkip 的那台机器,而不是点数余额。如果机器状态不佳还要同时处理四个,就把 asyncHookTimeout 再调高一点。
这和在 WebdriverIO 里做有什么不同?
WebdriverIO 的命令就在你写下它们的位置解析,所以夹在两条命令之间的识别调用不需要任何额外处理就落在正确的位置。Nightwatch 是排队执行的,这正是 perform 存在的原因,也是本文专门用一节讲它的原因。拿到 token 之后的部分,两边完全一样。WebdriverIO 版本的完整做法见 WebdriverIO 指南.
简短版结论
把识别放进 browser.perform,因为队列之外的任何代码都会在队列被构建的时候执行,而不是在你以为的时刻执行。把 asyncHookTimeout 调到高于客户端自身的超时,因为 10000 这个默认值比一次识别还短。用 execute 注入 token,因为响应字段是隐藏的。识别完立刻提交。只要测试不在识别工具自己那台机器上,就从环境变量读取 CAPSKIP_HOST,并让 CapSkip 以服务器模式运行。
- 客户端背后的原始端点,文档见 CapSkip API 文档.
- 勾选框挑战本身的说明,请见 reCAPTCHA v2 识别页面.
在给套件里每个 spec 都加上识别之前,有一点值得先知道:CapSkip 是一款 本地验证码识别工具,所以一次跑两百次识别的测试,和只识别一次的测试,花费完全一样。
