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

crawl4ai captcha - How to Solve CAPTCHA in Crawl4AI Without Losing the Session

Своего решателя капчи в Crawl4AI нет, поэтому решение капчи в Crawl4AI складывается из трёх вызовов, которые вы связываете сами: загрузить страницу в именованной сессии, решить капчу по sitekey внешним решателем, а затем вернуться в ту же вкладку, чтобы вставить токен и отправить форму. Спотыкаются обычно о порядок, в котором Crawl4AI выполняет действия. Начиная с версии 0.8.5, js_code выполняется после wait_for, поэтому обход, который отправляет форму в js_code и ждёт следующую страницу через wait_for, стоит на месте, пока не истечёт таймаут. Отправке место в js_code_before_wait. В этом руководстве разобран сценарий с сессией на примере reCAPTCHA v2, а затем хук для глубоких обходов, где вы не управляете каждым вызовом.

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

  • Python 3.10 или новее и Crawl4AI 0.9 или новее. Всё здесь прочитано в исходниках 0.9.4 и проверено на них; порядок из шага 3 действует начиная с 0.8.5.
  • Пакет CapSkip для Python, в котором есть AsyncCapSkip, по-настоящему асинхронный клиент, встающий в тот событийный цикл, на котором Crawl4AI уже работает.
  • URL страницы с капчей и селектор для элемента, который появляется только после того, как форма прошла.
  • CapSkip, запущенный на машине с Windows. Режим Local отвечает на 127.0.0.1, когда краулер работает на той же машине; режим Server слушает ваш сетевой или публичный IP, когда это не так. Оба режима описаны в разделе Настройки подключения.
# pip install crawl4ai
pip install -U crawl4ai capskip

# Downloads the browser Crawl4AI drives, once per machine.
crawl4ai-setup

Шаг 1: загружаем страницу в сессии и считываем sitekey

Передайте первому вызову session_id. Тогда вкладка остаётся открытой после возврата из вызова, с нетронутыми cookie и виджетом, и токен, который вы решите позже, попадёт в ту страницу, которая его запросила. Без session_id Crawl4AI закрывает страницу, как только получает HTML.

# pip install crawl4ai
import re
from crawl4ai import CrawlerRunConfig

PAGE_URL = "https://example.com/signup"
SESSION = "signup"
# The widget element, in any attribute order, with or without other classes.
WIDGET = r'<[^>]*class="(?:[^"]*\s)?g-recaptcha(?:\s[^"]*)?"[^>]*>'

async def read_sitekey(crawler):
    # session_id keeps this tab open for the next arun call.
    config = CrawlerRunConfig(session_id=SESSION)
    first = await crawler.arun(PAGE_URL, config=config)
    # Do not stop on first.success: a CAPTCHA page can be
    # flagged as blocked while its HTML is complete.
    widget = re.search(WIDGET, first.html)
    match = widget and re.search(r'data-sitekey="([^"]+)"', widget.group(0))
    return match.group(1) if match else None

Этот комментарий стоит там не просто так. Crawl4AI 0.9 прогоняет антибот-проверку по каждому результату, и страница, которую он принимает за страницу блокировки, помечается как неудачная: success возвращается равным False, а error_message начинается с Blocked by anti-bot protection. Под это подходит любой HTML-ответ с кодом 403 или 503, а также 429. При других статусах ошибок подходит страница меньше 10 KB, если атрибут class её виджета в точности равен g-recaptcha. Отрисованный HTML по-прежнему лежит в first.html и по-прежнему содержит нужный вам sitekey, поэтому прочитайте его, прежде чем решать, что обход провалился.

Поле html содержит отрисованную страницу, поэтому виджет, построенный скриптом, в нём тоже есть. Если ключа нет на элементе, ищите параметр k= в src iframe виджета, а если виджет отрисовывается поздно, добавьте в первый вызов wait_for на этот iframe.

Шаг 2: решаем капчу через AsyncCapSkip

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

# pip install capskip
from capskip import AsyncCapSkip

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

async def solve(sitekey):
    # Invisible v2 takes invisible=1, Enterprise takes enterprise=1.
    result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL)
    return result["code"]   # the g-recaptcha-response value

Пока это выполняется, в Crawl4AI ничего не ждёт, потому что решение происходит между двумя вызовами, а не внутри одного. Вкладка просто стоит открытой. А вот у токена часы идут: токен reCAPTCHA годен примерно две минуты после выдачи, поэтому сразу переходите к отправке. Подробнее об этом: срок действия токена reCAPTCHA.

Шаг 3: вставляем токен и отправляем форму в той же вкладке

Второй вызов повторно использует сессию и задаёт js_only: этот флаг велит Crawl4AI выполнить JavaScript в уже загруженной странице вместо того, чтобы загружать URL заново. Перезагрузка обходится во вторую загрузку страницы, на которой проверка может выпасть снова, и сбрасывает всё, что подготовила первая загрузка, например частично заполненную форму.

import json

async def submit(crawler, token):
    # Crawl4AI wraps this in an async function, so statements work.
    inject = (
        "document.getElementById('g-recaptcha-response').value = "
        f"{json.dumps(token)};"
        "document.querySelector('form').submit();"
    )
    config = CrawlerRunConfig(
        session_id=SESSION,
        js_only=True,                 # same tab, no reload
        js_code_before_wait=inject,   # runs BEFORE wait_for
        wait_for="css:.signup-complete",
    )
    return await crawler.arun(PAGE_URL, config=config)

Вот почему скрипт ставится в js_code_before_wait. Начиная с 0.8.5 и во всех выпусках 0.9 обход сначала выполняет js_code_before_wait, затем wait_for и в самом конце js_code, уже на готовой странице. Поэтому если поставить отправку в js_code, wait_for начнёт искать следующую страницу ещё до того, как что-либо отправлено, и будет искать, пока не истечёт page_timeout (по умолчанию 60 секунд), после чего вызов упадёт с Wait condition failed, а форма так и не уйдёт. Примеры, которые отправляют форму из js_code и ждут через wait_for в том же вызове, включая один пример в собственной документации Crawl4AI, на 0.9.4 упираются именно в это.

Собирайте строку через json.dumps, а не вставляйте токен между кавычками, потому что строковый литерал JSON одновременно является корректным литералом JavaScript, вместе с экранированием. Ждать навигацию самостоятельно не нужно: wait_for продолжает опрашивать страницу и сквозь навигацию, а .signup-complete находит уже на следующей странице. Чего Crawl4AI делать не станет, так это бросать исключение, когда скрипт падает. Синтаксическая ошибка даёт строку в логе, а ошибка во время выполнения, например опечатка в ID элемента, проглатывается без всякой записи и проявляется только как таймаут wait_for, поэтому проверьте эти две инструкции в консоли DevTools на настоящей странице, прежде чем винить решатель.

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

Шаг 4: решение внутри глубокого обхода с помощью хука

Сценарий с сессией справляется с капчей в Crawl4AI, когда каждый вызов arun в ваших руках. Глубокий обход или пакет arun_many такой возможности не дают, поэтому привяжите решение к хуку after_goto. Он выполняется на странице Playwright сразу после навигации и до wait_for, для каждого URL, на который переходит краулер (вызовы с js_only его пропускают), так что страницы без виджета проходят насквозь.

from capskip import CapSkipError

async def after_goto(page, context, url, response, **kwargs):
    holder = await page.query_selector("div.g-recaptcha[data-sitekey]")
    if holder is None:
        return page   # no widget, nothing to do
    sitekey = await holder.get_attribute("data-sitekey")
    try:
        result = await solver.recaptcha(sitekey=sitekey, url=page.url)
    except CapSkipError:
        return page   # keep the page HTML if the solve fails
    async with page.expect_navigation():
        await page.evaluate(
            "t => { document.getElementById('g-recaptcha-response').value = t;"
            " document.querySelector('form').submit(); }", result["code"])
    return page

crawler.crawler_strategy.set_hook("after_goto", after_goto)

Об этом пути стоит знать несколько вещей. Хук выполняется внутри обхода, поэтому время решения прибавляется к этой странице, а если на виджет одновременно натыкаются несколько страниц, каждая ждёт своего решения, и AsyncCapSkip обрабатывает их бок о бок. Хук глобален для всего краулера, поэтому проверка должна быть дешёвой: её выполняет каждая страница. И блок try здесь важен: исключение, вылетевшее из хука, валит весь обход этого URL, и HTML не возвращается вовсе, так что без try сбой CapSkip опустошил бы каждую защищённую страницу в глубоком обходе. Ещё одна ловушка: Crawl4AI сохраняет код статуса первого ответа. Проверка, отданная с кодом 403, всё равно возвращается с success, равным False, и с Blocked by anti-bot protection, даже если хук её решил и в result.html лежит страница, которая за ней скрывалась. На таких URL ищите в html свой контент, а не доверяйте success. Каждый хук и его аргументы Crawl4AI описывает на своей странице о хуках. Тот же хук подходит любому краулеру на базе Playwright, а более широкая картина изложена на странице сервиса распознавания капч для Playwright.

Где работает CapSkip, когда краулер на другой машине

Когда обход разрастается, Crawl4AI часто оказывается на Linux-сервере или в контейнере, а CapSkip представляет собой приложение для Windows, так что они нередко живут на разных машинах. Именно для этого и нужен режим Server. Он заставляет CapSkip слушать ваш сетевой адрес или публичный IP вместо 127.0.0.1, и краулер обращается к нему по тому же HTTP API отовсюду, откуда до него есть маршрут. Если этот маршрут идёт через интернет, используйте статический публичный IP, включите проверку API-ключа и ограничьте порт адресами краулера с помощью правила Windows Firewall. Это по-прежнему машина, которой владеете вы, и решение на ней по-прежнему не тарифицируется.

SDK сам не читает переменные окружения. Читайте CAPSKIP_HOST, CAPSKIP_PORT и CAPSKIP_API_KEY в своём коде и передавайте их явно, как это делает полный пример.

Одну деталь Crawl4AI нужно учесть заранее: начиная с 0.9.0 его Docker-сервер отклоняет session_id, js_code и js_code_before_wait с HTTP 400, если они приходят по сети, и больше не принимает код хуков. Ни один из путей в этом руководстве не пройдёт через REST API, поэтому используйте библиотеку в своём собственном процессе Python.

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

# pip install crawl4ai capskip
import asyncio
import json
import os
import re
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
from capskip import AsyncCapSkip

PAGE_URL = "https://example.com/signup"
SESSION = "signup"
WIDGET = r'<[^>]*class="(?:[^"]*\s)?g-recaptcha(?:\s[^"]*)?"[^>]*>'

solver = AsyncCapSkip(
    apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
    host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.getenv("CAPSKIP_PORT", "8080")),
)

async def main():
    async with AsyncWebCrawler() as crawler:
        first = await crawler.arun(
            PAGE_URL, config=CrawlerRunConfig(session_id=SESSION))
        widget = re.search(WIDGET, first.html)
        match = widget and re.search(r'data-sitekey="([^"]+)"', widget.group(0))
        if not match:
            raise RuntimeError(f"no sitekey found: {first.error_message}")

        result = await solver.recaptcha(sitekey=match.group(1), url=PAGE_URL)

        inject = (
            "document.getElementById('g-recaptcha-response').value = "
            f"{json.dumps(result['code'])};"
            "document.querySelector('form').submit();"
        )
        done = await crawler.arun(PAGE_URL, config=CrawlerRunConfig(
            session_id=SESSION,
            js_only=True,
            js_code_before_wait=inject,
            wait_for="css:.signup-complete",
            wait_for_timeout=30000,
        ))
        print(done.success)   # True once the next page has loaded

asyncio.run(main())

wait_for_timeout задаёт ожиданию после отправки собственный лимит, так что отправка, которая никуда не ведёт, падает через 30 секунд, а не через 60 секунд page_timeout. Замените .signup-complete на что угодно, что есть только на странице после формы. Об остальном ландшафте Python, включая Selenium и обычные HTTP-клиенты, читайте на странице сервиса распознавания капч для Python.

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

Что вы видитеПричинаИсправить
Wait condition failed примерно через 60 секунд, а форма так и не отправиласьОтправка стоит в js_code, который с 0.8.5 выполняется после wait_forПеренесите скрипт в js_code_before_wait
first.success равен False с Blocked by anti-bot protectionПроверка блокировок в Crawl4AI приняла страницу с капчей за страницу блокировкиВсё равно считайте sitekey из first.html; страница цела
wait_for уходит в таймаут на втором вызове, а html содержит пустую страницуsession_id отличается, поэтому Crawl4AI открыл новую пустую вкладкуИспользуйте один и тот же session_id в обоих вызовах
Токен в textarea есть, но сайт сообщает, что капча не пройденаСайт отправляет форму через функцию из data-callback, или токен истёкВызовите callback с токеном и отправьте форму в течение двух минут после решения
Никакой ошибки, а потом ожидание уходит в таймаутСкрипт вставки выбросил исключение внутри страницы, а Crawl4AI молча это проглатываетСначала выполните скрипт в консоли DevTools и проверьте ID, которые он использует
HTTP 400 от Docker-сервера Crawl4AIСервер отклоняет session_id и поля со скриптами, пришедшие по сетиЗапускайте библиотеку в своём собственном процессе Python
NetworkException при решенииCapSkip не запущен либо хост и порт указывают не на ту машинуЗапустите CapSkip и используйте режим Server, если краулер находится на другой машине
TimeoutException при решенииРешение заняло больше, чем recaptchaTimeoutПоднимите его в конструкторе выше значения по умолчанию в 300 секунд

FAQ

Решает ли Crawl4AI капчи сам?

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

Работает ли тот же сценарий для Cloudflare Turnstile?

Для виджета да. Вызовите solver.turnstile с sitekey и URL страницы и запишите токен в скрытый input с именем cf-turnstile-response вместо textarea reCAPTCHA. Полностраничные проверки представляют собой отдельную задачу, потому что их токен должен идти вместе с user agent, который возвращает CapSkip, а значит, браузер, отправляющий его, должен предъявить тот же самый user agent. Этот случай разобран отдельно: загляните на страницу решателя Cloudflare Turnstile.

Мой краулер работает в контейнере. Куда ставить CapSkip?

На машину с Windows, которой вы управляете, с включённым режимом Server. Контейнер после этого обращается к нему через API, как к любому другому внутреннему сервису, поэтому краулеру и решателю не нужна общая операционная система. Передайте адрес машины с Windows в CAPSKIP_HOST, включите проверку API-ключа и выдайте краулеру собственный ключ, чтобы его можно было отозвать отдельно.

Сколько сессия остаётся открытой между двумя вызовами?

Гораздо дольше, чем длится решение. Менеджер браузера в исходниках 0.9.4 убирает сессии, которые не использовались 30 минут, а закрытие краулера закрывает их все. На практике важен лимит токена, около двух минут, поэтому решение и отправка должны следовать друг за другом без паузы.

Коротко

Вот весь процесс решения капчи в Crawl4AI в одном абзаце. Загрузите страницу с session_id и считайте sitekey из HTML, даже если Crawl4AI называет обход заблокированным. Решите капчу через AsyncCapSkip. Вернитесь в ту же вкладку с js_only=True, вставьте токен и отправьте форму из js_code_before_wait, а следующую страницу ждите через wait_for с собственным таймаутом. Для глубоких обходов делайте то же самое из хука after_goto. Если краулер работает на другой машине, направьте клиент на адрес в режиме Server.

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