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

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 newCommandTimeout | 60 秒 | 每个会话中两条驱动命令之间的空闲时间 |
| CapSkip defaultTimeout | 120 秒 | 图片验证码和 ALTCHA 的轮询 |
| CapSkip recaptchaTimeout | 300 秒 | 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。
| 测试进程跑在哪里 | 用哪种连接模式 |
|---|---|
| 你的笔记本,上面开着 CapSkip | Local 模式。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 到期了 | 把它调到超过最长的那次识别,而不只是超过平均值 |
| 第一次识别时抛出 NetworkException | CapSkip 没有运行,或者测试进程在构建代理上却指向回环地址 | 启动 CapSkip,然后在本地模式和服务器模式之间做选择 |
| 图片识别上出现提示 120 秒的 TimeoutException | 图片类型用的是默认轮询超时,不是更长的 reCAPTCHA 那个 | 先确认识别工具在运行且没有被压满,再去调大任何数值 |
| 图片很清楚,答案却每次都是错的 | 整屏截图,或者元素里包含了内边距和标签 | 对只装着验证码的最内层视图截图 |
| 提到 base64 或文件缺失的 ValidationException | 元素截图返回的是空的,所以字符串太短,无法当作图片读取 | 确认截图之前元素在屏幕上且可见,并且查找确实匹配到了它 |
| reCAPTCHA 方法返回的 token 每次都被站点拒绝 | 这个小组件是 hCaptcha,它同样带 data-sitekey,而且不是受支持的类型 | 把选择器收紧到 g-recaptcha 类,并确认页面加载的到底是哪个小组件 |
| contexts 列表里只有 NATIVE_APP | WebView 不可调试,所以 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,填好响应字段,然后切回去。测试进程一旦不再和识别工具共用一台机器,就立刻切到服务器模式。
- 原生应用验证码背后的类型,以及它是怎么被读出来的: 图片验证码识别页面.
- WebView 场景与浏览器场景的共同之处: reCAPTCHA 验证码识别页面.
最后还有一点,它决定了你怎么写重试。因为这个 验证码识别工具 运行在你自己已有的硬件上,对一张裁剪不佳的图片再试一次不花任何成本,所以测试完全可以重新截一张更干净的图再试一次,而不是让整次运行失败。
