Integrations

Crawlee + CaptchaAI: интеграция современной платформы парсинга

Crawlee исправно тянет прокси, повторы запросов и очередь, но CAPTCHA он не решает — это отдельная задача, которую нужно закрыть самостоятельно. Ниже — три рабочих сценария подключения CaptchaAI к Crawlee: обычный CheerioCrawler, PlaywrightCrawler для JS-страниц и пул сессий, который переживает решённую CAPTCHA между запросами. Код можно вставить в существующего паука без переписывания архитектуры.


Зачем подключать CaptchaAI к Crawlee

Возможность Crawlee Что это даёт вместе с CaptchaAI
SessionPool (пул сессий) Одни и те же cookie и заголовки для всех запросов после решения CAPTCHA
Автоматический повтор запросов Запрос уходит на повтор сразу после того, как CaptchaAI вернул токен
Ротация прокси Работает вместе с прокси на стороне CaptchaAI без конфликтов настроек
RequestQueue (очередь запросов) Решение CAPTCHA становится обычным шагом очереди, а не отдельным скриптом сбоку

Каждая из этих возможностей уже встроена в Crawlee — добавить нужно только вызов CaptchaAI в нужной точке обработчика запроса. Ниже разобраны три такие точки: requestHandler обычного краулера, requestHandler браузерного краулера и слушатель пула сессий.


Шаг 1: базовая интеграция в CheerioCrawler

Самый простой случай — статическая страница с формой, где reCAPTCHA v2 подключена через data-sitekey. Обработчик проверяет наличие виджета, отправляет sitekey и адрес страницы в CaptchaAI, дожидается токена и подставляет его в форму:

const { CheerioCrawler } = require('crawlee');
const https = require('https');

const API_KEY = process.env.CAPTCHAAI_API_KEY;

async function solveCaptcha(sitekey, pageurl) {
    // Submit task
    const submitData = new URLSearchParams({
        key: API_KEY,
        method: 'userrecaptcha',
        googlekey: sitekey,
        pageurl: pageurl,
        json: '1',
    });

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
        method: 'POST',
        body: submitData,
    });
    const submitResult = await submitResp.json();

    if (submitResult.status !== 1) {
        throw new Error(`Submit error: ${submitResult.request}`);
    }

    const taskId = submitResult.request;

    // Poll for result
    await new Promise(r => setTimeout(r, 15000));

    for (let i = 0; i < 24; i++) {
        const pollResp = await fetch(
            `https://ocr.captchaai.com/res.php?key=${API_KEY}&action=get&id=${taskId}&json=1`
        );
        const pollResult = await pollResp.json();

        if (pollResult.status === 1) return pollResult.request;
        if (pollResult.request !== 'CAPCHA_NOT_READY') {
            throw new Error(`Solve error: ${pollResult.request}`);
        }

        await new Promise(r => setTimeout(r, 5000));
    }

    throw new Error('Solve timeout');
}

// Crawlee spider with CAPTCHA handling
const crawler = new CheerioCrawler({
    maxConcurrency: 5,
    requestHandlerTimeoutSecs: 180,

    async requestHandler({ request, $, log }) {
        // Check if page has CAPTCHA
        const captchaDiv = $('[data-sitekey]');

        if (captchaDiv.length > 0) {
            const sitekey = captchaDiv.attr('data-sitekey');
            log.info(`CAPTCHA found on ${request.url}, solving...`);

            const token = await solveCaptcha(sitekey, request.url);
            log.info('CAPTCHA solved, submitting form');

            // Submit form with token
            const formData = new URLSearchParams({
                'g-recaptcha-response': token,
            });

            const resp = await fetch(request.url, {
                method: 'POST',
                body: formData,
            });
            const html = await resp.text();
            // Parse the result page...
        }

        // Extract data
        const title = $('title').text();
        const data = $('table tr').map((i, row) => ({
            col1: $(row).find('td:eq(0)').text().trim(),
            col2: $(row).find('td:eq(1)').text().trim(),
        })).get();

        log.info(`Scraped ${data.length} rows from ${request.url}`);
    },

    failedRequestHandler({ request, log }) {
        log.error(`Failed: ${request.url}`);
    },
});

// Run
(async () => {
    await crawler.run([
        'https://example.com/page1',
        'https://example.com/page2',
    ]);
})();

Опрос res.php в примере выше ждёт 15 секунд перед первым запросом и повторяет ещё до 24 раз с интервалом 5 секунд — итого до 135 секунд ожидания. На практике reCAPTCHA v2 у CaptchaAI обычно решается быстрее: SLA-потолок — менее 60 секунд, так что запас в коде оставлен намеренно, под пиковую нагрузку.


Шаг 2: PlaywrightCrawler для JS-страниц с CAPTCHA

Если виджет reCAPTCHA появляется скриптом уже после рендера — например, через несколько секунд после DOMContentLoaded, — CheerioCrawler его просто не увидит: он не выполняет JavaScript. Здесь нужен PlaywrightCrawler:

const { PlaywrightCrawler } = require('crawlee');

const crawler = new PlaywrightCrawler({
    maxConcurrency: 3,
    requestHandlerTimeoutSecs: 180,
    launchContext: {
        launchOptions: {
            headless: true,
            args: ['--disable-blink-features=AutomationControlled'],
        },
    },

    async requestHandler({ request, page, log }) {
        await page.goto(request.url, { waitUntil: 'networkidle' });

        // Check for reCAPTCHA
        const sitekey = await page.evaluate(() => {
            const el = document.querySelector('[data-sitekey]');
            return el ? el.getAttribute('data-sitekey') : null;
        });

        if (sitekey) {
            log.info(`CAPTCHA detected, solving for ${request.url}`);

            const token = await solveCaptcha(sitekey, request.url);

            // Inject token
            await page.evaluate((t) => {
                const ta = document.querySelector('[name="g-recaptcha-response"]');
                if (ta) {
                    ta.style.display = 'block';
                    ta.value = t;
                }
                // Trigger callback
                const widget = document.querySelector('.g-recaptcha');
                if (widget) {
                    const cb = widget.getAttribute('data-callback');
                    if (cb && typeof window[cb] === 'function') {
                        window[cb](t);
                    }
                }
            }, token);

            await page.click('button[type="submit"]');
            await page.waitForNavigation({ waitUntil: 'networkidle' });
        }

        // Extract data
        const title = await page.title();
        const content = await page.textContent('body');
        log.info(`Page: ${title}, length: ${content.length}`);
    },
});

После решения токен подставляется в скрытое поле g-recaptcha-response и, если у виджета задан data-callback, дополнительно вызывается сам callback — иначе форма может не понять, что CAPTCHA пройдена, и заблокировать отправку.


Шаг 3: решение CAPTCHA с учётом пула сессий

Если сайт показывает CAPTCHA не всем запросам подряд, а только части сессий — например, после нескольких обращений с одного IP, — есть смысл хранить результат решения не в глобальной переменной, а в session.userData конкретной сессии Crawlee. Тогда повторное решение не потребуется, пока сессия остаётся рабочей:

const { CheerioCrawler, Session } = require('crawlee');

const crawler = new CheerioCrawler({
    useSessionPool: true,
    sessionPoolOptions: {
        maxPoolSize: 10,
        sessionOptions: {
            maxUsageCount: 50,
        },
    },

    async requestHandler({ request, $, session, log }) {
        // If blocked, solve CAPTCHA and mark session as usable
        if ($('.captcha-container').length > 0) {
            const sitekey = $('[data-sitekey]').attr('data-sitekey');
            const token = await solveCaptcha(sitekey, request.url);

            // Store token in session for subsequent requests
            session.userData = session.userData || {};
            session.userData.captchaToken = token;
            session.userData.tokenTime = Date.now();

            log.info('CAPTCHA solved, session updated');
        }

        // Normal scraping
        const items = $('div.item').map((i, el) => ({
            name: $(el).find('.name').text().trim(),
            price: $(el).find('.price').text().trim(),
        })).get();

        log.info(`Found ${items.length} items`);
    },
});

useSessionPool в этом примере держит до 10 сессий с лимитом в 50 запросов каждая — подберите оба числа под реальный порог блокировки конкретного сайта, а не оставляйте значения по умолчанию.


Практический пример: мониторинг цен на маркетплейсе

Частый сценарий для Crawlee — регулярный обход карточек товаров на маркетплейсе, где часть страниц закрывается reCAPTCHA v2 после нескольких запросов подряд с одного прокси. Комбинация из ротации прокси, SessionPool и обработчика CaptchaAI из шага 3 закрывает это без ручного вмешательства: краулер продолжает обход, решая CAPTCHA только на тех сессиях, которым она действительно показана.

Для команд, которые разворачивают Crawlee в европейских дата-центрах или ближе к Казахстану и Центральной Азии, стоит заложить запас по requestHandlerTimeoutSecs — нестабильные сети партнёров увеличивают задержку между решением CAPTCHA и подтверждением формы. И отдельно: собирайте с карточек товара только те данные, которые нужны для мониторинга цен, — если в парсинг попадают персональные данные пользователей (отзывы, имена), для аудитории из РФ это уже вопрос 152-ФЗ «О персональных данных», а не только вежливости к чужому серверу.


Частые ошибки при интеграции Crawlee и CaptchaAI

  • Слишком короткий requestHandlerTimeoutSecs. Опрос res.php может занять больше минуты; если тайм-аут обработчика меньше времени решения CAPTCHA, Crawlee пометит запрос как проваленный ещё до того, как CaptchaAI успеет ответить.
  • Общий селектор sitekey на несколько виджетов. Если на странице несколько форм с CAPTCHA, $('[data-sitekey]') может найти не тот элемент — ищите data-sitekey внутри контейнера конкретной формы, а не по всему документу.
  • Заголовок запроса не выставлен вручную. URLSearchParams в примерах выше сам расставляет application/x-www-form-urlencoded, но если отправку токена переносите на другой HTTP-клиент, заголовок нужно проставить явно — иначе in.php вернёт ошибку разбора параметров.

Частые вопросы

Обрабатывает ли Crawlee CAPTCHA сам, без сторонних сервисов?

Нет. Crawlee отвечает за сессии, прокси и повторы запросов, а решение CAPTCHA нужно подключать отдельно — например, через CaptchaAI.

Нужно ли отключать ротацию прокси Crawlee при подключении CaptchaAI?

Нет, это независимые механизмы: прокси Crawlee меняет исходящий IP, а CaptchaAI решает саму CAPTCHA по sitekey и pageurl. Они не конфликтуют и обычно используются вместе, особенно при высоком maxConcurrency.

Сколько потоков CaptchaAI нужно для очереди Crawlee?

Зависит от maxConcurrency краулера и доли страниц с CAPTCHA. При пяти параллельных сессиях, где CAPTCHA встречается не на каждой странице, обычно хватает плана BASIC ($15/мес, 5 потоков); при maxConcurrency: 50 и частых CAPTCHA присмотритесь к ADVANCE ($90/мес, 50 потоков) — CaptchaAI тарифицирует по одновременным потокам, а не по числу решённых CAPTCHA.

Можно ли развернуть Crawlee с CaptchaAI в Apify?

Да. CaptchaAI вызывается по HTTP из actor'а Crawlee на Apify так же, как из любого Node.js-процесса — ключ API достаточно передать через переменную окружения Apify, менять код интеграции не нужно.

Что делать, если res.php долго возвращает CAPCHA_NOT_READY?

Это штатный статус, пока CaptchaAI ещё решает задачу, — ошибкой он становится, только если цикл опроса исчерпан. Если CAPCHA_NOT_READY держится дольше SLA-потолка reCAPTCHA v2 (около 60 секунд), проверьте, не устарел ли sitekey, и совпадает ли pageurl в запросе с реальным адресом страницы.


Похожие материалы


Подключите решение CAPTCHA к своему Crawlee-конвейеру — оформите API-ключ CaptchaAI.

Комментарии для этой статьи отключены.