Integraciones

Guía de integración de Scrapy + CaptchaAI

En Scrapy el CAPTCHA no se resuelve dentro del spider, sino en un downloader middleware. Este inspecciona cada respuesta, detecta el desafío, pide el token a la API de CaptchaAI y lo deja en request.meta para que el spider lo reenvíe. El spider queda limpio y la cola sigue descargando mientras una página espera su token.

Ese matiz decide si la integración aguanta en producción. Un crawler que recorre 40.000 fichas con un desafío cada 300 páginas no necesita resolver más rápido: necesita que esa pausa no bloquee a los demás workers. Scrapy ya lo hace con su motor asíncrono; tú solo colocas la llamada al solver en la capa correcta.

Montamos la pieza de menor a mayor: cliente de la API, middleware, ajustes, spider y reintento, todo en Python 3.8+ con requests.

Qué necesitas antes de empezar

Requisito Detalles
Python 3.8+
Scrapy 2.5+
requests Para las llamadas a la API de CaptchaAI
Clave API de CaptchaAI Consíguela aquí
pip install scrapy requests

Antes de dimensionar nada: CaptchaAI cobra por threads concurrentes, no por resolución. Un thread es un CAPTCHA en vuelo; al terminar queda libre para el siguiente. BASIC cuesta $15/mes con 5 threads, STANDARD $30/mes con 15 y ADVANCE $90/mes con 50, todos con resoluciones ilimitadas dentro del mes. Para un spider eso significa que los threads son el techo de CAPTCHA esperando a la vez: ajústalos a la frecuencia real de desafíos, no al total de páginas.

Tipos soportados: reCAPTCHA v2 (incluidas invisible y Enterprise), reCAPTCHA v3, Cloudflare Turnstile y Challenge, GeeTest v3, imagen/OCR, grid y BLS; CaptchaFox (beta), Friendly Captcha (beta) y Lemin (beta) siguen en fase beta. hCaptcha y FunCaptcha (Arkose Labs) no son compatibles y GeeTest v4 figura como próximamente: si tu objetivo usa uno de esos, este middleware no cubre esa ruta.

Paso 1: el cliente de la API de CaptchaAI

Crea captcha_solver.py en la raíz del proyecto: dos métodos, uno para reCAPTCHA y otro para CAPTCHA de imagen, que envían la tarea a in.php y consultan el resultado en res.php. El patrón se repite: envías, recibes un id y sondeas hasta que la respuesta deja de ser CAPCHA_NOT_READY.

import requests
import time


class CaptchaAISolver:
    def __init__(self, api_key):
        self.api_key = api_key
        self.base_url = "https://ocr.captchaai.com"

    def solve_recaptcha(self, site_key, page_url, timeout=300):
        resp = requests.get(f"{self.base_url}/in.php", params={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": site_key,
            "pageurl": page_url,
        })

        if not resp.text.startswith("OK|"):
            raise Exception(f"Submit failed: {resp.text}")

        task_id = resp.text.split("|")[1]
        deadline = time.time() + timeout

        while time.time() < deadline:
            time.sleep(5)
            result = requests.get(f"{self.base_url}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
            })

            if result.text == "CAPCHA_NOT_READY":
                continue
            if result.text.startswith("OK|"):
                return result.text.split("|", 1)[1]
            raise Exception(f"Solve failed: {result.text}")

        raise TimeoutError(f"Task {task_id} timed out")

    def solve_image(self, image_base64, timeout=120):
        resp = requests.get(f"{self.base_url}/in.php", params={
            "key": self.api_key,
            "method": "base64",
            "body": image_base64,
        })

        if not resp.text.startswith("OK|"):
            raise Exception(f"Submit failed: {resp.text}")

        task_id = resp.text.split("|")[1]
        deadline = time.time() + timeout

        while time.time() < deadline:
            time.sleep(5)
            result = requests.get(f"{self.base_url}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
            })

            if result.text == "CAPCHA_NOT_READY":
                continue
            if result.text.startswith("OK|"):
                return result.text.split("|", 1)[1]
            raise Exception(f"Solve failed: {result.text}")

        raise TimeoutError(f"Task {task_id} timed out")

Dos detalles importan. solve_recaptcha recibe el sitekey y la URL: la API no visita el sitio, resuelve el desafío con esos dos datos y devuelve un token. Y el timeout de 300 segundos es un techo de seguridad, no el tiempo esperado.

Paso 2: el middleware que detecta y resuelve

Ahora middlewares.py, el corazón de la integración: intercepta cada respuesta, busca un data-sitekey o una imagen de CAPTCHA embebida y, si lo encuentra, llama al solver y guarda el resultado en request.meta.

import base64
import re
from scrapy import signals
from scrapy.http import HtmlResponse
from captcha_solver import CaptchaAISolver


class CaptchaAIMiddleware:
    """Scrapy downloader middleware that detects and solves CAPTCHAs."""

    def __init__(self, api_key):
        self.solver = CaptchaAISolver(api_key)

    @classmethod
    def from_crawler(cls, crawler):
        api_key = crawler.settings.get("CAPTCHAAI_API_KEY")
        if not api_key:
            raise ValueError("CAPTCHAAI_API_KEY setting is required")
        return cls(api_key)

    def process_response(self, request, response, spider):
        # Check for reCAPTCHA on the page
        site_key = self._find_recaptcha_key(response.text)
        if site_key:
            spider.logger.info(f"reCAPTCHA detected on {response.url}")
            token = self.solver.solve_recaptcha(site_key, response.url)
            request.meta["captcha_token"] = token
            spider.logger.info("CAPTCHA solved successfully")

        # Check for image CAPTCHA
        captcha_img = self._find_image_captcha(response)
        if captcha_img:
            spider.logger.info(f"Image CAPTCHA detected on {response.url}")
            text = self.solver.solve_image(captcha_img)
            request.meta["captcha_text"] = text
            spider.logger.info(f"Image CAPTCHA solved: {text}")

        return response

    def _find_recaptcha_key(self, html):
        match = re.search(
            r'data-sitekey=["\']([A-Za-z0-9_-]+)["\']', html
        )
        return match.group(1) if match else None

    def _find_image_captcha(self, response):
        img = response.css("img#captcha-image::attr(src)").get()
        if img and img.startswith("data:image"):
            return img.split(",", 1)[1]
        return None

Fíjate en from_crawler: lee la clave de los settings y falla en el arranque, mejor que descubrirlo en la página 900. La detección por regex es simple a propósito y será lo primero que adaptes: muchos sitios inyectan el sitekey por JavaScript o lo esconden en un iframe.

Paso 3: registrar el middleware en settings.py

import os

CAPTCHAAI_API_KEY = os.environ.get("CAPTCHAAI_API_KEY")

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.CaptchaAIMiddleware": 560,
}

La prioridad 560 lo sitúa tras el middleware de reintentos y antes del de redirecciones: ves la respuesta normalizada y aún puedes reenviar la solicitud. La clave viene de una variable de entorno; no la escribas en settings.py, que acaba en el repositorio.

Paso 4: el spider que reenvía el token

import scrapy


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

    def parse(self, response):
        # If CAPTCHA was solved, the token is in meta
        token = response.meta.get("captcha_token")
        if token:
            # Resubmit the page with the token
            yield scrapy.FormRequest(
                url=response.url,
                formdata={"g-recaptcha-response": token},
                callback=self.parse_products,
            )
        else:
            yield from self.parse_products(response)

    def parse_products(self, response):
        for product in response.css(".product-item"):
            yield {
                "name": product.css("h2::text").get(),
                "price": product.css(".price::text").get(),
                "url": response.urljoin(
                    product.css("a::attr(href)").get()
                ),
            }

        next_page = response.css("a.next-page::attr(href)").get()
        if next_page:
            yield scrapy.Request(response.urljoin(next_page))

El spider no sabe nada de CaptchaAI: comprueba si hay un captcha_token en meta y reenvía el formulario con el campo g-recaptcha-response, el nombre que espera reCAPTCHA v2 y que no debe cambiarse.

Paso 5: reintentar las páginas que devuelven desafío

Algunos sitios sirven un intersticial completo en vez de mostrar el CAPTCHA en la página real. Para eso añade un segundo middleware que lo detecte y reencole la solicitud.

class CaptchaRetryMiddleware:
    """Retry requests that return CAPTCHA challenge pages."""

    max_retries = 3

    def process_response(self, request, response, spider):
        if self._is_captcha_page(response):
            retries = request.meta.get("captcha_retries", 0)
            if retries < self.max_retries:
                request.meta["captcha_retries"] = retries + 1
                spider.logger.info(
                    f"CAPTCHA page detected, retry {retries + 1}"
                )
                return request.copy()

        return response

    def _is_captcha_page(self, response):
        indicators = [
            "g-recaptcha",
            "cf-turnstile",
            "captcha-image",
            "Please verify you are human",
        ]
        return any(ind in response.text for ind in indicators)

Tres reintentos es un buen punto de partida. Si subes ese número, sube también DOWNLOAD_DELAY: reencolar en exceso contra un sitio que ya te muestra desafíos empeora las cosas.

Paso 6: ejecutar el spider

export CAPTCHAAI_API_KEY="YOUR_API_KEY"
scrapy crawl products -o products.json

Un caso real: monitorizar precios en un marketplace regional

Una agencia de Ciudad de México sigue el precio de 12.000 referencias en un marketplace regional del tipo MercadoLibre y en Amazon.es, con dos pasadas diarias. Cerca del 2% de las páginas devuelven desafío —unas 240 por pasada— con dos o tres en vuelo a la vez en el pico.

Con esos números, BASIC ($15/mes, 5 threads) cubre el caso base y STANDARD ($30/mes, 15 threads) deja margen. Al facturarse por threads concurrentes, un mes con el doble de desafíos cuesta lo mismo mientras la concurrencia no suba: para quien factura en pesos o euros y paga en USD, ese coste fijo se presupuesta mejor que el pago por resolución.

Dos advertencias: respeta los términos de servicio del sitio y la normativa de protección de datos aplicable —GDPR y LOPDGDD en España, LFPDPPP en México— y mantén un DOWNLOAD_DELAY razonable. Un crawler educado recibe menos desafíos.

Cuando algo falla

Problema Causa Solución
ValueError: CAPTCHAAI_API_KEY setting is required Falta la variable de entorno Define CAPTCHAAI_API_KEY antes de lanzar el spider
El CAPTCHA no se detecta El HTML tiene otra estructura Revisa la respuesta real y ajusta la regex del middleware
TimeoutError en el solver Resolución lenta o red inestable Sube el tiempo de espera y revisa la latencia hasta la API
El spider se bloquea tras resolver Bloqueo por IP Baja la frecuencia de solicitudes y revisa tu salida de red autorizada
Se agotan los threads en el pico Concurrencia por encima del plan Sube de plan o baja CONCURRENT_REQUESTS

Preguntas frecuentes

¿Cuántos threads necesito para mi spider?

Cuenta CAPTCHA simultáneos, no páginas. Multiplica tu CONCURRENT_REQUESTS por el porcentaje de páginas con desafío y redondea hacia arriba: con 32 solicitudes en paralelo y un 5% de desafíos, los 5 threads de BASIC ($15/mes) bastan.

¿Dónde guardo la clave API en un proyecto Scrapy?

En una variable de entorno leída desde settings.py, nunca escrita en el archivo. Así la misma configuración vale en local, en Docker y en Scrapy Cloud sin exponer la clave en el repositorio.

¿Sirve el mismo middleware para GeeTest v3 o Turnstile?

Sí, añadiendo un método de resolución por tipo. process_response no cambia: detectas el marcador en el HTML y llamas al método que corresponde. Para hCaptcha y FunCaptcha no hay ruta: no son compatibles.

¿Puedo reutilizar un token entre varias solicitudes?

No. Cada token de reCAPTCHA v2 está ligado a su sesión y caduca en un par de minutos; reutilizarlo devuelve un error de verificación en el servidor del sitio.

¿Frena el middleware el resto del crawl?

Solo frena la solicitud que espera. El motor asíncrono sigue descargando las demás, así que con un CONCURRENT_REQUESTS bien ajustado el impacto sobre el tiempo total es marginal, salvo que casi todas las páginas muestren desafío.

Guías relacionadas

Los comentarios están deshabilitados para este artículo.