Как решить ALTCHA в Selenium без перехвата сетевых запросов

Чтобы решить ALTCHA в Selenium, ждать завершения виджета не нужно вообще. ALTCHA построена на доказательстве работы, а не на распознавании: сайт выдаёт задачу на хеширование, и клиент обязан найти число, которое ей удовлетворяет, прежде чем его пропустят. Смотреть здесь не на что и нажимать нечего, поэтому браузер нужен остальной части сценария, а не капче. Одновызовного аналога ожидания ответа из Playwright в Selenium нет, и это один из редких случаев, когда потеря нулевая: виджет сам называет свой эндпоинт challenge в атрибуте, а больше решателю ничего и не нужно.
Что понадобится
- CapSkip 1.2.6 или новее, запущенный на машине с Windows. Поддержка ALTCHA появилась именно в этом релизе, поэтому в более старой сборке вызывать просто нечего.
- Python 3.10 или новее с установленными пакетами Selenium и CapSkip, плюс подходящий браузер.
- URL страницы, на которой стоит виджет. Никакого sitekey здесь нет, потому что у ALTCHA его не бывает.
- Адрес решателя. Режим Local отвечает на 127.0.0.1 только для этого устройства, а режим Server слушает ваш сетевой адрес или публичный IP, чтобы до него дотянулись узел Grid, раннер CI или другая машина по тому же API. Шаг 5 объясняет, какой из них ваш, и оба задаются в одном месте: Настройки подключения.
# pip install selenium capskip pip install selenium capskip
Шаг 1: считываем эндпоинт challenge с виджета
Элемент виджета сам называет эндпоинт, у которого он попросит свой challenge. Какой атрибут его хранит, зависит от поколения виджета, поэтому читайте исходный код страницы, а не угадывайте.
| Поколение виджета | Атрибут, который задаёт challenge |
|---|---|
| v1 и v2 | challengeurl для эндпоинта плюс отдельный атрибут challengejson, когда challenge встроен в страницу |
| v3 и новее | challenge, причём этот единственный атрибут принимает либо URL, либо сами данные challenge |
Здесь важны две привычки Selenium, и обе неочевидны. Selenium не ждёт элемент автоматически, поэтому запрос виджета до того, как страница положила его в DOM, даёт NoSuchElementException, а не повторную попытку. И обычный вызов атрибута устроен гибридно: сначала он пробует одноимённое свойство JavaScript и только потом откатывается к разметке. Веб-компонент определяет свойства под собственные атрибуты, поэтому результат зависит от сборки виджета, а не от того HTML, который вы видите. У вызова DOM-атрибута такого отката нет, и он возвращает ровно то, что написано в разметке, а это вам и нужно. Справочник Python API разбирает эту разницу здесь: страница WebElement.
# pip install selenium capskip
from urllib.parse import urljoin
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
PAGE_URL = "https://example.com/signup"
driver = webdriver.Chrome()
driver.get(PAGE_URL)
# Nothing auto-waits in Selenium, so wait for the element.
widget = WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
)
# v1 and v2 use challengeurl or challengejson; v3 uses challenge.
value = (widget.get_dom_attribute("challengeurl")
or widget.get_dom_attribute("challengejson")
or widget.get_dom_attribute("challenge"))
if value is None:
raise SystemExit("no challenge attribute on the widget")Два из этих трёх атрибутов отдают вам сам документ, а не указатель на него, поэтому проверьте, что именно пришло, прежде чем считать это URL. Для URL нужен ещё один отдельный шаг: в разметке обычно лежит путь того же origin, например ведущий слеш и имя папки, а HTTP-клиент такое сам не заберёт. Сначала разрешите его относительно URL страницы.
import json
challenge, endpoint = None, None
if value.lstrip().startswith("{"):
# challengejson, or a v3 challenge holding the data inline.
challenge = json.loads(value)
else:
# A path in the markup becomes an absolute URL here.
endpoint = urljoin(PAGE_URL, value)Три стиля взаимодействия в атрибуте type у виджета, native, checkbox и switch, чисто визуальны и до решателя не доходят, и отдельный атрибут display визуален ровно так же. ALTCHA описывает их все здесь: собственном руководстве по виджету.
Шаг 2: забираем challenge сессией браузера
Этот шаг можно пропустить всегда, когда эндпоинт публичный, а таких немало. Передайте URL в CapSkip, и он заберёт challenge сам. Шаг существует потому, что некоторые сайты привязывают challenge к той сессии, которая его запросила, а решатель обращается с вашей машины и без cookie, так что полученное им не принадлежит никому. В итоге ответ верный, а сайт всё равно его отклоняет.
Лечится это так: заберите challenge внутри сессии браузера и передайте документ вместо указателя. Скопируйте cookie, которые Selenium уже держит, в HTTP-клиент, перенесите тот же user agent и запросите challenge сами.
import requests
if endpoint:
session = requests.Session()
for cookie in driver.get_cookies():
session.cookies.set(cookie["name"], cookie["value"],
domain=cookie["domain"])
session.headers["User-Agent"] = driver.execute_script(
"return navigator.userAgent")
# Referer matters on sites that check where the ask came from.
challenge = session.get(endpoint,
headers={"Referer": PAGE_URL}).json()Ошибка разбора JSON на этой строке полезна. Она означает, что эндпоинт ответил HTML, обычно страницей входа или окном согласия, а значит запрос ушёл от чужого и этот шаг был нужен. Запускайте решение сразу после загрузки. Окно короткое, и отсчёт начинается в момент, когда сайт выдал challenge, а не когда вы до него добрались.
Шаг 3: передаём challenge единственному методу ALTCHA
Метод всего один, и полностью его описывает страница решения ALTCHA. Этот метод принимает challenge в любом виде. Передайте документ, и запрос вообще не уйдёт. Передайте эндпоинт, и решатель заберёт его сам, включая свежий, если задача пролежала в очереди достаточно долго и первый успел истечь.
from capskip import CapSkip solver = CapSkip(host="127.0.0.1", port=8080) # The document from step 2, so nothing is fetched twice. result = solver.altcha(url=PAGE_URL, challenge_json=challenge) # Or hand over the endpoint and let CapSkip fetch it. # result = solver.altcha(url=PAGE_URL, challenge_url=endpoint) print(result["token"]) # base64 payload for the form field print(result["number"]) # the counter that satisfied it
Два ключа в этом результате существуют только ради ALTCHA. Ключ token держит полезную нагрузку в base64, которую ждёт форма, а ключ number держит счётчик, решивший задачу. Ключ code несёт ту же строку, что и token, поэтому подойдёт любой, но ключ token назван по тому полю, куда он и отправляется. Ключей GeeTest и user agent для Turnstile здесь нет.
Какие алгоритмы покрывает решатель
Старая схема закрыта алгоритмами SHA-1, SHA-256, SHA-384 и SHA-512, а доказательство работы v2 закрыто PBKDF2 и итеративным SHA. PBKDF2 стоит по умолчанию и рекомендован самой ALTCHA, а это подавляющее большинство живых сайтов. Argon2id и scrypt составляют исключение: они отклоняются, а не берутся в работу, потому что challenge, который просит любой из них, возвращается примерно за треть секунды как ERROR_CAPTCHA_UNSOLVABLE и никогда не повторяется, ведь функцию, требовательную к памяти, повторной попыткой не победить. На этом типе такой результат указывает на алгоритм, а не на нечитаемую картинку, и у самого кода ошибки есть отдельное руководство.
Шаг 4: пишем token в скрытое поле
Виджет отправляет свою полезную нагрузку в скрытом поле, имя которого берётся из его собственного атрибута name со значением altcha по умолчанию. Читайте этот атрибут, а не угадывайте, ровно так же, как вы читали атрибут с challenge. В этом прогоне виджет никто не проверял, поэтому поле никто и не заполнил, и заполняете его вы сами.
Selenium умеет передавать WebElement прямо в скрипт, поэтому сама кнопка оказывается самым чистым способом найти нужную форму. Передайте кнопку отправки и прочитайте её свойство form: на странице регистрации часто несколько форм, и если добавить поле в первую на странице, когда кнопка принадлежит другой, сервер не увидит значения вообще.
SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = arguments[1];
form.appendChild(field);
}
field.value = arguments[2];
"""
button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
field_name = widget.get_dom_attribute("name") or "altcha"
driver.execute_script(SET_FIELD, button, field_name, result["token"])
button.click()Передавайте строку как есть. Token представляет собой base64 от документа JSON, поля которого покрыты подписью HMAC сервера, поэтому всё, что похоже на наведение порядка, его ломает: обрезка пробелов, декодирование и повторное кодирование или пересборка JSON с другим порядком ключей. Некоторые интеграции читают полезную нагрузку из поля тела JSON, а не из поля формы, а сам виджет можно настроить на доставку через cookie, поэтому посмотрите, что отправляет собственная кнопка отправки страницы, и повторите это.
Если кнопка отправки так и не становится кликабельной, страница держит её на том, что её собственный скрипт услышал успех виджета, а не на значении в поле. Честных ответов здесь два. Найдите, что именно слушает страница, и дайте ей это, либо обойдите кнопку и отправьте поля формы напрямую с теми cookie, которые у браузера уже есть, что обычно короче и всегда стабильнее.
Шаг 5: где работает решатель, когда ваш скрипт переезжает
В примерах стоит 127.0.0.1, потому что это верно, пока ваш скрипт и CapSkip делят одну машину. Решателя вызывает ваш код на Python, а не браузер и не страница, поэтому адрес зависит от того, где идёт тестовый процесс, а не от того, где идёт Chrome. В Selenium это легко перепутать, потому что браузер и так очень часто находится в другом месте.
| Где выполняется процесс Python | Какой режим подключения |
|---|---|
| На машине с CapSkip, управляя локальным браузером | Режим Local. 127.0.0.1 действительно верен |
| На вашей машине, с управлением удалённым WebDriver или узлом Grid | Local mode. Браузер с решателем не общается никогда |
| На другой машине в той же сети | Режим Server, на приватном адресе машины с решателем |
| В контейнере, на раннере CI или на VPS | Server mode со статическим публичным IP и правилом брандмауэра |
Переключите CapSkip в режим Server, и он начнёт слушать ваш сетевой адрес или публичный IP вместо loopback, а раннер подключится по тому же HTTP API, каким пользовался бы локально. Если маршрут пересекает интернет, возьмите статический публичный IP и правило файрвола, которое пропускает только ожидаемые адреса. Режим Server меняет только то, какой адрес слушает решатель: это по-прежнему ваше железо и по-прежнему без платы за каждое решение. У прогона самих тестов через Grid свой набор ловушек, и они разобраны отдельно, в руководстве по Selenium Grid.
Читайте хост и порт из окружения, чтобы один скрипт работал в обоих местах. Клиент сам не читает ни CAPSKIP_HOST, ни CAPSKIP_PORT, поэтому передайте их в конструктор, как это сделано в полном примере ниже.
Одно замечание о прокси, касающееся именно ALTCHA. Прокси здесь поддерживается, но используется только для загрузки задачи. Браузерной сессии, которую можно было бы через него направить, нет, поэтому на само доказательство работы он не влияет, а когда вы передаёте документ задачи прямо в опции, он не делает совсем ничего.
Полный рабочий пример
import json, os, requests
from urllib.parse import urljoin
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
PAGE_URL = "https://example.com/signup"
SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = arguments[1];
form.appendChild(field);
}
field.value = arguments[2];
"""
solver = CapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)
driver = webdriver.Chrome()
try:
driver.get(PAGE_URL)
widget = WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
)
value = (widget.get_dom_attribute("challengeurl")
or widget.get_dom_attribute("challengejson")
or widget.get_dom_attribute("challenge"))
if value is None:
raise SystemExit("no challenge attribute on the widget")
if value.lstrip().startswith("{"):
challenge = json.loads(value)
else:
endpoint = urljoin(PAGE_URL, value)
session = requests.Session()
for cookie in driver.get_cookies():
session.cookies.set(cookie["name"], cookie["value"],
domain=cookie["domain"])
session.headers["User-Agent"] = driver.execute_script(
"return navigator.userAgent")
challenge = session.get(endpoint,
headers={"Referer": PAGE_URL}).json()
try:
result = solver.altcha(url=PAGE_URL, challenge_json=challenge)
except ApiException:
raise SystemExit("refused: Argon2id, scrypt, or an expired challenge")
except NetworkException:
raise SystemExit("solver unreachable: check host and connection mode")
except TimeoutException:
raise SystemExit("no answer inside defaultTimeout")
button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
field_name = widget.get_dom_attribute("name") or "altcha"
driver.find_element(By.NAME, "email").send_keys("[email protected]")
driver.execute_script(SET_FIELD, button, field_name, result["token"])
button.click()
finally:
driver.quit()Все четыре исключения наследуются от CapSkipError, поэтому ловля именно его закрывает любой сбой, который способен поднять SDK, одним блоком. Ловите конкретные, когда реакция разная, как выше, и CapSkipError, когда она одинаковая. Следите, какой именно TimeoutException вы ловите: у CapSkip и у самого Selenium имя одно, поэтому импортируйте одно из них под псевдонимом, если в области видимости оба.
Остальные типы работают так же и из того же клиента. reCAPTCHA и Turnstile принимают sitekey и URL страницы, GeeTest принимает значение gt, challenge и URL страницы, а распознавание картинок принимает путь к файлу, URL или base64. Все методы, доступные в пакете, перечислены на странице сервиса распознавания капч для Python, а всё про браузеры целиком собрано на странице сервиса распознавания капчи для Selenium.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| NoSuchElementException на виджете | Элемент ещё не был вставлен в DOM, что бывает, когда разметку строит скрипт, а Selenium сам не ждёт | Оберните поиск в явное ожидание присутствия элемента |
| Все три чтения атрибутов возвращают None | Виджет настроили целиком из JavaScript, поэтому ни одного из трёх имён в разметке нет | Прочитайте конфигурацию из собственного скрипта страницы или возьмите эндпоинт, который видно на панели сети |
| Пустое значение атрибута там, где в исходном коде страницы оно явно есть | Обычный вызов атрибута вернул свойство, которое определяет веб-компонент, а оно пустое | Используйте вызов DOM-атрибута: он читает разметку и не откатывается к свойству |
| MissingSchema от HTTP-клиента с указанием значения, которое начинается со слеша | В разметке лежит путь того же origin, и вызов DOM-атрибута возвращает его ровно так, как он записан | Разрешите его относительно URL страницы до загрузки и до передачи решателю |
| Значение атрибута начинается с фигурной скобки | Атрибут challengejson либо виджет v3, который несёт сам документ, а не URL | Разберите его как JSON и передайте как встроенный challenge |
| Ошибка разбора JSON при загрузке эндпоинта | Эндпоинт ответил HTML, то есть запрос пришёл без сессии браузера | Перенесите cookie и user agent до загрузки |
| ApiException на задаче, перехваченной мгновение назад | Переданная задача уже истекла, поэтому решатель отклонил её, а не стал хешировать | Загружайте и решайте одним заходом или передайте эндпоинт, чтобы решатель забрал challenge заново, если этот эндпоинт не привязан к сессии |
| ERROR_CAPTCHA_UNSOLVABLE внутри ApiException, примерно через треть секунды | Challenge использует Argon2id или scrypt | Повторять нечего. Эти два отклоняются намеренно |
| Голый отказ в проверке при token, который по логу был решён | Задача истекла между решением и отправкой | Перехватывайте, решайте и отправляйте, не вставляя между ними ничего медленного |
| NetworkException на первом решении | CapSkip не запущен либо скрипт работает в контейнере и нацелен на loopback | Запустите CapSkip, затем выберите между Local mode и Server mode |
| TimeoutException с упоминанием 120 секунд | Решатель не ответил в пределах обычного таймаута опроса, который ALTCHA использует вместо удлинённого таймаута reCAPTCHA | Проверьте, что решатель запущен и не перегружен. Поднятие потолка лишь отложит тот же самый ответ |
| Форма отправляется, но сервер сообщает об отсутствующем значении altcha | Скрытое поле добавили к другой форме на странице | Прочитайте свойство form у кнопки отправки, как это делает пример |
| Кнопка отправки остаётся неактивной | Страница держит её закрытой, пока её собственный скрипт не увидит успех виджета | Отправьте поля формы напрямую или дайте странице то, чего она слушает |
| ValidationException при вызове | Не передана ни одна опция с задачей либо передана опция, которую ALTCHA не принимает | Передайте эндпоинт или документ и уберите всё остальное |
FAQ
Нужен ли вообще браузер, чтобы решить ALTCHA?
Нет. ALTCHA представляет собой задачу на хеширование, поэтому ответ считается на CPU и браузер в ответе не участвует. Если капча была единственной причиной открыть Selenium, закройте его: заберите challenge HTTP-клиентом и отправьте token обратно. Весь этот путь разобран здесь: руководство по ALTCHA на обычном Python во всех подробностях. Selenium оправдывает своё место, когда настоящая страница нужна остальной части сценария: логин, который ставит cookie, многошаговая форма или разметка, которая появляется только после выполнения скрипта.
Почему атрибут виджета возвращается пустым?
Потому что обычный вызов атрибута устроен гибридно. Он возвращает свойство JavaScript, когда элемент его определяет, и только потом откатывается к разметке, а веб-компонент определяет свойства под собственные атрибуты, поэтому пустое свойство побеждает значение, которое видно в исходном коде страницы. У вызова DOM-атрибута такого отката нет, и он возвращает то, что написано в разметке. Вторая причина в том, что вы читаете неверное имя для того поколения, которое перед вами, поэтому попробуйте все три, прежде чем решать, что атрибута нет.
Может ли раннер CI или контейнер достучаться до решателя?
Да, через режим Server. Переключите CapSkip с loopback на ваш сетевой адрес или публичный IP в настройках подключения, а затем направьте на него переменную окружения с хостом. После этого раннер и решатель говорят по тому же HTTP API, что и на одной машине. Если маршрут пересекает интернет, возьмите статический публичный IP и ограничьте его правилом файрвола. С удалённым узлом Grid всё иначе, и там обычно не нужно вообще ничего, потому что узел запускает браузер, а ваш скрипт, который и вызывает решателя, остаётся там, где вы его запустили.
Оборвут ли решение собственные таймауты Selenium?
Нет, потому что решение не является командой WebDriver. Неявное ожидание, таймаут загрузки страницы и таймаут скрипта покрывают вызовы, которые Selenium отправляет браузеру, а ваш вызов решателя представляет собой обычный Python между двумя такими вызовами. Действующий потолок здесь другой: обычный таймаут опроса клиента в 120 секунд, который ALTCHA использует вместо удлинённого таймаута reCAPTCHA, потому что это нагрузка на процессор, а не сессия браузера. Следите лучше за ограничением, обёрнутым вокруг всего теста, например за таймаутом плагина на тест или за лимитом задачи CI.
Коротко
Дождитесь виджета, прочитайте его атрибут с challenge вызовом DOM-атрибута, разрешите путь относительно URL страницы и заберите этот эндпоинт внутри сессии браузера, если сайт привязывает challenge к сессии. Передайте документ или URL единственному методу ALTCHA вместе с URL страницы, затем впишите token в скрытое поле, имя которому даёт собственный атрибут name виджета, в той форме, которой принадлежит кнопка отправки. По дороге token не трогайте. Держите загрузку, решение и отправку рядом, потому что окно может закрыться быстрее двух минут, а истёкший challenge выглядит ровно как неверный ответ.
- Что это за тип капчи и как он работает: страница решения ALTCHA.
- Та же работа вообще без браузера: решение ALTCHA на обычном Python.
И последнее, что меняет вашу логику повторов. Дело в том, что безлимитный сервис распознавания капчи считает доказательство работы на железе, которым вы и так владеете, поэтому выброшенный challenge стоит вам нескольких миллисекунд собственного CPU и ничего больше, так что перезагрузить страницу ради свежего challenge всегда дешевле, чем тащить устаревший token через длинный тест.
