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

Чтобы решить 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 и v2 | challengeurl для эндпоинта плюс отдельный атрибут 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-раннере или на VPS | Server 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, как только скрипт перестаёт делить машину с решателем.
- Что это за тип капчи и как он работает: страница решения ALTCHA.
- Та же работа без браузера: решение ALTCHA на обычном Python.
И последнее, что влияет на то, как вы проектируете повторную попытку. Поскольку на этом маршруте работу берёт на себя локальный сервис распознавания капчи , который вычисляет доказательство работы на вашей собственной машине, отброшенная задача обходится в несколько миллисекунд вашего процессора и больше ни во что, поэтому вы спокойно можете перезагрузить страницу и взять новую задачу, а не тянуть просроченный token через весь длинный прогон.
