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

solve altcha in selenium - How to Solve ALTCHA in Selenium Without Network Interception

Чтобы решить 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 и v2challengeurl для эндпоинта плюс отдельный атрибут 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 или узлом GridLocal mode. Браузер с решателем не общается никогда
На другой машине в той же сетиРежим Server, на приватном адресе машины с решателем
В контейнере, на раннере CI или на VPSServer 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 выглядит ровно как неверный ответ.

И последнее, что меняет вашу логику повторов. Дело в том, что безлимитный сервис распознавания капчи считает доказательство работы на железе, которым вы и так владеете, поэтому выброшенный challenge стоит вам нескольких миллисекунд собственного CPU и ничего больше, так что перезагрузить страницу ради свежего challenge всегда дешевле, чем тащить устаревший token через длинный тест.