Как решать капчи в тестах Appium (клиент на Python)

Шаг с капчей в Appium обычно становится точкой, где мобильный прогон останавливается и ждёт человека. Так быть не обязано. Appium уже умеет снимать скриншот того элемента, который держит задачу, а CapSkip работает на вашей собственной машине и отвечает на неё, так что тест вводит результат и идёт дальше. Неудобство здесь не в решении, а в сессии: Appium закрывает сессию, которая замолчала, а решение как раз и есть та самая тишина, которую он не любит. В этом руководстве разбираются обе формы, с которыми вы столкнётесь, нативное поле с картинкой и капча внутри WebView, на Python.
Что понадобится
- CapSkip, запущенный на машине с Windows, с включённым сервером API.
- Appium 2 и рабочий драйвер, UiAutomator2 для Android или XCUITest для iOS, плюс устройство или эмулятор, которым вы уже умеете управлять.
- Python 3.10 или новее с установленными клиентом Appium и пакетом CapSkip.
- Адрес решателя. Local mode отвечает на 127.0.0.1, поэтому до него дотянется только код, работающий на самой машине с CapSkip, а Server mode слушает ваш сетевой адрес или публичный 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)Это свойство записывает capability appium:newCommandTimeout, поэтому драйвер или клиент, который не выставляет её под удобным именем, примет то же значение через 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: решите reCAPTCHA внутри WebView
Вторая форма выглядит как экран входа или регистрации, который на самом деле представляет собой веб-страницу в WebView. Картинки, которую надо прочитать, здесь нет, поэтому переключитесь в веб-контекст и работайте с 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, а поддерживаемым типом не является, поэтому голый селектор по атрибуту с удовольствием отдаст вам не тот ключ. Ищите класс g-recaptcha или исключайте hCaptcha, проверяя класс h-captcha либо скрипт js.hcaptcha.com. Вход через WebView как раз и есть одно из тех мест, где с ним легко встретиться. FunCaptcha и Arkose тоже не поддерживаются.
Читайте URL страницы из драйвера, а не прописывайте его в коде. WebView часто загружает URL с сессией или обратным путём в строке запроса, а решение привязано к той странице, для которой его запрашивали, поэтому угаданный URL даёт token, который сайт отклонит.
На форме, которая отправляется обычным образом, заполнить поле ответа достаточно. Этого мало на странице, которая ждёт, что reCAPTCHA позовёт её обратно: так устроены случаи, где кнопка отправки привязана к callback виджета, а не к форме. Там нужно вызвать ещё и сам callback, и это отдельная проблема, не мобильная: страница решения reCAPTCHA с callback рассказывает, на что смотреть. Прежде чем снова трогать нативные кнопки, вернитесь в нативный контекст, иначе следующий find_element полезет искать в DOM и не найдёт ничего.
Если в списке контекстов всё время виден только NATIVE_APP, WebView недоступен для отладки. На Android за это отвечает настройка на стороне приложения, которой управляют разработчики, так что стоит уточнить у них, прежде чем винить Appium.
Шаг 4: где работает решатель и какой режим подключения для этого нужен
Вот часть, которую на мобильных понимают задом наперёд, поэтому скажем прямо. Решателя вызывает ваш тестовый код на Python. Его не вызывает ни телефон, ни эмулятор, ни сервер Appium. Значит, единственный вопрос в том, где выполняется ваш процесс теста, а собственная сеть устройства тут ни при чём.
Это значит, что привычные советы про то, как эмулятор Android достучится до хост-машины, здесь не относятся к делу, как и адрес удалённого сервера Appium. Важно другое, и оно проще: если процесс с вашим тестом работает на машине с CapSkip, loopback верен. Если он где угодно ещё, loopback неверен, и первое же решение выбросит NetworkException.
| Где выполняется тестовый процесс | Какой режим подключения |
|---|---|
| Ваш ноутбук с открытым на нём CapSkip | Режим Local. 127.0.0.1 действительно верен |
| Ваш ноутбук, управляющий удалённым сервером Appium или облаком устройств | По-прежнему Local mode. Наружу уходит только вызов драйвера |
| Сборочный агент в той же сети | Server mode, на внутреннем адресе машины с CapSkip |
| Размещённый CI-раннер или контейнер | Server mode со статическим публичным IP и правилом брандмауэра |
Переключите CapSkip в Server mode, и он начнёт слушать ваш сетевой адрес или публичный IP вместо loopback, так что любой из этих вариантов дотянется до него по тому же HTTP API. Если маршрут идёт через интернет, лучше взять статический публичный IP и правилом файрвола разрешить только те адреса, которые вы ожидаете. Server mode меняет лишь то, где слушает решатель, и ничего больше: железо по-прежнему ваше, и счётчик решений по-прежнему не ведётся. Читайте хост и порт из окружения, чтобы один набор тестов работал в обоих местах. Клиент сам не читает ни 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 не запущен либо процесс теста работает на сборочном агенте и нацелен на loopback | Запустите CapSkip, затем выберите между Local mode и Server mode |
| TimeoutException с упоминанием 120 секунд на картинке | Тип с картинками использует таймаут опроса по умолчанию, а не более длинный таймаут reCAPTCHA | Прежде чем что-то поднимать, проверьте, что решатель запущен и не перегружен |
| Ответ каждый раз неверный, хотя картинка чёткая | Полноэкранный скриншот или элемент, включающий отступы и подпись | Снимайте самое вложенное представление, в котором лежит только капча |
| ValidationException с упоминанием base64 или отсутствующего файла | Скриншот элемента вернулся пустым, поэтому строка оказалась слишком короткой, чтобы прочитать её как картинку | Проверьте, что до скриншота элемент был на экране и виден, и что поиск действительно попал в него |
| Метод для reCAPTCHA возвращает token, который сайт отклоняет каждый раз | Перед вами виджет hCaptcha, который тоже несёт data-sitekey и не является поддерживаемым типом | Сузьте селектор до класса g-recaptcha и убедитесь, какой виджет грузит страница |
| В списке контекстов есть только NATIVE_APP | WebView недоступен для отладки, поэтому Appium не может к нему подключиться | Попросите команду приложения включить отладку WebView в той сборке, которую вы тестируете |
| find_element падает сразу после шага с WebView | Драйвер всё ещё находится в веб-контексте и ищет в DOM | Возвращайтесь в NATIVE_APP, прежде чем трогать нативные элементы |
| Поле reCAPTCHA заполнено, но кнопка ничего не делает | Страница ждёт callback виджета, а не читает поле | Вызовите ещё и callback или отправьте форму напрямую |
| Сайт отклоняет token, который вернул решатель | URL страницы, переданный решателю, был угадан, а не прочитан из WebView | Передавайте driver.current_url изнутри веб-контекста |
FAQ
Нужно ли телефону или эмулятору дотягиваться до решателя?
Нет, и это самое полезное, что стоит понять про эту схему. HTTP-вызов к CapSkip делает ваш процесс на Python, поэтому устройство видит только запрос скриншота и send_keys. На телефон ничего ставить не надо, трафик приложения никуда не перенаправляется, а собственный хост-адрес эмулятора здесь вообще ни при чём. Реальное устройство на проводе, эмулятор и облачное устройство ведут себя с точки зрения решателя одинаково.
Могут ли тесты Appium в CI пользоваться CapSkip?
Да, через Server mode. Размещённый раннер не видит ваш адрес loopback, поэтому переключите CapSkip на прослушивание вашего сетевого адреса или публичного IP в настройках подключения и нацельте на него переменную окружения с хостом. Если маршрут идёт через интернет, возьмите статический публичный IP и ограничьте его правилом файрвола. Решатель во всех случаях остаётся на вашем железе, поэтому ни лицензия, ни количество решений не меняются от того, что тесты уехали с вашего стола.
Есть ли здесь что-то только для Android?
Нет. Замените UiAutomator2Options на XCUITestOptions, импортированный из appium.options.ios, и форма прогона останется прежней, потому что скриншоты элементов, переключение контекста и таймер простоя живут выше драйвера. Меняются только локаторы, ведь у iOS нет resource id: используйте accessibility id там, где приложение его задаёт, и предикат или цепочку классов там, где не задаёт. Решатель никогда не узнаёт, с какой платформы ему передали картинку.
Решать капчу или отключить её для тестов?
Отключайте, если приложение ваше и вы можете это сделать. Тестовая сборка, которая пропускает проверку, или тестовый ключ провайдера, который всегда проходит, быстрее и предсказуемее любого решения и убирает из набора тестов лишнюю зависимость. Решение оправдывает себя там, где экран вам не подчиняется: сторонний вход внутри вашего сценария, форма регистрации партнёра, стенд, который ради вас никто не поменяет, или прогон в облаке устройств против продакшена. Вот в этих случаях выбор стоит между решателем и человеком.
Коротко
Поднимите newCommandTimeout раньше, чем напишете что-либо ещё, потому что 60 секунд по умолчанию короче того, сколько разрешено думать решателю, а смерть сессии посреди теста выглядит как совершенно другой баг. Снимайте скриншот элемента, а не экрана, передавайте его как data URI в base64 и вводите результат обратно через send_keys. Для WebView переключите контекст, прочитайте sitekey и текущий URL из DOM, заполните поле ответа, затем переключитесь назад. Переходите в Server mode в тот момент, когда процесс теста перестаёт делить машину с решателем.
- Какой тип капчи стоит за нативным приложением и как его читают: страница сервиса распознавания капч-картинок.
- Всё, что случай с WebView разделяет со случаем в браузере: страница решения reCAPTCHA.
И последнее, что определяет, как вы пишете повторную попытку. Поскольку это распознавание капчи работает на железе, которое у вас уже есть, вторая попытка на плохо обрезанной картинке не стоит ничего, поэтому тест вполне может сделать более чистый скриншот и попробовать снова, а не валить весь прогон.
