Как решить капчу в nodriver с помощью асинхронного SDK

nodriver captcha - How to Solve CAPTCHA in nodriver With the Async SDK

Шаг с капчей в nodriver состоит из привычных трёх действий: считать sitekey со страницы, отправить его решателю, записать токен обратно через JavaScript. Меняется то, что nodriver асинхронен насквозь. Он управляет Chrome через websocket на asyncio, поэтому блокирующее решение не просто заставляет ваш скрипт ждать, а останавливает сокет, по которому идут все сообщения DevTools. В паре с асинхронным клиентом всё остаётся отзывчивым.

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

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

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

nodriver стал официальным преемником undetected-chromedriver, его написал тот же автор, и главное в нём то, что ни webdriver, ни Selenium в стеке нет вообще. Он говорит по DevTools Protocol с браузером, который сам же и запустил. Нет бинарника chromedriver, который надо патчить, и нет версии драйвера, которую надо держать в соответствии с Chrome.

Для работы с капчей важна вторая половина этой фразы: он полностью асинхронный. Соединение представляет собой websocket, которым занимается asyncio, а фоновая задача читает из него протокольные сообщения. Каждый поиск элемента, каждая навигация и каждый обработчик событий зависят от того, получит ли эта задача своё время. Вызовите посреди этого синхронный решатель, и на всё время решения в процессе не будет выполняться больше ничего, а для reCAPTCHA v2 это обычно от пятнадцати до сорока пяти секунд.

Поэтому правило для этого фреймворка короткое. Используйте асинхронный клиент и вызывайте его через await.

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

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

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

# pip install nodriver
import nodriver as uc

async def main():
    browser = await uc.start()
    page = await browser.get("https://example.com/page-with-recaptcha")

    # select() retries for 10 seconds by default, so it doubles
    # as a wait condition for a widget that renders late.
    holder = await page.select("div.g-recaptcha")
    sitekey = holder.attrs["data-sitekey"]

    print(sitekey)   # 6Lxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

uc.loop().run_until_complete(main())

Имена атрибутов хранятся на элементе ровно так, как их записал HTML, вместе с дефисами, поэтому обращение по квадратным скобкам остаётся единственным надёжным способом прочитать атрибут. Точечное сокращение выглядит рабочим и тихо таковым не является: на запрос data_sitekey элемент возвращает None, а не бросает исключение, потому что этот поиск проваливается в значение по умолчанию. Дальше None доезжает до решателя в виде пустого ключа и всплывает гораздо позже как ERROR_GOOGLEKEY, далеко от той строки, которая всё это вызвала. По-настоящему переименован ровно один атрибут: class хранится под именем class_, чтобы не пересекаться с ключевым словом Python.

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

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

frame = await page.select("iframe[src*='recaptcha/api2/anchor']")
sitekey = parse_qs(urlparse(frame.attrs["src"]).query)["k"][0]

Шаг 2: решаем капчу, не останавливая сокет

В Python SDK два клиента. CapSkip синхронный, а AsyncCapSkip представляет собой настоящую реализацию на asyncio, а не псевдоним, и это ровно то, что нужно этому фреймворку. Оба обращаются к решателю на вашей машине на порту 8080, и ни один не берёт плату за каждое решение.

# pip install capskip
from capskip import AsyncCapSkip

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

# Same call shape for v3 (version="v3") and Enterprise
# (enterprise=1). Invisible v2 takes invisible=1.
result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL)

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

Раз вызов можно ждать через await, несколько вкладок могут решать капчу одновременно без всякой многопоточности. Откройте страницы, соберите решения через gather, затем вставьте каждый токен в ту вкладку, которой он принадлежит. Более широкий приём, включая то, как SDK наращивает паузы между опросами вместо сна с фиксированным интервалом, разобран в отдельном материале: параллельное решение капчи.

# Three tabs, three solves, one wait.
import asyncio

results = await asyncio.gather(*[
    solver.recaptcha(sitekey=k, url=u) for k, u in targets
])

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

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

Ответная textarea скрыта через display:none, поэтому напечатать в неё текст нельзя ни в одном инструменте автоматизации. Заполняют её через JavaScript. Метод evaluate в nodriver принимает строку с выражением и не даёт передать аргументы отдельно, поэтому токен приходится встраивать прямо в эту строку, и делать это безопасно позволяет json.dumps, а не f-string. Строковый литерал JSON является корректным строковым литералом JavaScript, вместе с кавычками и экранированием.

# json.dumps gives a correctly quoted JS string literal.
import json

await page.evaluate(
    "document.getElementById('g-recaptcha-response').value = "
    + json.dumps(token)
)

# Then submit the form the way the page expects.
button = await page.select("button[type=submit]")
await button.click()

У проверки того, что значение записалось, есть своя ловушка, и она стоит десяти секунд вашего внимания. Если выставлен return_by_value, evaluate отдаёт обычное значение Python только тогда, когда это значение истинно. Пустая строка или ноль проваливаются мимо, и вы получаете объект протокола. Поэтому не читайте длину и не проверяйте её: нулевая длина как раз и есть тот случай, который вы пытаетесь поймать. Возвращайте то, что не может оказаться ложным.

# String() keeps a zero-length answer truthy, so the check
# reports the real number instead of a protocol object.
length = await page.evaluate(
    "String(document.getElementById('g-recaptcha-response').value.length)",
    return_by_value=True,
)
print(length)   # "0" means the injection did not land

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

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

Всё описанное выше в одном скрипте. Решатель создаётся один раз и переиспользуется, а браузер останавливается в блоке finally, чтобы неудачное решение не оставило после себя процесс Chrome.

# pip install nodriver capskip
import json
import nodriver as uc
from capskip import AsyncCapSkip

PAGE_URL = "https://example.com/page-with-recaptcha"

async def main():
    solver = AsyncCapSkip(host="127.0.0.1", port=8080)
    browser = await uc.start()
    try:
        page = await browser.get(PAGE_URL)

        holder = await page.select("div.g-recaptcha")
        sitekey = holder.attrs["data-sitekey"]
        if not sitekey:
            raise RuntimeError("Widget found but data-sitekey was empty.")

        result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL)
        await page.evaluate(
            "document.getElementById('g-recaptcha-response').value = "
            + json.dumps(result["code"])
        )

        button = await page.select("button[type=submit]")
        await button.click()
        await page.sleep(2)

        print(page.target.url)   # the page you land on after submitting
    finally:
        browser.stop()

uc.loop().run_until_complete(main())

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

nodriver рано или поздно оказывается на сервере, и там ему нужны две вещи: бинарник Chromium и сеанс рабочего стола, в котором Chrome будет рисовать. Headless-режим по умолчанию выключен, поэтому на сервере без такого сеанса headless надо включать явно. Решателю ехать следом не обязательно.

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

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

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

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

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

Что вы видитеПричинаИсправить
AttributeError при обращении к attrsselect() ничего не нашёл и вернул NoneРасширьте селектор или увеличьте таймаут select
Sitekey равен None, и никакой ошибки нетТочечное обращение не достаёт атрибут с дефисомЧитайте его из attrs по квадратным скобкам
ERROR_GOOGLEKEYДо решателя дошёл пустой sitekeyПроверьте значение, прежде чем тратить на него решение
Скрипт висит всё время решенияСинхронный клиент заблокировал цикл событийИспользуйте AsyncCapSkip и вызывайте через await
NetworkExceptionCapSkip не запущен или указан неверный хостЗапустите приложение или направьте host на адрес сервера
TimeoutExceptionРешение заняло больше, чем recaptchaTimeoutПоднимите его выше стандартных 300 секунд
evaluate возвращает объект, а не строкуЗначение оказалось ложным, поэтому обычный возврат был пропущенОберните в String() длину, а не само значение

FAQ

Можно ли оставить синхронный клиент, если решение нужно всего одно?

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

Нужно ли заходить в iframe reCAPTCHA?

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

Я перехожу с undetected-chromedriver. Что переносится?

Три действия переносятся без изменений, потому что они никогда не зависели от драйвера: считать sitekey, решить капчу, записать токен в textarea. Не переносится API вокруг них, потому что теперь каждый вызов асинхронный и объекта драйвера нет. Ещё в nodriver есть помощник, который превращает работающий экземпляр undetected-chromedriver в объект браузера, и это позволяет переносить скрипт по частям. Старый подход описан в руководстве по капче для undetected-chromedriver.

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

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

Коротко

Считайте sitekey из attrs по квадратным скобкам, решите капчу через AsyncCapSkip на 127.0.0.1:8080, вставьте токен через evaluate с json.dumps, затем отправьте форму. Ждите всё через await, потому что синхронное решение держит сокет, который управляет браузером. Более широкую картину по Python, включая Selenium и Playwright, смотрите на странице сервиса распознавания капч для Python.

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