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