Scarnox · Blog

Middleware de captcha para Scrapy: resuelve captchas en tu spider (2026)

Scrapy está construido en torno a peticiones y callbacks, así que un captcha no encaja del todo en él. La forma limpia de gestionarlo es un middleware de descarga: ve cada respuesta, detecta un desafío, obtiene un token y deja que el spider envíe el formulario con ese token. Esta guía construye ese middleware para páginas de PopularCaptcha, que Scarnox resuelve hoy sin proxy.

Empieza con crédito de prueba gratis, sin tarjeta y prueba el código contra una página de prueba antes de apuntarlo a cualquier otra cosa.

Cómo funciona el middleware

El middleware se ejecuta en cada respuesta:

  1. Si la respuesta no tiene data-sitekey, deja pasar la respuesta.
  2. Si lo tiene, el middleware resuelve el captcha y devuelve una copia de la misma petición con el token guardado en meta.
  3. Scrapy vuelve a obtener esa petición. El middleware ve el token en meta y deja pasar la respuesta.
  4. El spider lee el token de meta y envía el formulario con FormRequest.from_response.

La llamada de resolución se ejecuta en un hilo mediante deferToThread, así que no bloquea el reactor de Scrapy. Las demás peticiones siguen avanzando mientras se resuelve el captcha.

Ajustes del proyecto

Pon tu clave en una variable de entorno, no en el código:

# settings.py
import os

SCARNOX_API_KEY = os.environ["SCARNOX_API_KEY"]
SCARNOX_TIMEOUT = 185

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.PopularCaptchaMiddleware": 543,
}

ROBOTSTXT_OBEY = True
DOWNLOAD_DELAY = 3
CONCURRENT_REQUESTS_PER_DOMAIN = 1

El retardo de descarga y la concurrencia única por dominio están ahí a propósito. Mantienen baja la carga del sitio y reducen el número de desafíos que ves.

El middleware

# myproject/middlewares.py
import requests
from scrapy import Request
from scrapy.http import Response
from twisted.internet.threads import deferToThread

API_URL = "https://api.scarnox.com/api/tasks/create"


class PopularCaptchaMiddleware:
    def __init__(self, api_key: str, timeout: int):
        self.api_key = api_key
        self.timeout = timeout

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            api_key=crawler.settings.get("SCARNOX_API_KEY"),
            timeout=crawler.settings.getint("SCARNOX_TIMEOUT", 185),
        )

    def _solve(self, sitekey: str, page_url: str) -> str:
        r = requests.post(
            API_URL,
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={
                "captcha_type": "PopularCaptchaTaskProxyless",
                "site_key": sitekey,
                "site_url": page_url,
            },
            timeout=self.timeout,  # se ejecuta en un hilo de trabajo, no en el reactor
        )
        data = r.json()
        if data.get("errorId") != 0:
            raise RuntimeError(f"{data.get('errorCode')}: {data.get('errorDescription')}")
        return data["solution"]["token"]

    def process_response(self, request: Request, response: Response, spider):
        # Ya resuelta, o un envío de formulario que construimos nosotros: deja pasar.
        if request.meta.get("captcha_token") or request.meta.get("skip_captcha"):
            return response

        sitekey = response.css("[data-sitekey]::attr(data-sitekey)").get()
        if not sitekey:
            return response

        spider.logger.info("PopularCaptcha on %s, solving", response.url)

        def retry_with_token(token: str) -> Request:
            # Devolver una Request la reprograma. dont_filter evita el filtro de duplicados.
            return request.replace(
                dont_filter=True,
                meta={**request.meta, "captcha_token": token},
            )

        return deferToThread(self._solve, sitekey, response.url).addCallback(retry_with_token)

Scrapy admite devolver un Deferred desde process_response. El callback convierte el token en una petición reintentada, y Scrapy la programa como cualquier otra.

El spider

# myproject/spiders/search.py
import scrapy
from scrapy.http import FormRequest


class SearchSpider(scrapy.Spider):
    name = "search"
    start_urls = ["https://search.example.com/search?q=web+scraping"]

    def parse(self, response):
        token = response.meta.get("captcha_token")
        if token and response.css("[data-sitekey]"):
            # Scrapy rellena los propios campos del formulario; nosotros añadimos el token.
            yield FormRequest.from_response(
                response,
                formdata={
                    "h-captcha-response": token,
                    "g-recaptcha-response": token,
                },
                callback=self.parse_results,
                meta={"skip_captcha": True},
                dont_filter=True,
            )
            return

        yield from self.parse_results(response)

    def parse_results(self, response):
        for item in response.css("div.result"):
            yield {
                "title": item.css("h3::text").get(),
                "url": item.css("a::attr(href)").get(),
            }

Los selectores son marcadores de posición. Sustitúyelos por los reales del sitio que estés scrapeando.

La marca skip_captcha es lo que impide que el middleware vuelva a resolver el envío del formulario. Sin ella, cada envío costaría otra resolución.

Ejecútalo

export SCARNOX_API_KEY=scarnox_YOURAPIKEY
scrapy crawl search -O results.json

Vigila el registro. Una línea como PopularCaptcha on ..., solving significa que se encontró un desafío y se solicitó un token. Si ves esa línea en cada página, ve más despacio. Los desafíos frecuentes son señal de que el sitio ha detectado el rastreo.

Tiempos del token

Un token de PopularCaptcha caduca en unos dos minutos. El middleware solicita el reintento en cuanto llega el token, y el spider envía en la misma cadena de callbacks. Mantén la cadena corta. No añadas descargas lentas ni procesamiento largo entre el token y el envío del formulario. Para más detalles sobre esa ventana, consulta caducidad del token de captcha.

Errores

Una resolución fallida lanza un RuntimeError dentro del hilo. Scrapy lo registrará como una petición fallida. Las resoluciones fallidas no se cobran, así que reintentar es barato. Para una política de reintentos con límites, usa el RetryMiddleware integrado de Scrapy y envuelve _solve en un bucle corto. Nuestra guía sobre gestión de errores del solver de captcha enumera los patrones que aguantan en producción.

Scrapea de forma responsable

Para el trasfondo legal, consulta si el web scraping es legal.

Precios

Scarnox cobra por resolución exitosa, y las resoluciones fallidas son gratuitas. Consulta las tarifas actuales para ver los precios. Empieza con crédito de prueba gratis, sin tarjeta.

Próximos pasos