Как обработать капчу в 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 и CaptchaFox.

Типичные ошибки с капчей в 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 и CaptchaFox. Передайте 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 работает как распознавание капчи , который работает на вашей машине, поэтому добавление его в обход не стоит вам ни одного лишнего сетевого хопа.