Как обработать капчу в Scrapy с помощью downloader middleware

scrapy captcha - How to Handle a Scrapy CAPTCHA with Downloader Middleware

Обработке капчи в Scrapy место в downloader middleware, а не в пауке. Middleware видит каждый ответ, поэтому может один раз распознать челлендж, решить его и вернуть пауку настоящую страницу, будто ничего не произошло. Ваши parse-методы остаются чистыми. В этом руководстве мы напишем такой middleware, не дадим решению блокировать событийный цикл и разберём настройки, от которых зависит, выдержит ли всё это нагрузку или остановит ваш парсинг.

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

Всё ниже исходит из того, что решатель доступен на 127.0.0.1:8080. Ничего не покидает вашу машину, а в парсере это важнее обычного: вы и так шлёте много запросов, и каждый поход к стороннему сервису добавляет задержку к каждому из них.

Почему обработке капчи в Scrapy место в middleware

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

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

Порядок важен, и именно здесь чаще всего ошибаются. Scrapy вызывает process_request в порядке возрастания, а process_response — в порядке убывания . Регистрация на 585 означает, что наш middleware увидит ответы раньше, чем RetryMiddleware на 550.

# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.CaptchaMiddleware": 585,
}

# The async client needs Scrapy's asyncio reactor.
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"

Шаг 1: распознать челлендж

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

# Markers for the two widgets you will hit most often.
CAPTCHA_MARKERS = ("g-recaptcha", "cf-turnstile")

def looks_like_captcha(response):
    if response.status in (403, 429):
        return True
    # Only touch the body for HTML; binary responses have no text.
    ctype = response.headers.get("Content-Type", b"").decode()
    if "html" not in ctype:
        return False
    return any(m in response.text for m in CAPTCHA_MARKERS)

Пусть эта функция остаётся скучной и дешёвой. Она выполняется на каждом ответе за весь обход.

Шаг 2: вытащить sitekey со страницы

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

def extract_sitekey(response):
    # reCAPTCHA v2 and invisible both use data-sitekey.
    key = response.css(".g-recaptcha::attr(data-sitekey)").get()
    if key:
        return "recaptcha", key
    key = response.css(".cf-turnstile::attr(data-sitekey)").get()
    if key:
        return "turnstile", key
    return None, None

Если виджет вставляется через JavaScript, sitekey не окажется в HTML, который скачал Scrapy. Это проблема рендеринга, а не решения, и обычно означает, что ключ придётся доставать из тега script регулярным выражением.

Шаг 3: решить, не блокируя обход

Именно этот шаг тихо убивает пропускную способность. Scrapy работает на однопоточном событийном цикле. Решение занимает секунды, поэтому вызов блокирующего клиента прямо в middleware замораживает на всё это время все остальные запросы в работе. При CONCURRENT_REQUESTS равном 16 вы только что сериализовали все 16.

Есть два правильных выхода. В Python SDK есть AsyncCapSkip, настоящий асинхронный клиент, а не алиас, поэтому под asyncio-реактором его можно ждать напрямую:

# pip install capskip
from capskip import AsyncCapSkip

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

# Submit and poll both happen inside this await.
result = await solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)
token = result["code"]

Если вы всё ещё на классическом реакторе Twisted, вынесите блокирующий клиент в поток и дождитесь Deferred:

from capskip import CapSkip
from twisted.internet.threads import deferToThread

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

# deferToThread keeps the reactor free while the solve runs.
result = await deferToThread(
    solver.recaptcha,
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

Оба варианта работают, потому что Scrapy позволяет любому методу downloader middleware быть корутиной. Объявите его через async def — остальное Scrapy сделает сам.

Шаг 4: повторно отправить с токеном

Сам по себе токен ничего не даёт. Его нужно вернуть сайту так, как это сделал бы его собственный фронтенд: для классической формы это поле с именем g-recaptcha-response.

Верните новый Request из process_response , и Scrapy поставит его в очередь заново. Две детали уберегут от ошибок: dont_filter=True, потому что этот URL уже встречался и фильтр дублей его отбросит, и счётчик в meta , чтобы сайт, который продолжает выдавать челлендж, не загнал вас в бесконечный цикл.

from scrapy import FormRequest

def resubmit(response, token, tries):
    return FormRequest.from_response(
        response,
        formdata={"g-recaptcha-response": token},
        dont_filter=True,          # the dupe filter has seen this URL
        meta={"captcha_tries": tries + 1},
    )

Middleware целиком

# myproject/middlewares.py
import logging

from capskip import (
    AsyncCapSkip, ApiException, NetworkException, TimeoutException)
from scrapy import FormRequest
from scrapy.exceptions import IgnoreRequest

logger = logging.getLogger(__name__)
MAX_CAPTCHA_TRIES = 2


class CaptchaMiddleware:
    def __init__(self):
        self.solver = AsyncCapSkip(host="127.0.0.1", port=8080)

    async def process_response(self, request, response, spider):
        if not looks_like_captcha(response):
            return response

        tries = request.meta.get("captcha_tries", 0)
        if tries >= MAX_CAPTCHA_TRIES:
            raise IgnoreRequest("captcha not cleared: %s" % request.url)

        kind, sitekey = extract_sitekey(response)
        if not sitekey:
            return response          # not a shape we handle

        try:
            if kind == "turnstile":
                result = await self.solver.turnstile(
                    sitekey=sitekey, url=response.url)
            else:
                result = await self.solver.recaptcha(
                    sitekey=sitekey, url=response.url)
        except (ApiException, NetworkException, TimeoutException) as e:
            logger.warning("solve failed for %s: %s", request.url, e)
            return response

        logger.info("solved %s captcha for %s", kind, request.url)

        return FormRequest.from_response(
            response,
            formdata={"g-recaptcha-response": result["code"]},
            dont_filter=True,
            meta={**request.meta, "captcha_tries": tries + 1},
        )

SDK бросает четыре типа исключений: ValidationException, NetworkException, ApiException и TimeoutException. Три перечисленных выше происходят во время выполнения; а ValidationException означает, что у вас неверные параметры, и он должен громко падать на этапе разработки, а не проглатываться. Возврат исходного ответа при сбое позволяет остальному конвейеру решить, что делать, и это лучше, чем ронять паука.

Настройки, которые действительно важны

НастройкаЗачем
CONCURRENT_REQUESTS_PER_DOMAINСнизьте его. Попадание на капчу обычно сигнал о скорости, и более быстрое решение не устраняет причину челленджа
DOWNLOAD_DELAY плюс AUTOTHROTTLE_ENABLEDДешевле, чем решать. Каждый челлендж, которого вы избежали, стоит ноль
COOKIES_ENABLEDДолжны оставаться включёнными. Именно cookie доступа, полученная после решённого челленджа, избавляет следующий запрос от нового челленджа
RETRY_TIMESНезависим от вашего счётчика капчи. Держите два лимита раздельно, иначе они перемножатся

Третья строка — то, что стоит усвоить. Если cookie выключены, каждый запрос выглядит как первый визит, и вы будете вечно решать один и тот же челлендж. Большинство описываемых людьми бесконечных циклов с капчей в Scrapy сводятся именно к этому.

Графические капчи внутри паука

Некоторые сайты используют в форме входа обычную картинку с искажённым текстом. Здесь нет sitekey, поэтому всё проще: скачайте картинку и передайте байты как data URI.

import base64
import scrapy
from capskip import CapSkip

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

class LoginSpider(scrapy.Spider):
    def parse_captcha_image(self, response):
        # response.body is the raw image, fetched with session cookies.
        b64 = base64.b64encode(response.body).decode()
        result = solver.normal("data:image/png;base64," + b64)
        return result["code"]     # the text on the image

normal() принимает также путь к файлу или удалённый URL. В Scrapy вам нужна именно форма data URI, потому что картинка обычно корректно отрисовывается только для той сессии, которая её запросила. Учтите, что для графических капч прокси не поддерживаются, только для reCAPTCHA, Turnstile и GeeTest.

Типичные ошибки с капчей в Scrapy

СимптомПричинаИсправить
Пропускная способность обхода падаетБлокирующее решение в событийном циклеИспользовать AsyncCapSkip или deferToThread
Повторно отправленный запрос никогда не выполняетсяФильтр дублей его отбросилДобавить dont_filter=True
Одна и та же страница выдаёт челлендж бесконечноCookie отключены, или meta не передаётся дальшеВключите cookie, слейте request.meta в новый запрос
ERROR_GOOGLEKEYsitekey устарел или захардкоженЧитайте его из живого ответа каждый раз
NetworkException на каждом решенииCapSkip не запущен или порт отличаетсяЗапустите приложение, проверьте порт в Settings

Каждая строка ошибки, которую может вернуть API, перечислена в Документация по API. Собственная справка по downloader middleware от Scrapy полностью описывает правила порядка.

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

Работает ли это со стандартным реактором Scrapy?

Да, но только вариант с deferToThread . AsyncCapSkip — asyncio-клиент, поэтому ему нужен TWISTED_REACTOR , выставленный в asyncio-реактор. Оба подхода не дают обходу встать.

Нужно ли решать каждую капчу в Scrapy, на которую я наткнулся?

Нет. Внезапная стена челленджей означает, что ваш паттерн обхода отметили. Сначала сбавьте темп. Пробиваться решением — значит лечить симптом, и обычно так зарабатывают более жёсткую блокировку.

Могу ли я направить решение через тот же прокси, что и запрос?

Да, для reCAPTCHA, Turnstile и GeeTest. Передайте proxy={"type": "HTTPS", "uri": "user:[email protected]:3128"} в вызов решения, чтобы токен генерировался с того же выходного IP, который видела страница.

Куда поставить middleware, если я использую ещё и прокси-middleware?

Ниже него в терминах process_response , то есть с бо́льшим числом. Прокси-middleware работают с запросами, наш — с ответами. На 585 он отрабатывает раньше, чем ответ увидит retry middleware.

Сводка

Капча в Scrapy — задача из пяти частей: распознать челлендж в middleware, прочитать sitekey из живого ответа, решить его, не блокируя реактор, отправить повторно с dont_filter=Trueи ограничить число попыток в meta. Сделайте это правильно, и пауки останутся чистыми, потому что всем занимается один файл.

Более широкую картину, как CapSkip встраивается в парсер, смотрите на странице распознавание капчи для веб-скрейпинга , в руководстве по интеграции с Pythonили в деталях самого виджета на странице Решатель reCAPTCHA v2 . CapSkip — это распознавание капчи , который работает на вашей машине, поэтому добавление его в обход не стоит вам ни одного лишнего сетевого хопа.