Как решить ALTCHA в Scrapy одним встроенным запросом

solve altcha in scrapy - How to Solve ALTCHA in Scrapy With One Inline Request

Чтобы решить ALTCHA в Scrapy, считайте адрес challenge с элемента altcha-widget, загрузите его встроенным запросом (inline request) из того же колбэка, передайте JSON методу altcha класса AsyncCapSkip и отправьте форму с токеном в поле, которое называет виджет (по умолчанию altcha). ALTCHA построена на доказательстве работы, а не на картинке, поэтому браузер здесь не нужен вообще, и обычного паука Scrapy достаточно. Единственную ловушку, характерную именно для Scrapy, легко пропустить: эндпоинт challenge каждый раз возвращает новый документ по тому же URL, а фильтр дубликатов Scrapy молча отбрасывает второй запрос к нему. В этом руководстве разобраны виджет, загрузка, решение и отправка, а также есть паук, которого можно запустить.

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

  • Scrapy 2.14 или новее, ради метода встроенных запросов, который используется ниже. С версии 2.13 Scrapy по умолчанию работает на asyncio-реакторе, и именно это позволяет колбэку ждать асинхронный клиент через await. Более старые версии разобраны в разделе ближе к концу.
  • Python-пакет capskip версии 1.2.0 или новее (в этом выпуске появился метод altcha) и CapSkip 1.3.0 или новее на машине с Windows.
  • Пакет form2request, который Scrapy теперь рекомендует для сборки отправки форм.
  • Страница с формой. Всё остальное, включая challenge, берётся с этой страницы, а откуда именно, показывает шаг 1.
  • Адрес решателя. Режим Local отвечает на 127.0.0.1 и только для этого устройства; режим Server слушает ваш сетевой адрес или публичный IP, чтобы краулер на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения, а о том, когда переключаться, рассказывает отдельный раздел ниже.
# pip install scrapy capskip form2request
pip install scrapy capskip form2request

Шаг 1: считываем challenge с виджета

У ALTCHA нет sitekey. Чтобы решить ALTCHA в Scrapy, нужен challenge или адрес, с которого он приходит, и элемент виджета несёт либо то, либо другое. Какой атрибут его хранит, зависит от поколения виджета, поэтому читайте все три.

ВиджетАтрибутЧто содержит
v1 и v2challengeurl или challengejsonchallengeurl указывает эндпоинт, который отдаёт challenge, часто в виде относительного пути; challengejson несёт сам документ challenge
v3 и новееchallengeЛибо этот эндпоинт, либо сам документ challenge
# Inside a spider callback; response is the page with the form.
widget = response.css("altcha-widget")
source = (widget.attrib.get("challenge")
          or widget.attrib.get("challengejson")
          or widget.attrib.get("challengeurl"))

# The token goes in a field named by the widget, altcha by default.
field = widget.attrib.get("name", "altcha")

# A v3 challenge or a v1/v2 challengejson carries the document inline.
inline = source.lstrip().startswith("{")

Обратите внимание и на атрибут name. Виджет записывает свою полезную нагрузку в скрытое поле с этим именем; по умолчанию это altcha, но сайт может его изменить. Чтение атрибута стоит одной строки и спасает от отправки, которая молча оставляет настоящее поле пустым. В некоторых развёртываниях challenge генерируется собственным скриптом страницы, а не отдаётся эндпоинтом, и тогда читать нечего. Какой вариант у вас, покажет вкладка Network.

Шаг 2: загружаем challenge внутри колбэка

Если виджет указывает на эндпоинт, загрузите его прямо из колбэка, в котором вы уже находитесь, методом download_async объекта engine; документация Scrapy описывает его в разделе про встроенные запросы (inline requests). Запрос проходит через downloader middleware, поэтому к нему применяются хранилище cookie, user agent и настройки прокси паука, как и к самой странице. Через планировщик он не проходит, и в этом весь смысл.

# The page's jar and proxy; no scheduler, no dupefilter.
meta = {"dont_cache": True, "allow_offsite": True}
for key in ("cookiejar", "proxy"):
    if key in response.meta:
        meta[key] = response.meta[key]
reply = await self.crawler.engine.download_async(
    scrapy.Request(response.urljoin(source), meta=meta,
                   headers={"Referer": response.url})
)
if reply.status != 200:
    raise ValueError(f"challenge endpoint answered {reply.status}")
challenge_json = reply.text

Вот почему важно обойти планировщик. Очевидная альтернатива (отдать через yield объект Request на загрузку challenge с собственным колбэком) срабатывает для первой формы и теряет вторую. Каждая загрузка эндпоинта возвращает новый challenge, но URL не меняется, поэтому фильтр дубликатов Scrapy считает второй запрос уже выполненным и отбрасывает его. Никаких ошибок. Фильтр один раз пишет в лог первое отбрасывание на уровне debug, увеличивает счётчик в статистике обхода, а стоящая за запросом форма просто никогда не отправляется. Встроенный запрос до фильтра не доходит вообще.

Остальная часть этого блока берёт на себя детали, которыми обычно занимается планировщик. Состояние отдельного запроса само по себе не переносится, поэтому цикл копирует ключ cookiejar или proxy из meta страницы, если страница была загружена с ним. Заголовок Referer задаётся вручную, потому что middleware, который его добавляет, работает на стороне паука. Если эндпоинт challenge находится на другом домене (например, это хостинговый сервис ALTCHA), а паук задаёт allowed_domains, фильтр offsite заблокировал бы загрузку, и allow_offsite пропускает именно этот запрос. Проверка статуса нужна потому, что ответ 403 от эндпоинта приходит здесь как обычный ответ: middleware, который превращает ошибочные статусы в сбои, работает на стороне паука, а встроенный запрос эту сторону пропускает. А dont_cache не подпускает HTTP-кэш Scrapy к challenge, если кэш у вас включён. Закэшированный challenge уже истёк, и CapSkip сразу отклоняет истёкший встроенный challenge, а не тратит CPU на токен, который сайт отвергнет. Без этого флага при втором запуске каждая страница с общим эндпоинтом получит один и тот же сохранённый challenge. Та же логика касается и страницы с формой. Закэшированная страница воспроизводит старый токен защиты от подделки запросов и старую сессионную cookie, а если challenge встроен в страницу, то ещё и старый challenge, поэтому при включённом кэше ставьте dont_cache и на страницы с формами или отключите кэш для этого паука.

Можно вместо этого передать эндпоинт как challenge_url, и CapSkip загрузит challenge сам. Для публичного эндпоинта это нормально. Но запрос CapSkip не несёт ни одной cookie вашего паука, а некоторые сайты выдают challenge только той сессии, которая загрузила форму. Загрузка через Scrapy сохраняет одну сессию от начала до конца.

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

Используйте AsyncCapSkip и объявите колбэк через async def. В Python-пакете этот класс представляет собой настоящий asyncio-клиент, а не алиас, поэтому await на нём возвращает управление Scrapy, пока идёт решение, и все остальные запросы продолжают двигаться. Синхронный класс CapSkip останавливал бы весь обход на время каждого решения; подробно эта проблема разобрана в руководстве по middleware для капчи в Scrapy.

from capskip import AsyncCapSkip

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

# Pass the document exactly as the endpoint sent it.
result = await solver.altcha(url=response.url, challenge_json=challenge_json)

token = result["token"]   # base64 payload for the form field

Поскольку challenge передаётся прямо в вызове, CapSkip вообще не делает сетевых запросов. Он хеширует, пока не найдёт ответ, что обычно занимает миллисекунды, а клиент опрашивает результат сразу и ещё раз через четверть секунды, так что типичное решение возвращается примерно за четверть секунды. Вызов ждёт до defaultTimeout, 120 секунд, потому что это работа для CPU, а не браузерная сессия. Два алгоритма challenge, Argon2id и scrypt, отклоняются, а не берутся в работу, и такой отказ приходит в виде ApiException меньше чем за секунду. Более распространённые схемы PBKDF2 и SHA, включая рекомендованный ALTCHA вариант по умолчанию, поддерживаются.

Передавайте текст ответа в том виде, в каком он пришёл; разбирать его не нужно. Менять нельзя токен: это base64 от документа JSON, поля которого подписал сервер сайта, поэтому обрезанный, декодированный или перекодированный токен не проходит проверку.

Шаг 4: отправляем форму с токеном

Скрытого поля, которое заполняет виджет, в загруженном Scrapy HTML нет. Виджет создаёт его в браузере после своего запуска, поэтому ничто, читающее отданный HTML, его не найдёт. Добавьте его сами под именем из шага 1. Пример собирает отправку через form2request, который Scrapy рекомендует вместо FormRequest.from_response: начиная со Scrapy 2.16 этот старый метод при каждом вызове пишет в лог предупреждение об устаревании.

from form2request import form2request

form = response.xpath("//form[.//altcha-widget]")
data = {"email": "[email protected]", field: token}
yield form2request(form, data).to_scrapy(
    callback=self.after_submit,
    priority=10,
    meta={"handle_httpstatus_all": True},
)

form2request сохраняет то, что страница уже положила в форму, например токен защиты от подделки запросов, и нажимает первую кнопку отправки, так что запрос выглядит как тот, что отправил бы браузер. XPath выбирает форму, в которой находится виджет, а это важно на страницах, где есть ещё и поле поиска. handle_httpstatus_all позволяет колбэку увидеть отклонённую отправку, которую Scrapy иначе отбросил бы до того, как она дойдёт. Повышенное значение priority ставит отправку впереди всего, что ещё ждёт в планировщике, потому что challenge истекают. У некоторых из них окно действия едва дотягивает до двух минут, и токен, который ждёт позади длинного обхода, может истечь раньше, чем уйдёт.

Считайте, что каждый токен годится для одной отправки. Если сайт отклоняет форму, начните заново с шага 2 с новым challenge, а не повторяйте тот же запрос. Некоторые интеграции передают полезную нагрузку в теле JSON или в cookie, а не в поле формы, поэтому один раз отправьте форму вручную с открытыми DevTools и повторите то, что отправляет страница.

Запуск решателя на другой машине

В примерах стоит 127.0.0.1, потому что это верно, пока паук и CapSkip делят одну машину. У краулера, развёрнутого в Scrapyd на другой машине, на VPS или на хостинговой платформе для Scrapy, loopback указывает на него самого, и первое же решение выбрасывает NetworkException. Переключите CapSkip в режим Server, и он начнёт слушать ваш сетевой адрес или публичный IP, так что паук сможет достучаться до него через API из любого разрешённого вами места. Если маршрут идёт через интернет, используйте статический публичный IP, включите проверку API-ключа и ограничьте порт ожидаемыми адресами с помощью правила Windows Firewall. Это по-прежнему ваша собственная машина с Windows, и решение капчи по-прежнему не тарифицируется.

Клиент сам не читает переменные окружения. Читайте CAPSKIP_HOST и CAPSKIP_API_KEY в пауке и передавайте их в конструктор, как это делает полный пример, чтобы один и тот же код работал и у вас на столе, и на сервере.

Полный рабочий пример

# pip install scrapy capskip form2request
import os

import scrapy
from capskip import AsyncCapSkip, CapSkipError
from form2request import form2request


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

    solver = AsyncCapSkip(
        apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
        host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
        port=8080,
    )

    async def parse(self, response):
        widget = response.css("altcha-widget")
        source = (widget.attrib.get("challenge")
                  or widget.attrib.get("challengejson")
                  or widget.attrib.get("challengeurl"))
        if not source:
            self.logger.warning("no ALTCHA challenge on %s", response.url)
            return
        field = widget.attrib.get("name", "altcha")

        if source.lstrip().startswith("{"):
            challenge_json = source
        else:
            # Inline request: the page's jar and proxy, no dupefilter.
            meta = {"dont_cache": True, "allow_offsite": True}
            for key in ("cookiejar", "proxy"):
                if key in response.meta:
                    meta[key] = response.meta[key]
            reply = await self.crawler.engine.download_async(
                scrapy.Request(response.urljoin(source), meta=meta,
                               headers={"Referer": response.url})
            )
            if reply.status != 200:
                self.logger.error("challenge endpoint answered %s", reply.status)
                return
            challenge_json = reply.text

        try:
            result = await self.solver.altcha(
                url=response.url, challenge_json=challenge_json)
        except CapSkipError as exc:
            # Expired challenge, unsupported algorithm, or CapSkip unreachable.
            self.logger.error("ALTCHA not solved on %s: %r", response.url, exc)
            return

        form = response.xpath("//form[.//altcha-widget]")
        data = {"email": "[email protected]", field: result["token"]}
        yield form2request(form, data).to_scrapy(
            callback=self.after_submit,
            priority=10,
            meta={"handle_httpstatus_all": True},
        )

    def after_submit(self, response):
        yield {"url": response.url, "status": response.status}

Запустите его командой scrapy runspider с именем файла или перенесите класс в проект. Паук поддерживает оба поколения виджета, загружает challenge внутри сессии страницы и отдаёт по одному item на каждую отправленную форму со статусом, которым ответил сайт, включая отказы. Поскольку загрузка challenge идёт в обход планировщика, страницы с общим эндпоинтом challenge получают каждая свой challenge. Сырой эндпоинт, который стоит за методом altcha, описывает справочник API, а простой вызов на Python без Scrapy вокруг разобран в руководстве по ALTCHA на Python.

На версиях Scrapy до 2.14

До 2.14 метода download_async нет, но начиная со Scrapy 2.6 та же встроенная загрузка работает через engine.download, результат которого ожидается через вспомогательную функцию: она превращает его в то, что корутина может ждать через await.

from scrapy.utils.defer import maybe_deferred_to_future

# Scrapy 2.6 to 2.13: the same inline fetch, through engine.download.
reply = await maybe_deferred_to_future(self.crawler.engine.download(
    scrapy.Request(response.urljoin(source),
                   meta={"dont_cache": True, "allow_offsite": True})
))

На этих версиях FormRequest.from_response собирает отправку без предупреждения, так что его можно использовать вместо form2request: передайте тот же XPath как formxpath и положите токен в formdata. Если же вы пускаете challenge через планировщик, обычным запросом с собственным колбэком, задайте для него dont_filter, иначе фильтр дубликатов отбросит все загрузки после первой.

На Scrapy до 2.13 для AsyncCapSkip также нужно, чтобы в settings.py параметр TWISTED_REACTOR указывал на asyncio-реактор. В проектах, созданных начиная со Scrapy 2.7, эта строка уже есть.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
Первая форма отправляется, а следующие нет, и без ошибокChallenge был отдан через yield обычным запросом, и фильтр дубликатов отбросил повторыЗагружайте его через download_async или задайте dont_filter для запроса
ApiException почти мгновенно, но только после первого запускаHTTP-кэш воспроизвёл старый, уже истёкший challengeДобавьте dont_cache в запрос challenge
Сайт сообщает о провале проверки, хотя решение прошло успешноТокен слишком долго ждал, был использован повторно или попал не в то полеОтправляйте сразу, с повышенным priority, один раз на токен, под именем из атрибута name виджета
403 от эндпоинта challengeЭндпоинт ждёт ту сессию, которая загрузила формуЗагружайте его через Scrapy, как показано выше, а не через challenge_url
ApiException меньше чем за секунду, каждый разСайт использует Argon2id или scrypt, которые отклоняются без попытки решенияПовторять нечего; для этого сайта нужен другой путь
Обход встаёт, пока решается каждая формаСинхронный класс CapSkip работает в событийном циклеПерейдите на AsyncCapSkip и колбэк с async def
NoEventLoopError, not currently running on any asynchronous event loop (AsyncLibraryNotFoundError в более старых установках)Проект жёстко задаёт реактор, отличный от asyncioУдалите эту строку TWISTED_REACTOR или укажите в ней asyncio-реактор
AttributeError: ‘ExecutionEngine’ object has no attribute ‘download_async’Версия Scrapy ниже 2.14Обновитесь или используйте engine.download, как показано выше
IgnoreRequest, filtered offsite request, при загрузке challengeЭндпоинт challenge находится на другом домене, а паук задаёт allowed_domainsДобавьте allow_offsite в meta запроса challenge
ScrapyDeprecationWarning о from_response при каждой отправкеВ Scrapy 2.16 и новее FormRequest.from_response объявлен устаревшимСобирайте отправку через form2request
NetworkException на первом решенииCapSkip не запущен либо неверны хост и портЗапустите CapSkip и проверьте, в каком режиме он должен работать: Local или Server

FAQ

Нужен ли для ALTCHA scrapy-playwright или headless-браузер?

Нет, решить ALTCHA в Scrapy можно обычными запросами. Вся работа виджета сводится к тому, чтобы загрузить challenge, потратить немного CPU на поиск числа и записать результат в поле. Challenge может загрузить Scrapy, число находит CapSkip, а поле записывает form2request, так что JavaScript нигде выполнять не нужно. Благодаря этому паук остаётся таким же быстрым и дешёвым в работе, как любой другой обход на Scrapy.

Может ли паук на Scrapyd или на хостинговой платформе для Scrapy достучаться до решателя?

Да. Переключите CapSkip в режим Server в настройках подключения, чтобы он слушал сетевой адрес, а не loopback, задайте CAPSKIP_HOST в окружении паука и передайте его клиенту, как это делает полный пример. Хостинговая платформа подключается через тот же HTTP API, что и локальный паук. Если маршрут идёт через интернет, используйте статический публичный IP с правилом файрвола. Решатель остаётся на вашем собственном железе, поэтому количество решений никак не влияет на то, сколько вы платите.

Почему это сделано в колбэке, а не в downloader middleware?

Потому что ALTCHA стоит на форме, которую вы сами решаете отправить, а не на странице блокировки, которая прерывает обход. Middleware хорошо подходит для проверки, которая может появиться в любом ответе, и руководство по middleware для reCAPTCHA обрабатывает её именно так. ALTCHA относится к одному шагу сценария, а у колбэка, который собирает эту форму, уже есть всё, что нужно для решения.

Можно ли решать много форм параллельно?

Да, и Scrapy уже делает это за вас. Каждый асинхронный колбэк, который ждёт решатель через await, отдаёт управление, поэтому у каждой загруженной Scrapy страницы решение может идти одновременно с остальными. CONCURRENT_REQUESTS на это не влияет: он ограничивает загрузки, а не колбэки. На стороне CapSkip параметр Max. Threads в настройках ALTCHA задаёт, сколько challenge хешируется одновременно, а так как это работа для CPU, больше потоков, чем ядер, ничего не даёт. Поскольку каждый колбэк загружает свой challenge непосредственно перед решением, ни один из них не лежит в очереди и не устаревает. По той же причине не загружайте заранее пачку challenge, чтобы решить её позже.

Коротко

Чтобы решить ALTCHA в Scrapy, считайте с элемента altcha-widget атрибут challenge, challengejson или challengeurl, а также атрибут name. Загрузите challenge через download_async с dont_cache, чтобы он шёл в сессии паука и никогда не встречался с фильтром дубликатов. Вызовите через await метод altcha класса AsyncCapSkip, передав JSON в том виде, в каком он пришёл, и отдайте через yield отправку form2request с токеном в данных и повышенным priority. Один challenge, один токен, одна отправка, и режим Server, когда паук работает где-то ещё.

И последнее об объёмах. Краулер, который отправляет тысячи форм, решает тысячи challenge, и когда распознавание капчи работает на вашей собственной машине, это стоит времени CPU, а не платы за каждое решение.