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

Обработке капчи в Scrapy место в downloader middleware, а не в пауке. Middleware видит каждый ответ, поэтому может один раз распознать челлендж, решить его и вернуть пауку настоящую страницу, будто ничего не произошло. Ваши parse-методы остаются чистыми. В этом руководстве мы напишем такой middleware, не дадим решению блокировать событийный цикл и разберём настройки, от которых зависит, выдержит ли всё это нагрузку или остановит ваш парсинг.
Что понадобится
- Scrapy 2.x и Python 3.10 или новее
- Локально запущенный CapSkip с включённым API server. См. руководство по настройке
- Python SDK:
pip install capskip
Всё ниже исходит из того, что решатель доступен на 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 imagenormal() принимает также путь к файлу или удалённый URL. В Scrapy вам нужна именно форма data URI, потому что картинка обычно корректно отрисовывается только для той сессии, которая её запросила. Учтите, что для графических капч прокси не поддерживаются, только для reCAPTCHA, Turnstile и GeeTest.
Типичные ошибки с капчей в Scrapy
| Симптом | Причина | Исправить |
|---|---|---|
| Пропускная способность обхода падает | Блокирующее решение в событийном цикле | Использовать AsyncCapSkip или deferToThread |
| Повторно отправленный запрос никогда не выполняется | Фильтр дублей его отбросил | Добавить dont_filter=True |
| Одна и та же страница выдаёт челлендж бесконечно | Cookie отключены, или meta не передаётся дальше | Включите cookie, слейте request.meta в новый запрос |
ERROR_GOOGLEKEY | sitekey устарел или захардкожен | Читайте его из живого ответа каждый раз |
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 — это распознавание капчи , который работает на вашей машине, поэтому добавление его в обход не стоит вам ни одного лишнего сетевого хопа.
