Integrations

Руководство по интеграции Scrapy + CaptchaAI

Проверку CAPTCHA в Scrapy закрывают не в пауке, а в downloader middleware: паук продолжает отдавать чистый parse, а middleware перехватывает ответ, находит на странице data-sitekey, отправляет задачу в CaptchaAI и кладёт токен в request.meta. Ниже — рабочая связка из трёх файлов: модуль решателя, само middleware и правки в settings.py.

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

Что понадобится перед стартом

Требование Подробности
Python 3.8+
Scrapy 2.5+
requests Для вызовов API CaptchaAI
API-ключ CaptchaAI Получить ключ
pip install scrapy requests

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

Шаг 1: модуль решателя CaptchaAI

Создайте captcha_solver.py в корне проекта Scrapy. Класс делает две вещи: отправляет задачу в in.php и опрашивает res.php, пока не придёт токен или не истечёт тайм-аут.

import requests
import time


class CaptchaAISolver:
    def __init__(self, api_key):
        self.api_key = api_key
        self.base_url = "https://ocr.captchaai.com"

    def solve_recaptcha(self, site_key, page_url, timeout=300):
        resp = requests.get(f"{self.base_url}/in.php", params={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": site_key,
            "pageurl": page_url,
        })

        if not resp.text.startswith("OK|"):
            raise Exception(f"Submit failed: {resp.text}")

        task_id = resp.text.split("|")[1]
        deadline = time.time() + timeout

        while time.time() < deadline:
            time.sleep(5)
            result = requests.get(f"{self.base_url}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
            })

            if result.text == "CAPCHA_NOT_READY":
                continue
            if result.text.startswith("OK|"):
                return result.text.split("|", 1)[1]
            raise Exception(f"Solve failed: {result.text}")

        raise TimeoutError(f"Task {task_id} timed out")

    def solve_image(self, image_base64, timeout=120):
        resp = requests.get(f"{self.base_url}/in.php", params={
            "key": self.api_key,
            "method": "base64",
            "body": image_base64,
        })

        if not resp.text.startswith("OK|"):
            raise Exception(f"Submit failed: {resp.text}")

        task_id = resp.text.split("|")[1]
        deadline = time.time() + timeout

        while time.time() < deadline:
            time.sleep(5)
            result = requests.get(f"{self.base_url}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
            })

            if result.text == "CAPCHA_NOT_READY":
                continue
            if result.text.startswith("OK|"):
                return result.text.split("|", 1)[1]
            raise Exception(f"Solve failed: {result.text}")

        raise TimeoutError(f"Task {task_id} timed out")

Здесь важны два момента. CAPCHA_NOT_READY — штатный ответ, а не ошибка: именно так API сообщает, что задача ещё в очереди. И интервал опроса в 5 секунд подобран не случайно — reCAPTCHA v2 решается за время до 60 секунд, так что более частый опрос только нагружает соединение без выигрыша.

Шаг 2: downloader middleware для Scrapy

Создайте middlewares.py. Метод process_response вызывается для каждого ответа: если на странице нашёлся data-sitekey, middleware решает reCAPTCHA v2 и кладёт токен в request.meta["captcha_token"]; если найдена картинка-капча в base64 — распознаёт её через OCR-метод.

import base64
import re
from scrapy import signals
from scrapy.http import HtmlResponse
from captcha_solver import CaptchaAISolver


class CaptchaAIMiddleware:
    """Scrapy downloader middleware that detects and solves CAPTCHAs."""

    def __init__(self, api_key):
        self.solver = CaptchaAISolver(api_key)

    @classmethod
    def from_crawler(cls, crawler):
        api_key = crawler.settings.get("CAPTCHAAI_API_KEY")
        if not api_key:
            raise ValueError("CAPTCHAAI_API_KEY setting is required")
        return cls(api_key)

    def process_response(self, request, response, spider):
        # Check for reCAPTCHA on the page
        site_key = self._find_recaptcha_key(response.text)
        if site_key:
            spider.logger.info(f"reCAPTCHA detected on {response.url}")
            token = self.solver.solve_recaptcha(site_key, response.url)
            request.meta["captcha_token"] = token
            spider.logger.info("CAPTCHA solved successfully")

        # Check for image CAPTCHA
        captcha_img = self._find_image_captcha(response)
        if captcha_img:
            spider.logger.info(f"Image CAPTCHA detected on {response.url}")
            text = self.solver.solve_image(captcha_img)
            request.meta["captcha_text"] = text
            spider.logger.info(f"Image CAPTCHA solved: {text}")

        return response

    def _find_recaptcha_key(self, html):
        match = re.search(
            r'data-sitekey=["\']([A-Za-z0-9_-]+)["\']', html
        )
        return match.group(1) if match else None

    def _find_image_captcha(self, response):
        img = response.css("img#captcha-image::attr(src)").get()
        if img and img.startswith("data:image"):
            return img.split(",", 1)[1]
        return None

Ветку с изображением стоит оставить, даже если сейчас вы её не используете: в реальных краулерах текстовая или grid-капча всплывает на страницах логина и пагинации чаще, чем reCAPTCHA.

Шаг 3: подключение в settings.py

import os

CAPTCHAAI_API_KEY = os.environ.get("CAPTCHAAI_API_KEY")

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.CaptchaAIMiddleware": 560,
}

Приоритет 560 ставит middleware после HttpCompressionMiddleware (590 в стеке по умолчанию), то есть на вход приходит уже распакованный HTML — иначе регулярка не найдёт data-sitekey в сжатом теле ответа.

Шаг 4: паук, который использует токен

import scrapy


class ProductSpider(scrapy.Spider):
    name = "products"
    start_urls = ["https://example.com/products"]

    def parse(self, response):
        # If CAPTCHA was solved, the token is in meta
        token = response.meta.get("captcha_token")
        if token:
            # Resubmit the page with the token
            yield scrapy.FormRequest(
                url=response.url,
                formdata={"g-recaptcha-response": token},
                callback=self.parse_products,
            )
        else:
            yield from self.parse_products(response)

    def parse_products(self, response):
        for product in response.css(".product-item"):
            yield {
                "name": product.css("h2::text").get(),
                "price": product.css(".price::text").get(),
                "url": response.urljoin(
                    product.css("a::attr(href)").get()
                ),
            }

        next_page = response.css("a.next-page::attr(href)").get()
        if next_page:
            yield scrapy.Request(response.urljoin(next_page))

Токен g-recaptcha-response действителен ограниченное время, поэтому повторную отправку формы делают сразу в том же цикле, а не откладывают в очередь на потом.

Шаг 5: повторы на страницах с проверкой

Даже при корректном детекте часть ответов будет возвращать страницу проверки вместо контента — из-за таймингов, редиректов или временного ограничения частоты запросов. Отдельное middleware с ограниченным числом повторов закрывает этот случай.

class CaptchaRetryMiddleware:
    """Retry requests that return CAPTCHA challenge pages."""

    max_retries = 3

    def process_response(self, request, response, spider):
        if self._is_captcha_page(response):
            retries = request.meta.get("captcha_retries", 0)
            if retries < self.max_retries:
                request.meta["captcha_retries"] = retries + 1
                spider.logger.info(
                    f"CAPTCHA page detected, retry {retries + 1}"
                )
                return request.copy()

        return response

    def _is_captcha_page(self, response):
        indicators = [
            "g-recaptcha",
            "cf-turnstile",
            "captcha-image",
            "Please verify you are human",
        ]
        return any(ind in response.text for ind in indicators)

Три попытки — разумный потолок. Если страница отдаёт проверку и на четвёртый раз, проблема почти наверняка не в капче, а в блокировке по IP или в частоте запросов.

Шаг 6: запуск краулера

export CAPTCHAAI_API_KEY="YOUR_API_KEY"
scrapy crawl products -o products.json

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

Типичная для команд из России, Беларуси и Казахстана задача — агрегатор, который каждую ночь обходит несколько тысяч карточек товаров у региональных площадок. Проверки там всплывают неравномерно: обычные страницы каталога отдаются свободно, а формы поиска и пагинация глубже пятой страницы упираются в reCAPTCHA v2.

Считается это просто. Пусть за ночной прогон проверка срабатывает на 300 страницах и каждая занимает поток до 60 секунд — в один поток это около пяти часов последовательной работы. При CONCURRENT_REQUESTS = 16 и тарифе STANDARD ($30/мес, 15 потоков) те же 300 проверок распределяются по потокам и перестают быть узким местом: пока один поток ждёт решения, остальные обходят страницы без проверок.

Ещё один момент, о котором стоит подумать до запуска, а не после: собирайте только те данные, которые вы вправе обрабатывать. Если в выдачу попадают карточки продавцов с именами и контактами, это уже персональные данные — требования 152-ФЗ и GDPR-подобных режимов для трансграничных проектов применяются к вашему пайплайну независимо от того, как вы проходите проверку на сайте. Это не юридическая консультация, а напоминание согласовать состав полей заранее.

Разбор типовых ошибок

Проблема Причина Решение
ValueError: CAPTCHAAI_API_KEY setting is required Не задана переменная окружения Экспортировать CAPTCHAAI_API_KEY перед запуском
CAPTCHA не обнаружена Другая структура HTML на целевой странице Обновить регулярку _find_recaptcha_key под фактическую разметку
TimeoutError при решении Медленная сеть или длинная очередь Увеличить timeout в решателе
Паук блокируется уже после решения Ограничение по IP или частоте запросов Снизить CONCURRENT_REQUESTS, добавить прокси в отдельном middleware
Токен принимается, но форма не проходит Токен просрочен между решением и отправкой Отправлять форму сразу после получения токена

Часто задаваемые вопросы

Какие типы CAPTCHA закроет эта схема?

Через solve_recaptcha идут reCAPTCHA v2 и v3, включая Enterprise-варианты; через solve_image — обычные и grid-капчи. Отдельными методами API поддерживаются Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3 и BLS CAPTCHA. CaptchaFox (beta), Friendly Captcha (beta) и Lemin (beta) доступны в бета-статусе. hCaptcha и FunCaptcha сервис не решает, GeeTest v4 заявлен как «скоро».

Сколько потоков нужно моему краулеру?

Ориентируйтесь на число проверок, которые выполняются одновременно, а не на общий объём страниц. Если middleware решает не больше пяти капч параллельно, хватит BASIC ($15/мес, 5 потоков); при десятках параллельных пауков смотрите на ADVANCE ($90/мес, 50 потоков). Число решений внутри тарифа не ограничено.

Работает ли middleware со Scrapy-Splash и Scrapy-Playwright?

Да, без изменений. Оба варианта отдают в process_response уже отрендеренный HTML, поэтому детект по data-sitekey и по img#captcha-image срабатывает так же, как на обычном ответе.

Как хранить API-ключ в CI и на проде?

Только как переменную окружения или секрет CI (CAPTCHAAI_API_KEY). Ключ, попавший в settings.py под контролем версий, придётся считать скомпрометированным и перевыпускать — поэтому в примере выше и стоит os.environ.get.

Почему опрос идёт раз в 5 секунд, а не чаще?

Более частый опрос не ускоряет решение: время работы решателя определяется типом проверки, а не частотой ваших запросов к res.php. Пятисекундный интервал даёт приемлемую задержку и не создаёт лишнего трафика.

Что почитать дальше

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