Как решить ALTCHA в Playwright и заполнить скрытое поле

solve altcha in playwright - How to Solve ALTCHA in Playwright and Fill the Hidden Field

Чтобы решить ALTCHA в Playwright, просить виджет о работе не нужно вообще. ALTCHA построена на доказательстве работы, а не на распознавании: сайт выдаёт задачу на хеширование и пропускает любого, кто ответит верно, а платой служит процессорное время. Смотреть не на что и кликать не по чему, так что браузер здесь нужен для остального сценария, а не для капчи. Перехватите задачу, которую страница уже запросила, посчитайте хеш на своей машине за миллисекунды, затем впишите ответ в поле, которое отправляет форма. В этом руководстве всё это делается в Playwright для Python.

Что понадобится

  • CapSkip 1.2.6 или новее, запущенный на машине с Windows. Поддержка ALTCHA появилась именно в этом релизе, поэтому в более старой сборке вызывать просто нечего.
  • Python 3.10 или новее с установленными пакетами Playwright и CapSkip и хотя бы одним скачанным браузером.
  • URL страницы, на которой стоит виджет. Sitekey не нужен, потому что у ALTCHA его нет.
  • Адрес решателя. Local mode отвечает на 127.0.0.1 только для этого устройства, а Server mode слушает ваш сетевой адрес или публичный IP, чтобы контейнер, CI-раннер или другая машина могли обратиться к нему по тому же API. Какой вариант подходит вам, разбирается в шаге 4, и оба настраиваются в разделе Настройки подключения.
# pip install playwright capskip
pip install playwright capskip
playwright install chromium

Шаг 1: перехватите задачу, пока страница открыта

Всё остальное держится на одном этом значении. Задача представляет собой небольшой документ JSON, в котором лежат алгоритм, хеш задачи, соль, подпись и максимальное число, и сайт его подписывает. Изнутри прогона Playwright достать её можно двумя способами, а какой сработает, зависит от того, как собрана страница.

Считайте эндпоинт с виджета

Элемент виджета сам называет эндпоинт, к которому обратится. Не угадывайте атрибут: между поколениями виджета он менялся.

Поколение виджетаАтрибут, который задаёт challenge
v1 и v2challengeurl для эндпоинта плюс отдельный атрибут challengejson, когда challenge встроен в страницу
v3 и новееchallenge, и этот единственный атрибут принимает либо URL, либо сами данные задачи
# pip install playwright capskip
from playwright.sync_api import sync_playwright

PAGE_URL = "https://example.com/signup"

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(PAGE_URL)

    # v1 and v2 use challengeurl; v3 and later use challenge.
    widget = page.locator("altcha-widget")
    endpoint = widget.get_attribute("challengeurl")
    if not endpoint:
        endpoint = widget.get_attribute("challenge")

Три стиля взаимодействия в атрибуте type у виджета, native, checkbox и switch, различаются только внешне. Они отправляют одну и ту же полезную нагрузку, и до решателя разница не доходит, так что выяснять, на какой из них вы смотрите, не нужно. Отдельный атрибут display точно так же отвечает только за вид. Все их ALTCHA описывает в собственном руководстве по виджету.

Или перехватите ответ, который страница уже получила

Прочитать атрибут не всегда достаточно. Виджет v3 может держать в этом атрибуте сами данные challenge, а не URL, поэтому разбирайте значение как JSON, когда оно начинается с фигурной скобки, а виджет, настроенный целиком из JavaScript, вообще не оставляет в разметке того, что можно прочитать. Перехват сетевого ответа закрывает этот второй случай и отдаёт вам сам документ, а не указатель на него. Взводите ожидание до того, что запускает загрузку, иначе запрос произойдёт, пока никто не слушает.

# Filter out the widget's own script: its URL also contains
# altcha, and it loads before the challenge is ever requested.
is_challenge = lambda r: ("altcha" in r.url
    and "json" in r.headers.get("content-type", ""))

# This fires during navigation only when the widget carries
# auto="onload". Otherwise wrap the click that triggers it.
with page.expect_response(is_challenge) as caught:
    page.goto(PAGE_URL)

challenge = caught.value.json()
print(challenge["algorithm"], challenge["maxnumber"])

Прежде чем полагаться на такой вариант, проверьте у виджета атрибут auto. Он решает, когда начинается проверка, и только значение onload отправляет запрос во время навигации. Если атрибут не задан или равен onfocus либо onsubmit, ничего не запрашивается, пока кто-нибудь не тронет форму, поэтому взводите ожидание вокруг клика по виджету, а не вокруг goto.

Сопоставляйте по подстроке, а не по всему URL, но никогда не по одному слову altcha. Путь различается от сайта к сайту и часто несёт строку запроса против кеширования, поэтому точное сравнение и есть одна из причин, по которым это никогда не срабатывает. Вторая причина в слишком вольном сопоставлении: скрипт виджета обычно отдаётся по пути со словом altcha, грузится он первым, и тогда ожидание завершается на JavaScript, который не примет ни один парсер JSON. Этот приём Playwright разбирает в своём руководстве по работе с сетью.

Шаг 2: передайте задачу единственному методу для ALTCHA

Метод всего один, и полностью его описывает страница решения ALTCHA, а задачу он принимает в любом виде. Передайте эндпоинт, и решатель сам заберёт задачу. Передайте документ, и наружу не уйдёт ни одного запроса.

from capskip import CapSkip

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

# The document from step 1, 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

Если браузер уже видел документ, передавайте его прямо в опции. Это единственное место, где управление браузером меняет совет. Сайт будет судить вас по той задаче, которая выдана вашей браузерной сессии, поэтому передача решателю именно этого документа держит их в согласии. Забрать вторую задачу с эндпоинта не ошибка, но тогда страница держит одну задачу, а вы отвечаете на другую, и на сайте, который привязывает задачу к сессии, ответ не сойдётся. Опция принимает словарь, который сериализуется за вас, или строку JSON, если она у вас уже есть.

Два ключа в этом результате существуют только ради 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, и их не пробуют решать, а отклоняют: задача, требующая любой из них, примерно за треть секунды возвращается как ERROR_CAPTCHA_UNSOLVABLE и никогда не повторяется, потому что повтор не лечит функцию, требовательную к памяти. На этом типе такой результат указывает на алгоритм, а не на нечитаемую картинку, и у самого кода ошибки есть отдельное руководство.

Шаг 3: впишите token в скрытое поле виджета

Виджет отправляет свою полезную нагрузку в скрытом input, имя которого берётся из его же атрибута name, а по умолчанию там стоит altcha. Читайте этот атрибут, а не предполагайте, ровно так же, как вы читали атрибут с задачей. В прогоне с браузером поле заполняете вы сами, потому что виджет никто не проверял и сам он ничего не впишет.

# Walk up from the submit button so the field lands in the
# form that actually posts, not in the first form on the page.
SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

field_name = widget.get_attribute("name") or "altcha"
page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
page.click("button[type=submit]")

Выберите нужную форму. На странице регистрации их часто несколько, и если добавить поле к первой на странице, когда кнопка отправки принадлежит другой, сервер не увидит значения вовсе. Именно поэтому фрагмент кода поднимается вверх от кнопки отправки.

Передавайте строку как есть. Token представляет собой base64 от документа JSON, поля которого покрыты подписью HMAC сервера, поэтому всё, что похоже на наведение порядка, его ломает: обрезка пробелов, декодирование и повторное кодирование или пересборка JSON с другим порядком ключей. Некоторые интеграции читают полезную нагрузку из поля тела JSON, а не из поля формы, а сам виджет можно настроить на доставку через cookie, поэтому посмотрите, что отправляет собственная кнопка отправки страницы, и повторите это.

Одно отличие браузера от простого HTTP-клиента: страница может гонять по форме собственный скрипт. Если кнопка отправки остаётся выключенной, страница ждёт сигнала об успехе виджета, а не читает поле. Честных ответов здесь два, и какой выбрать, зависит от того, сколько страницы вы хотите сохранить. Можно найти, что именно слушает страница, и дать ей это, а можно обойти кнопку и отправить поля формы напрямую вместе с cookie браузера, что обычно короче и всегда стабильнее.

Шаг 4: где работает решатель, когда Playwright переезжает в CI

В примерах выше стоит 127.0.0.1, потому что это верно, пока ваш скрипт и CapSkip живут на одной машине. Решателя вызывает ваш код на Python, а не браузер и не страница, поэтому адрес определяется тем, где выполняется процесс теста. В Playwright об этом легко забыть, потому что браузер часто уже находится где-то в другом месте.

Перенесите этот процесс в официальный образ Docker для Playwright или на CI-раннер, и loopback станет указывать на контейнер, где никто не слушает, поэтому первое же решение выбросит NetworkException. Переключите CapSkip в Server mode, и он начнёт слушать ваш сетевой адрес или публичный IP, а контейнер подключится по тому же HTTP API. Если маршрут идёт через интернет, лучше взять статический публичный IP и правилом файрвола разрешить только те адреса, которые вы ожидаете. Server mode меняет лишь то, где слушает решатель, и ничего больше: железо по-прежнему ваше, и счётчик решений по-прежнему не ведётся.

Где выполняется процесс PythonКакой режим подключения
На машине с CapSkip, управляя локальным браузеромРежим Local. 127.0.0.1 действительно верен
На другой машине в той же сетиРежим Server, по внутреннему адресу этой машины
В контейнере Playwright, на CI-раннере или на VPSServer mode со статическим публичным IP и правилом брандмауэра
Локально, но с подключением к удалённому браузеруLocal mode. Браузер с решателем не общается никогда

Читайте хост и порт из окружения, чтобы один скрипт работал в обоих местах. Клиент также сам подхватывает CAPSKIP_HOST, CAPSKIP_PORT и CAPSKIP_API_KEY, если передавать их вручную не хочется.

Одно замечание о прокси, касающееся именно ALTCHA. Прокси здесь поддерживается, но используется только для загрузки задачи. Браузерной сессии, которую можно было бы через него направить, нет, поэтому на само доказательство работы он не влияет, а когда вы передаёте документ задачи прямо в опции, он не делает совсем ничего.

Полный рабочий пример

import os
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from playwright.sync_api import sync_playwright

PAGE_URL = "https://example.com/signup"

SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

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

def is_challenge(r):
    return "altcha" in r.url and "json" in r.headers.get("content-type", "")

with sync_playwright() as p:
    page = p.chromium.launch(headless=True).new_page()

    # Catch, solve and submit with nothing slow in between.
    with page.expect_response(is_challenge) as caught:
        page.goto(PAGE_URL)

    try:
        result = solver.altcha(url=PAGE_URL, challenge_json=caught.value.json())
    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")

    field_name = page.locator("altcha-widget").get_attribute("name") or "altcha"
    page.fill("input[name=email]", "someone@example.com")
    page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
    page.click("button[type=submit]")

Все четыре исключения наследуются от CapSkipError, поэтому перехват одного этого класса закрывает в одном блоке любой сбой, который может выбросить SDK. Ловите конкретные классы, когда реакция на них разная, как выше, и CapSkipError, когда она одинаковая.

Остальные типы работают так же и из того же клиента. reCAPTCHA и Turnstile принимают sitekey и URL страницы, GeeTest принимает значение gt, challenge и URL страницы, а распознавание картинок принимает путь к файлу, URL или base64. Все методы, доступные в пакете, перечислены на странице сервиса распознавания капч для Python, а всё про браузеры целиком собрано на странице сервиса распознавания капч для Playwright.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
Оба атрибута виджета возвращаются как NoneВиджет настроили целиком из JavaScript, поэтому ни одного из двух имён в разметке нетПерехватите вместо этого ответ: он от разметки не зависит
get_attribute висит 30 секунд и затем выбрасывает исключениеЭлемент виджета так и не появился, поэтому локатор выждал свой таймаут по умолчаниюПроверьте селектор по отрисованной странице, а затем переходите к перехвату ответа
Ожидание ответа завершается по таймаутуУ виджета нет атрибута auto со значением onload, поэтому запрос так и не ушёл, либо ожидание взвели уже после навигацииОберните то, что запускает запрос, и откройте контекстный менеджер до него
Ошибка разбора JSON на перехваченном ответеОжидание завершилось на собственном скрипте виджета, в URL которого тоже есть altchaДобавьте в фильтр тип содержимого JSON
ApiException на задаче, перехваченной мгновение назадПереданная задача уже истекла, поэтому решатель отклонил её, а не стал хешироватьПерехватывайте и решайте на одном дыхании или передайте эндпоинт, чтобы решатель забрал задачу заново
Голый отказ в проверке при token, который по логу был решёнЗадача истекла между решением и отправкойПерехватывайте, решайте и отправляйте, не вставляя между ними ничего медленного
ERROR_CAPTCHA_UNSOLVABLE внутри ApiException, примерно через треть секундыChallenge использует Argon2id или scryptПовторять нечего. Эти два отклоняются намеренно
NetworkException на первом решенииCapSkip не запущен либо скрипт работает в контейнере и нацелен на loopbackЗапустите CapSkip, затем выберите между Local mode и Server mode
Форма отправляется, но сервер сообщает об отсутствующем значении altchaСкрытое поле добавили к другой форме на страницеОбращайтесь к той форме, которой принадлежит кнопка отправки
TimeoutException с упоминанием 120 секундРешатель не ответил в пределах таймаута опроса по умолчаниюПроверьте, что решатель запущен и не перегружен. Поднятие потолка лишь отложит тот же самый ответ
Кнопка отправки так и не становится активнойСтраница держит её закрытой, пока её собственный скрипт не увидит успех виджетаОтправьте поля формы напрямую или дайте странице то, чего она слушает
ValidationException при вызовеНе передана ни одна опция с задачей либо передана опция, которую ALTCHA не принимаетПередайте эндпоинт или документ и уберите всё остальное

FAQ

Нужен ли вообще браузер, чтобы решить ALTCHA?

Нет. ALTCHA сводится к хешированию, поэтому решается процессором, и в ответе браузер не участвует. Если Playwright вы открыли только ради капчи, закройте его: заберите задачу HTTP-клиентом и отправьте token обратно. Именно это и разбирает руководство по ALTCHA на обычном Python шаг за шагом. Playwright оправдывает своё место тогда, когда остальной сценарий требует настоящей страницы: логин, который ставит cookie, многошаговая форма или сайт, который рисует свою разметку скриптом.

Достучится ли Playwright до решателя из Docker или GitHub Actions?

Да, через Server mode. Переключите CapSkip с loopback на ваш сетевой адрес или публичный IP в настройках подключения, а затем нацельте на него переменную окружения с хостом. После этого контейнер, раннер и решатель говорят по тому же HTTP API, что и на одной машине. Если маршрут идёт через интернет, возьмите статический публичный IP и ограничьте его правилом файрвола. Решатель в любом случае остаётся на вашем железе, поэтому ни лицензия, ни количество решений не меняются.

Сколько времени token ALTCHA остаётся действительным?

Недолго, и решает это сайт. Некоторые окна закрываются меньше чем за две минуты. Когда окно истекает, сайт отклоняет ответ голым отказом в проверке, который выглядит ровно как неверный ответ, и ничто в ответе не подскажет, что именно из двух произошло. Поэтому не собирайте задачи заранее, не паркуйте token в переменной, пока браузер проходит ещё три страницы, и никогда не держите его, пока форму заполняет человек. Свежее решение стоит миллисекунды, и это дешевле, чем выяснять, почему не прошло просроченное.

Не оборвут ли решение собственные таймауты Playwright?

Нет, потому что решение не является вызовом Playwright. Таймауты действий и навигации по умолчанию в 30 секунд покрывают клики, ожидания и загрузку страниц, а вызов решателя остаётся обычным кодом на Python между двумя из них. Потолком здесь служит собственный таймаут опроса клиента в 120 секунд, который ALTCHA использует вместо более длинного таймаута reCAPTCHA, потому что тут работает процессор, а не браузерная сессия. Следите лучше за ограничением, обёрнутым вокруг всего теста: таймаутом плагина на тест или лимитом задачи CI.

Коротко

Перехватите задачу, которую запросил виджет, либо с атрибута, либо с ответа, передайте этот документ единственному методу для ALTCHA вместе с URL страницы и впишите token в скрытое поле, имя которому даёт собственный атрибут name виджета, в той форме, которая действительно отправляется. Token по дороге не трогайте. Держите перехват, решение и отправку рядом, потому что окно может закрыться меньше чем за две минуты, а истёкшую задачу не отличить от неверного ответа. Переключайтесь в Server mode, как только скрипт перестаёт делить машину с решателем.

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