如何在 Appium 测试中识别验证码(Python 客户端)

appium captcha - How to Solve CAPTCHAs in Appium Tests (Python Client)

Appium 里的验证码环节,往往就是一次移动端运行停下来等人的地方。其实不必如此。Appium 本来就能对承载挑战的元素截图,而 CapSkip 跑在你自己的机器上并给出答案,于是测试把结果输入进去,继续往下走。麻烦的地方不是识别,而是会话:Appium 会结束一个安静下来的会话,而识别恰恰就是它不喜欢的那种安静。本指南用 Python 覆盖你会遇到的两种形态:原生图片字段,以及 WebView 里的验证码。

你需要什么

  • CapSkip 运行在一台 Windows 机器上,并且已经打开 API 服务器。
  • Appium 2 和一个可用的驱动,Android 用 UiAutomator2,iOS 用 XCUITest,外加一台你已经能驱动的设备或模拟器。
  • Python 3.10 或更高版本,已安装 Appium 客户端和 CapSkip 包。
  • 识别工具的地址。本地模式只在 127.0.0.1 上应答,所以只有运行在 CapSkip 那台机器上的代码才能访问它;服务器模式则监听你的网络地址或公网 IP,构建代理或 CI 运行器也能访问。第 4 步会说明该用哪一种,两者都位于 连接设置.
# pip install Appium-Python-Client capskip
pip install Appium-Python-Client capskip

第 1 步:给会话留出等待的余地

先做这件事,因为它是最浪费时间的那种故障。Appium 为每个会话维护一个空闲计时器,名为 newCommandTimeout。它默认是 60 秒,窗口内没有新命令到达时,服务器就判定客户端已经离开并结束会话。之后的每一次调用都会对着一个不存在的会话失败。

一次识别就是命令流里的一段空白。你的 Python 代码是在和 CapSkip 说话,不是和 Appium 说话,所以在整个识别期间驱动都闲着。把两边的时钟放在一起比一比,问题就很明显了。

计时器默认值它的适用范围
Appium newCommandTimeout60 秒每个会话中两条驱动命令之间的空闲时间
CapSkip defaultTimeout120 秒图片验证码和 ALTCHA 的轮询
CapSkip recaptchaTimeout300 秒reCAPTCHA、Turnstile 与极验(GeeTest)的轮询

图片验证码通常回得够快,没人会注意到。繁忙的识别工具上的 reCAPTCHA 就不一样了,而客户端愿意等的时间是 Appium 的五倍。把空闲计时器调到超过你愿意等待的最长识别时间。

# pip install Appium-Python-Client capskip
from appium import webdriver
from appium.options.android import UiAutomator2Options

options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"

# Default is 60 seconds. A reCAPTCHA solve can outlast that.
options.new_command_timeout = 300

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

这个属性写的是 appium:newCommandTimeout capability,所以如果某个驱动或客户端没有用友好的名字暴露它,用 set_capability 传同样的值即可。在 iOS 上类是 XCUITestOptions,capability 完全相同,因为每个驱动都是从 Appium 的共享基类驱动继承这一项,而不是各自实现。另外也要注意服务器地址:Appium 2 直接在裸端口上提供服务,后面没有路径。

第 2 步:识别原生图片验证码

这是移动应用里最常见的形态:一个装着扭曲文字的 ImageView,下面一个文本框。Appium 可以对单个元素截图并以 base64 返回,而这恰好就是图片方法接受的三种输入形式之一,所以完全不需要碰磁盘。

from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

# Appium crops the element out of a device screenshot for you.
image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)

field = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_input")
field.send_keys(result["code"])

code 键里放的是识别出来的文字。图片方法也接受文件路径或远程 URL,所以如果某一步已经保存了截图,你可以改传路径,但 base64 这条路能避免在测试运行中产生临时文件,事后也更好清理。

在围绕这个方法搭东西之前,有两点值得先知道。它不支持代理,这在这里没关系,因为图片根本不会离开你的机器。另外它按 120 秒的默认超时轮询,而不是更长的 reCAPTCHA 那个,因为这里不涉及浏览器会话。

对准图片元素,而不是整个屏幕。整屏截图里验证码只占一角,等于交给识别工具一个手机界面去读,得到的错误答案看起来像识别不准,其实是裁剪不对。如果你能找到的元素是一个带内边距和标签的容器,那就去找里面那个视图,否则多出来的像素会拖低准确率。

第 3 步:识别 WebView 里的 reCAPTCHA

另一种形态是登录或注册界面,它其实是 WebView 里的一个网页。这里没有图片要读,所以切到 Web 上下文,像在浏览器里那样操作 DOM。

# contexts looks like ['NATIVE_APP', 'WEBVIEW_com.example.app']
web = [c for c in driver.contexts if c.startswith("WEBVIEW")][0]
driver.switch_to.context(web)

# Narrow to g-recaptcha: hCaptcha also carries data-sitekey.
sitekey = driver.find_element(
    AppiumBy.CSS_SELECTOR,
    ".g-recaptcha[data-sitekey]").get_attribute("data-sitekey")

result = solver.recaptcha(sitekey=sitekey, url=driver.current_url)

driver.execute_script(
    "document.getElementById('g-recaptcha-response').value = arguments[0];",
    result["code"],
)
driver.switch_to.context("NATIVE_APP")

在调用 reCAPTCHA 方法之前,先确认小组件到底是什么。hCaptcha 的小组件上也有 data-sitekey,而它不是受支持的类型,所以一个光秃秃的属性选择器会毫不犹豫地把错误的 key 交给你。去找 g-recaptcha 类,或者通过检查是否存在 h-captcha 类或 js.hcaptcha.com 脚本来排除 hCaptcha。WebView 登录界面正是常见的遇到它的地方。FunCaptcha 和 Arkose 同样不受支持。

从驱动读取页面 URL,不要写死。WebView 加载的 URL 常常在查询串里带着会话或返回路径,而识别结果是与请求它的那个页面绑定的,所以猜出来的 URL 会产生一个站点不认的 token。

在正常提交的表单上,填好响应字段就够了。但在等待 reCAPTCHA 回调的页面上不够,那种页面的提交按钮接的是小组件的回调,而不是表单。那种情况还需要把回调也调用一次,而这是它自己的问题,不是移动端的问题: 回调验证码识别页面 说明了该注意什么。再次操作原生按钮之前要切回原生上下文,否则下一次 find_element 会去 DOM 里搜索并失败。

如果 contexts 列表始终只显示 NATIVE_APP,说明这个 WebView 不可调试。在 Android 上这是应用侧由开发者控制的设置,所以在认定 Appium 有问题之前,值得先去和他们确认。

第 4 步:识别工具跑在哪里,以及这需要哪种连接模式

这一点在移动端上常被人弄反,所以值得说白一些。识别工具是被你的 Python 测试代码调用的。手机不会调用它,模拟器不会调用它,Appium 服务器也不会。所以唯一的问题是你的测试进程跑在哪里,设备自身的网络与此毫无关系。

这意味着关于 Android 模拟器如何访问宿主机的那些常见建议在这里都不相干,远程 Appium 服务器的地址同样不相干。真正要紧的事更简单:如果跑测试的那个进程在 CapSkip 机器上,回环地址就是对的。如果它在别的任何地方,就不对,第一次识别就会抛出 NetworkException。

测试进程跑在哪里用哪种连接模式
你的笔记本,上面开着 CapSkipLocal 模式。127.0.0.1 在这里确实是对的
你的笔记本,驱动远程 Appium 服务器或设备云仍然是本地模式。出去的只有驱动调用
同一网络上的构建代理服务器模式,用 CapSkip 机器的内网地址
托管的 CI 运行器或容器Server mode,配一个固定公网 IP 加一条防火墙规则

把 CapSkip 切到服务器模式,它就改为监听你的网络地址或公网 IP,而不是回环地址,于是上面这些都能通过同一套 HTTP API 访问它。当链路跨公网时,建议使用静态公网 IP,并配一条只放行你预期地址的防火墙规则。服务器模式改变的只是识别工具监听的位置,别的什么都没变:它仍然是你自己的硬件,仍然不计量。从环境变量里读取主机和端口,这样同一套测试在两种场合都能跑。客户端不会自己读取 CAPSKIP_HOST 或 CAPSKIP_PORT,所以要把它们传给构造函数,就像下面的完整示例那样。

完整可运行示例

import os
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip, ApiException, NetworkException, TimeoutException

solver = CapSkip(
    host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)

options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"
options.new_command_timeout = 300      # must outlast the longest solve

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

try:
    image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
    result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)

    driver.find_element(
        AppiumBy.ID, "com.example.app:id/captcha_input").send_keys(result["code"])
    driver.find_element(AppiumBy.ACCESSIBILITY_ID, "Submit").click()
except ApiException:
    print("the solver refused the image")
except NetworkException:
    print("solver unreachable: check the host and the connection mode")
except TimeoutException:
    print("no answer inside defaultTimeout")
finally:
    driver.quit()

这四种异常都派生自 CapSkipError,所以改为捕获它,就能在一个代码块里处理 SDK 可能抛出的每一种失败。响应处理方式不同时,像上面那样分别捕获;不需要区分时就捕获 CapSkipError。把 driver.quit 放在 finally 块里:否则一个在识别中途挂掉的测试会留下一个会话占着设备,直到你刚刚调大的那个空闲计时器终于到期。

其余类型在同一个客户端上的用法是一样的。Turnstile 需要 sitekey 和页面 URL,极验(GeeTest)需要 gt 值、challenge 和页面 URL,ALTCHA 需要页面 URL 和一个挑战端点。该包提供的全部方法见 Python 验证码识别页面,而图片类型另有 一个专门的页面.

常见错误及其含义

你所看到的原因修复
一次慢识别之后会话就没了,之后每条命令都失败你的代码在等识别工具的时候 newCommandTimeout 到期了把它调到超过最长的那次识别,而不只是超过平均值
第一次识别时抛出 NetworkExceptionCapSkip 没有运行,或者测试进程在构建代理上却指向回环地址启动 CapSkip,然后在本地模式和服务器模式之间做选择
图片识别上出现提示 120 秒的 TimeoutException图片类型用的是默认轮询超时,不是更长的 reCAPTCHA 那个先确认识别工具在运行且没有被压满,再去调大任何数值
图片很清楚,答案却每次都是错的整屏截图,或者元素里包含了内边距和标签对只装着验证码的最内层视图截图
提到 base64 或文件缺失的 ValidationException元素截图返回的是空的,所以字符串太短,无法当作图片读取确认截图之前元素在屏幕上且可见,并且查找确实匹配到了它
reCAPTCHA 方法返回的 token 每次都被站点拒绝这个小组件是 hCaptcha,它同样带 data-sitekey,而且不是受支持的类型把选择器收紧到 g-recaptcha 类,并确认页面加载的到底是哪个小组件
contexts 列表里只有 NATIVE_APPWebView 不可调试,所以 Appium 无法附加上去请应用团队在你要测的那个构建里打开 WebView 调试
WebView 步骤之后紧接着 find_element 就失败驱动仍然在 Web 上下文里,正在 DOM 中搜索操作原生元素之前切回 NATIVE_APP
reCAPTCHA 字段填好了,按钮却没有反应页面在等小组件的回调,而不是在读那个字段把回调也调用一次,或者直接提交表单
识别工具返回的 token 被站点拒绝传给识别工具的页面 URL 是猜的,而不是从 WebView 里读出来的在 Web 上下文里传 driver.current_url

常见问题

手机或模拟器需要能访问到识别工具吗?

不需要,而这是关于这套配置最有用的一点。对 CapSkip 的 HTTP 调用是你的 Python 进程发出的,所以设备看到的永远只有一个截图请求和一次 send_keys。手机上不用装任何东西,应用的流量也不会被重定向,模拟器自己的宿主机地址更是完全用不上。从识别工具的角度看,插着数据线的真机、模拟器和云端设备表现完全一致。

在 CI 里跑的 Appium 测试能用 CapSkip 吗?

可以,用服务器模式。托管运行器看不到你的回环地址,所以在连接设置里把 CapSkip 改为监听你的网络地址或公网 IP,再把主机环境变量指向它。链路跨公网时请使用静态公网 IP,并用防火墙规则限制访问。无论哪种情况,识别工具都留在你自己的硬件上,所以测试搬离你的办公桌之后,授权和识别次数都不会有任何变化。

这些内容只适用于 Android 吗?

不是。把 UiAutomator2Options 换成 XCUITestOptions,改从 appium.options.ios 导入,整个运行的形态完全一样,因为元素截图、上下文切换和空闲计时器都位于驱动之上。只有定位方式会变,因为 iOS 没有 resource id:应用设置了 accessibility id 的地方就用它,没设置的地方就用 predicate 或 class chain。识别工具永远不会知道一张图片是从哪个平台交过来的。

我该识别验证码,还是在测试里把它关掉?

如果这是你自己的应用而且你能关,那就关掉。一个跳过这项检查的测试构建,或者一个永远通过的厂商测试 key,比任何识别都更快、更确定,还能从你的测试套件里去掉一个依赖。当你控制不了那个界面时,识别才有它的价值:流程里嵌着的第三方登录、合作方的注册表单、没人会为你改的预发布环境,或者在生产环境上跑的设备云测试。这些情况下的选择只有识别工具或者人。

简短版结论

先把 newCommandTimeout 调大,再写别的任何东西,因为默认的 60 秒比识别工具被允许花的时间还短,而会话在测试中途死掉看起来完全像是另一个 bug。对元素截图,而不是对屏幕截图,把它作为 base64 data URI 传出去,再用 send_keys 把结果输回去。对于 WebView,切换上下文,从 DOM 里读出 sitekey 和当前 URL,填好响应字段,然后切回去。测试进程一旦不再和识别工具共用一台机器,就立刻切到服务器模式。

最后还有一点,它决定了你怎么写重试。因为这个 验证码识别工具 运行在你自己已有的硬件上,对一张裁剪不佳的图片再试一次不花任何成本,所以测试完全可以重新截一张更干净的图再试一次,而不是让整次运行失败。