Как решить капчу в DrissionPage и вставить токен

drissionpage captcha - How to Solve CAPTCHA in DrissionPage and Inject the Token

Шаг с капчей в DrissionPage состоит из трёх действий: считать sitekey со страницы, отправить его решателю, затем записать токен обратно в форму через JavaScript. DrissionPage управляет настоящим Chrome через DevTools Protocol, а не через webdriver, поэтому со стороны браузера всё проще, чем в Selenium. С моделью элементов проще не стало, и у неё есть одна настройка по умолчанию, которая превращает отсутствующий виджет в ошибку от решателя вместо ошибки от вашего локатора. Именно из-за этой настройки большинство таких скриптов падают непонятным образом, поэтому ей отведён отдельный раздел.

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

  • Python 3.10 или новее, с DrissionPage 4.1 и установленным CapSkip SDK.
  • Браузер Chromium, который DrissionPage может запустить или к которому может подключиться.
  • URL страницы с защищённой формой. Sitekey, который вы считываете во время выполнения.
  • Запущенный CapSkip в режиме Local, если скрипт и решатель работают на одной машине, или в режиме Server, если скрипт запущен на другой. Оба режима описаны в разделе Настройки подключения.
# Both packages, one line.
pip install DrissionPage capskip

Почему с DrissionPage задача выглядит иначе

DrissionPage общается с Chrome напрямую через CDP. Посередине нет процесса chromedriver, значит, нет бинарника драйвера, который надо патчить, и нет отдельной службы, версию которой надо держать в соответствии с версией браузера. Ещё он умеет подключаться к уже запущенному браузеру, и здесь это по-настоящему полезно: можно один раз войти вручную, оставить профиль открытым и дать скрипту подхватить сессию.

Что не меняется, так это токен. Решённая reCAPTCHA представляет собой строку, которая должна попасть в скрытую g-recaptcha-response textarea до отправки формы. У этой textarea выставлено display:none, поэтому напечатать в неё текст не выйдет ни в одном инструменте автоматизации. Значение записывается через JavaScript, а в DrissionPage за это отвечает метод run_js.

Об одном стоит сказать прямо: отсутствие webdriver не означает отсутствие детекта. Запуск Chrome через CDP убирает один сигнал и оставляет остальной отпечаток ровно там же, где он был. Решение проверки и правдоподобный отпечаток браузера относятся к разным задачам, и здесь разбирается только первая.

Шаг 1: считайте sitekey со страницы

Sitekey находится в основном документе, а не внутри iframe виджета. Разметка самого Google кладёт его на контейнерный элемент в атрибут data-sitekey, а атрибутный локатор DrissionPage находит его без CSS-селектора.

# pip install DrissionPage
from DrissionPage import ChromiumPage

page = ChromiumPage()
page.get("https://example.com/page-with-recaptcha")

# @attr finds by attribute. The default search timeout is 10s,
# which is plenty for a widget that renders on load.
holder = page.ele("@data-sitekey")
sitekey = holder.attr("data-sitekey")

print(sitekey)   # 6Lxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Префиксы локаторов DrissionPage здесь стоит выучить, потому что они заменяют большую часть XPath, который иначе пришлось бы писать: @attr=value ищет точное совпадение, @attr:value ищет подстроку, @attr^value совпадает с началом, а @attr$value с концом. Префикс с именем тега, как в tag:iframe, дополнительно фиксирует тип элемента.

Некоторые сайты вообще не кладут sitekey на основную страницу и передают его только в URL iframe. Тогда читайте его оттуда, используя contains, чтобы попасть в нужный фрейм.

# Fallback: the k= query parameter on the widget iframe.
from urllib.parse import urlparse, parse_qs

frame = page.ele("tag:iframe@src:recaptcha/api2/anchor")
src = frame.attr("src")
sitekey = parse_qs(urlparse(src).query)["k"][0]

Шаг 2: решаем через локальный API

Решатель работает на вашей машине на порту 8080 и говорит на API 2captcha, поэтому вызов SDK занимает одну строку и никакой платы за каждое решение за ним не стоит. Передавайте URL страницы из page.url вместо того чтобы набирать его заново, потому что редирект между get() и решением иначе приведёт к тому, что вы отправите неверный URL.

# pip install capskip
from capskip import CapSkip

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

# Same call shape for v3 (version="v3") and Enterprise (enterprise=1).
result = solver.recaptcha(sitekey=sitekey, url=page.url)

token = result["code"]   # the g-recaptcha-response value

У Turnstile и GeeTest есть собственные методы, solver.turnstile() и solver.geetest(), и оба устроены так же, как вызов выше. Полный список параметров для каждого приведён в документации CapSkip API.

Шаг 3: внедрите токен и отправьте форму

DrissionPage передаёт дополнительные аргументы в run_js позиционно, и скрипт читает их как arguments[0] и так далее. Передавайте токен именно аргументом, а не собирайте JavaScript через f-string: токен длинный, непрозрачный и порой полон символов, об экранировании которых лучше не думать.

# Extra args arrive as arguments[0], arguments[1], ...
page.run_js(
    "document.getElementById('g-recaptcha-response').value = arguments[0];",
    token,
)

# Then submit the form the way the page expects.
page.ele("tag:button@type=submit").click()

Если сайт задаёт callback вместо чтения textarea при отправке, вызовите его после установки значения. Имя функции у каждого сайта своё, поэтому берите его из разметки самой страницы, а не угадывайте, и учтите, что это по-прежнему обычная reCAPTCHA v2: callback меняет способ передачи токена, а не способ его решения. На странице решателя reCAPTCHA v2 описаны оба способа отправки.

Тихий сбой, который отнимает больше всего времени

В DrissionPage параметр Settings.raise_when_ele_not_found по умолчанию выставлен в False. Локатор, который ничего не нашёл, не бросает исключение. Он возвращает NoneElement: объект ложный в булевом контексте и равный None из Python, так что скрипт спокойно едет дальше.

Это осознанное решение разработчиков, и оно удобно, когда вы проверяете необязательные элементы. Здесь оно неудобно, потому что следующим шагом вы читаете атрибут, а в тексте ошибки будет назван локатор, а не виджет. Хуже случай, когда элемент действительно существует, но атрибута data-sitekey у него нет вовсе. Тогда attr() возвращает None, этот None доезжает до решателя в виде пустого ключа, и сбой наконец всплывает как ERROR_GOOGLEKEY в вызове API, на который вы и не думали грешить.

Лечится двумя строками. Включите исключения и проверьте sitekey, прежде чем тратить на него решение.

# Make a missing element fail where it happened.
from DrissionPage.common import Settings

Settings.set_raise_when_ele_not_found(True)

# And still check the value, because a found element can hold nothing.
if not sitekey:
    raise RuntimeError("No sitekey on the page. Check the widget rendered.")

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

Всё описанное выше в одном скрипте. Блок finally здесь важнее обычного, потому что DrissionPage умеет подключаться к браузеру, который запустили не вы, а закрывать чужой браузер обычно совсем не то, чего вы хотите.

# pip install DrissionPage capskip
from DrissionPage import ChromiumPage
from DrissionPage.common import Settings
from capskip import CapSkip

Settings.set_raise_when_ele_not_found(True)

PAGE_URL = "https://example.com/page-with-recaptcha"
page = ChromiumPage()
solver = CapSkip(host="127.0.0.1", port=8080)

try:
    page.get(PAGE_URL)
    sitekey = page.ele("@data-sitekey").attr("data-sitekey")
    if not sitekey:
        raise RuntimeError("Widget found but data-sitekey was empty.")

    result = solver.recaptcha(sitekey=sitekey, url=page.url)
    page.run_js(
        "document.getElementById('g-recaptcha-response').value = arguments[0];",
        result["code"],
    )
    page.ele("tag:button@type=submit").click()
    page.wait.doc_loaded()

    print(page.title)   # the page you land on after submitting
finally:
    page.quit()

Запуск решателя на другой машине

Парсеры рано или поздно переезжают на сервер, и DrissionPage переезжает вместе с ними: ему нужен бинарник Chromium и буфер экрана, больше почти ничего. Решателю не обязательно ехать следом на ту же машину.

У CapSkip два режима подключения. Локально привязывается к 127.0.0.1 и доступен только с этого устройства, что и нужно, пока вы пишете скрипт. Сервер привязывается к вашему сетевому или публичному IP, поэтому парсящая виртуальная машина, хост контейнеров или второй рабочий компьютер могут обращаться к тому же решателю через API. Статический публичный IP держит этот адрес постоянным. В коде больше ничего не меняется, кроме адреса хоста, который вы передаёте, и цена тоже не меняется, потому что это по-прежнему ваше железо.

# Same SDK, same call. Only the host moves.
solver = CapSkip(host="10.0.0.12", port=8080, apiKey="YOUR_API_KEY")

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

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

Что вы видитеПричинаИсправить
ElementNotFoundError при вызове attr()Локатор ничего не нашёл и вернул NoneElementРасширьте локатор или дождитесь отрисовки виджета
Скрипт идёт дальше мимо отсутствующего элементаraise_when_ele_not_found по умолчанию равен FalseSettings.set_raise_when_ele_not_found(True)
ERROR_GOOGLEKEYattr() вернул None, и был отправлен пустой ключПроверьте значение или считайте параметр k из iframe
NetworkExceptionCapSkip не запущен или указан неверный хостЗапустите приложение или направьте host на адрес сервера
TimeoutExceptionРешение заняло больше, чем recaptchaTimeoutПоднимите его выше стандартных 300 секунд
Форма отклоняет токен, который решился нормальноТокен устарел до отправкиРешайте капчу прямо перед отправкой, а не при загрузке страницы

FAQ

Что использовать, ChromiumPage или более новый класс Chromium?

Любой. Версия 4.1 экспортирует Chromium, ChromiumPage, SessionPage и WebPage из пакета верхнего уровня, и ChromiumPage по-прежнему остаётся самым коротким путём к одной вкладке. Работа с капчей одинакова при любом выборе, потому что чтение sitekey, решение и вставка токена происходят через объект вкладки.

Нужно ли переключаться в iframe reCAPTCHA?

Нет, и именно здесь чаще всего всё усложняют. Чекбокс живёт в iframe, но и атрибут sitekey, и скрытая textarea с ответом принадлежат основному документу. Фрейм вы трогаете только тогда, когда сайт не отдаёт sitekey на странице и его приходится читать из URL самого фрейма.

Почему мой скрипт передаёт sitekey со значением None и не жалуется?

Потому что по умолчанию DrissionPage возвращает ложную заглушку вместо исключения, и потому что существующему элементу вполне может не хватать запрошенного атрибута. Оба пути заканчиваются значением None в переменной, которую вы считали строкой. Включите исключения в начале файла и добавьте явную проверку значения: вместе они закрывают оба случая.

Мой парсер работает на VPS. Куда ставить решатель?

Куда угодно, лишь бы они видели друг друга. Режим Server заставляет решатель слушать сетевой адрес вместо адреса обратной петли, поэтому VPS обращается к нему через API ровно так же, как к любому внутреннему сервису. Направьте аргумент host на этот адрес, включите проверку ключа и выдайте VPS собственный ключ.

Коротко

Считайте sitekey атрибутным локатором, решите капчу на 127.0.0.1:8080, вставьте токен через run_js и позиционные аргументы, затем отправьте форму. Включите raise_when_ele_not_found до всего этого, потому что значение по умолчанию прячет ровно тот сбой, на который вы наткнётесь первым. Более широкую картину по Python, включая Selenium и Playwright, смотрите на странице сервиса распознавания капч для Python. И решатель представляет собой локальный сервис распознавания капчи и не тарифицируется поштучно, поэтому обход с тысячей повторов стоит столько же, сколько обход с десятью.