Tutoriales

Manejo de CAPTCHA en aplicaciones Django con CaptchaAI

En un proyecto Django el CAPTCHA aparece en dos frentes que conviene no mezclar, porque cada uno se resuelve con herramientas distintas:

  • Tus propios formularios. Tú pones el widget de Turnstile o reCAPTCHA y validas el token en el servidor contra el endpoint oficial de Cloudflare o de Google. CaptchaAI no interviene aquí.
  • Sitios ajenos. Cuando tu aplicación tiene que interactuar con un portal externo protegido por CAPTCHA, necesitas resolver el desafío para seguir. Ese es el trabajo de CaptchaAI, con su flujo de envío y sondeo.

Esta guía recorre los dos patrones con código listo para copiar, más las variantes síncrona, asíncrona y con Celery.

Escenario 1: verificar el CAPTCHA en tus formularios Django

  • El token que genera el widget en el navegador no vale nada hasta que lo validas en el servidor.
  • La comprobación es tuya, no de CaptchaAI: va contra siteverify de Cloudflare (o de Google, para reCAPTCHA).
  • Sin ese paso, cualquiera puede enviar el formulario sin resolver el desafío y saltarse la protección.

Añadir Turnstile a un formulario y validarlo en el servidor

El formulario declara un campo oculto para el token y la vista lo comprueba contra siteverify antes de procesar nada:

# forms.py
from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)
    cf_turnstile_response = forms.CharField(
        widget=forms.HiddenInput(),
        required=True,
    )
# views.py
import requests
from django.conf import settings
from django.shortcuts import render, redirect
from .forms import ContactForm

def contact_view(request):
    if request.method == "POST":
        form = ContactForm(request.POST)
        if form.is_valid():
            # Verify Turnstile token with Cloudflare
            token = form.cleaned_data["cf_turnstile_response"]
            verification = requests.post(
                "https://challenges.cloudflare.com/turnstile/v0/siteverify",
                data={
                    "secret": settings.TURNSTILE_SECRET_KEY,
                    "response": token,
                    "remoteip": request.META.get("REMOTE_ADDR"),
                },
            ).json()

            if verification.get("success"):
                # Process the form
                return redirect("success")
            else:
                form.add_error(None, "CAPTCHA verification failed")
    else:
        form = ContactForm()

    return render(request, "contact.html", {
        "form": form,
        "turnstile_sitekey": settings.TURNSTILE_SITE_KEY,
    })
<!-- templates/contact.html -->
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <div class="cf-turnstile" data-sitekey="{{ turnstile_sitekey }}"></div>
    <button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Escenario 2: resolver el CAPTCHA de sitios externos con CaptchaAI

Aquí el trabajo lo hace CaptchaAI: tu aplicación consulta un sitio externo protegido por CAPTCHA que no puedes validar tú. Piensa en un panel interno que cada mañana entra a un portal ajeno:

  • Un marketplace regional (tipo MercadoLibre o Amazon.es) del que revisas precios.
  • Un portal público con un trámite que hay que consultar: una cita previa en España, el SAT en México o AFIP en Argentina.
  • Si ese portal levanta un Turnstile, la vista se queda a medias hasta resolver el desafío.

Respeta siempre los términos del sitio y la normativa de protección de datos. El patrón es el mismo: envías la tarea a in.php, sondeas res.php y usas el token antes de que caduque.

Una clase de servicio para hablar con la API de CaptchaAI

Encapsular ese flujo en una sola clase te evita repetir la lógica y te deja un único sitio donde ajustar el timeout o los errores. La clase hace tres cosas:

  1. Envía la tarea a in.php con el método correcto según el tipo de CAPTCHA.
  2. Sondea res.php cada pocos segundos hasta recibir el token o agotar el timeout.
  3. Lanza una excepción propia (CaptchaSolveError) si el desafío resulta irresoluble o tarda demasiado.
# services/captcha_solver.py
import time
import requests
from django.conf import settings


class CaptchaSolverService:
    """Django service for solving CAPTCHAs via CaptchaAI."""

    API_BASE = "https://ocr.captchaai.com"

    def __init__(self):
        self.api_key = settings.CAPTCHAAI_API_KEY

    def solve_recaptcha_v2(self, sitekey, page_url, invisible=False):
        """Solve reCAPTCHA v2."""
        params = {
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }
        if invisible:
            params["invisible"] = 1
        return self._submit_and_poll(params)

    def solve_turnstile(self, sitekey, page_url, action=None):
        """Solve Cloudflare Turnstile."""
        params = {
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }
        if action:
            params["action"] = action
        return self._submit_and_poll(params)

    def solve_image(self, image_base64):
        """Solve image/text CAPTCHA."""
        return self._submit_and_poll({
            "key": self.api_key,
            "method": "base64",
            "body": image_base64,
            "json": 1,
        })

    def get_balance(self):
        """Check API balance."""
        response = requests.get(f"{self.API_BASE}/res.php", params={
            "key": self.api_key,
            "action": "getbalance",
            "json": 1,
        }, timeout=30)
        return float(response.json().get("request", 0))

    def _submit_and_poll(self, params, timeout=120):
        """Submit task and poll for result."""
        # Submit
        response = requests.post(f"{self.API_BASE}/in.php", data=params, timeout=30)
        response.raise_for_status()
        data = response.json()

        if data.get("status") != 1:
            raise CaptchaSolveError(f"Submit failed: {data.get('request')}")

        task_id = data["request"]

        # Poll
        start = time.time()
        while time.time() - start < timeout:
            time.sleep(5)
            result = requests.get(f"{self.API_BASE}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1,
            }, timeout=30).json()

            if result.get("status") == 1:
                return result["request"]
            if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
                raise CaptchaSolveError("CAPTCHA unsolvable")

        raise CaptchaSolveError("Solve timed out")


class CaptchaSolveError(Exception):
    pass

Configuración en settings.py

Guarda la clave API y las claves de Turnstile fuera del código fuente. En este ejemplo van en settings.py por claridad, pero en producción llegan desde variables de entorno:

# settings.py
CAPTCHAAI_API_KEY = "YOUR_API_KEY"
TURNSTILE_SITE_KEY = "0x4AAAAAAAC3DHQhMMQ_Rxrg"
TURNSTILE_SECRET_KEY = "0x4AAAAAAAC3DHQhYYY_secret"

Llamar al servicio desde tus vistas y comandos

Vista para recopilación de datos externos

La vista recibe la URL objetivo, pide a CaptchaAI el token de Turnstile y lo reenvía al sitio protegido para acceder al recurso:

# views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .services.captcha_solver import CaptchaSolverService, CaptchaSolveError

@require_POST
def scrape_external_data(request):
    """Solve CAPTCHA and fetch data from external CAPTCHA-protected site."""
    url = request.POST.get("target_url")
    if not url:
        return JsonResponse({"error": "target_url required"}, status=400)

    solver = CaptchaSolverService()

    try:
        # Solve the CAPTCHA
        token = solver.solve_turnstile(
            sitekey="0x4AAAAAAAC3DHQhMMQ_Rxrg",
            page_url=url,
        )

        # Use token to access the protected resource
        import requests as http_requests
        response = http_requests.post(url, data={
            "cf-turnstile-response": token,
        }, timeout=30)

        return JsonResponse({
            "status": "success",
            "data": response.text[:1000],
        })

    except CaptchaSolveError as e:
        return JsonResponse({"error": str(e)}, status=500)

Comando de gestión para pruebas rápidas

Un comando manage.py es la forma más cómoda de probar la integración sin pasar por el navegador:

  • Lo lanzas desde la terminal o desde un cron y ves el token en pantalla.
  • Te sirve para comprobar una sitekey nueva antes de meterla en una vista.
  • Al final imprime el saldo restante. Ejecútalo indicando tipo, sitekey y URL:
# management/commands/solve_captcha.py
from django.core.management.base import BaseCommand
from myapp.services.captcha_solver import CaptchaSolverService


class Command(BaseCommand):
    help = "Solve a CAPTCHA and print the token"

    def add_arguments(self, parser):
        parser.add_argument("--type", choices=["recaptcha", "turnstile"], required=True)
        parser.add_argument("--sitekey", required=True)
        parser.add_argument("--url", required=True)

    def handle(self, *args, **options):
        solver = CaptchaSolverService()

        self.stdout.write(f"Solving {options['type']} for {options['url']}...")

        if options["type"] == "recaptcha":
            token = solver.solve_recaptcha_v2(options["sitekey"], options["url"])
        else:
            token = solver.solve_turnstile(options["sitekey"], options["url"])

        self.stdout.write(self.style.SUCCESS(f"Token: {token[:50]}..."))

        # Check balance
        balance = solver.get_balance()
        self.stdout.write(f"Remaining balance: ${balance:.2f}")
python manage.py solve_captcha --type turnstile --sitekey 0x4AAA... --url https://example.com

Vistas asíncronas de Django con CaptchaAI

  • Django admite vistas asíncronas desde la versión 4.1, así que si tu proyecto ya usa async def conviene que la resolución no bloquee el bucle de eventos.
  • Cambia requests por aiohttp para que las llamadas a in.php y res.php sean no bloqueantes.
  • Sondea con asyncio.sleep en lugar de time.sleep, de modo que el worker atienda otras peticiones mientras espera el token.
# views.py (async)
import aiohttp
import asyncio
from django.http import JsonResponse

CAPTCHAAI_API_KEY = "YOUR_API_KEY"

async def solve_captcha_async(request):
    """Async view for solving CAPTCHAs."""
    sitekey = request.GET.get("sitekey")
    page_url = request.GET.get("url")

    if not sitekey or not page_url:
        return JsonResponse({"error": "sitekey and url required"}, status=400)

    async with aiohttp.ClientSession() as session:
        # Submit
        async with session.post("https://ocr.captchaai.com/in.php", data={
            "key": CAPTCHAAI_API_KEY,
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }) as resp:
            data = await resp.json()

        if data.get("status") != 1:
            return JsonResponse({"error": data.get("request")}, status=500)

        task_id = data["request"]

        # Poll
        for _ in range(30):
            await asyncio.sleep(5)
            async with session.get("https://ocr.captchaai.com/res.php", params={
                "key": CAPTCHAAI_API_KEY,
                "action": "get",
                "id": task_id,
                "json": 1,
            }) as resp:
                result = await resp.json()

            if result.get("status") == 1:
                return JsonResponse({"token": result["request"]})

    return JsonResponse({"error": "timeout"}, status=504)

Resolución en segundo plano con Celery

Cuando una resolución puede tardar y no quieres que el usuario espere dentro de la petición HTTP, muévela a una tarea de Celery. El flujo queda repartido en cuatro pasos:

  1. La vista encola el trabajo y responde al instante con un task_id.
  2. El worker de Celery resuelve el CAPTCHA en segundo plano.
  3. Si algo falla, self.retry reintenta hasta max_retries.
  4. El navegador sondea con el task_id hasta que el resultado está listo.
# tasks.py
from celery import shared_task
from .services.captcha_solver import CaptchaSolverService, CaptchaSolveError

@shared_task(bind=True, max_retries=2, default_retry_delay=10)
def solve_captcha_task(self, captcha_type, sitekey, page_url):
    """Background CAPTCHA solving with Celery."""
    solver = CaptchaSolverService()

    try:
        if captcha_type == "recaptcha_v2":
            token = solver.solve_recaptcha_v2(sitekey, page_url)
        elif captcha_type == "turnstile":
            token = solver.solve_turnstile(sitekey, page_url)
        else:
            raise ValueError(f"Unknown type: {captcha_type}")

        return {"success": True, "token": token}

    except CaptchaSolveError as e:
        self.retry(exc=e)
# Usage in views
from .tasks import solve_captcha_task

def start_solve(request):
    result = solve_captcha_task.delay("turnstile", "0x4AAA...", "https://example.com")
    return JsonResponse({"task_id": result.id})

def check_solve(request, task_id):
    from celery.result import AsyncResult
    result = AsyncResult(task_id)
    if result.ready():
        return JsonResponse(result.get())
    return JsonResponse({"status": "pending"})

Resolución de problemas frecuentes

Los fallos habituales al integrar CaptchaAI en Django caen casi siempre en estas casillas:

Síntoma Causa Solución
CaptchaSolveError en producción La clave API no está en la configuración Añade CAPTCHAAI_API_KEY a la configuración de Django
La tarea de Celery se reintenta sin fin CAPTCHA irresoluble o sitekey incorrecto Fija max_retries y valida la entrada antes de encolar
La vista asíncrona se congela Hay código síncrono dentro de una vista async Usa aiohttp en lugar de requests
El token caduca antes de enviar el formulario La resolución tardó demasiado Resuelve justo antes de usar el token, no por adelantado
Errores de importación en el comando de gestión El servicio no está registrado en la app Revisa que la app esté en INSTALLED_APPS

Preguntas frecuentes

¿Qué plan de CaptchaAI necesito para un proyecto Django?

Depende de cuántos CAPTCHA resuelvas a la vez. CaptchaAI se factura por threads (resoluciones simultáneas), no por CAPTCHA: el plan BASIC cuesta $15/mes con 5 threads y resoluciones ilimitadas, y subes de tramo —STANDARD ($30/mes, 15 threads), ADVANCE ($90/mes, 50 threads)— a medida que crece tu volumen. Un panel interno con una tarea Celery cada vez va sobrado con BASIC.

¿El mismo servicio resuelve reCAPTCHA, Turnstile e imágenes?

Sí. La clase CaptchaSolverService expone un método por tipo y todos comparten el flujo submit/poll. CaptchaAI resuelve reCAPTCHA v2/v3, Cloudflare Turnstile y Challenge, GeeTest v3 e imágenes/OCR; hCaptcha y FunCaptcha no están soportados, así que no cuentes con ellos en tu vista.

¿Debo resolver dentro de la vista o con Celery?

En vistas que responden al navegador, usa Celery para que nadie espere 15 segundos o más frente a una pantalla en blanco. La resolución síncrona directa encaja mejor en comandos de gestión, scripts y tareas programadas, donde nadie está mirando la petición en tiempo real.

¿Cómo evito que el token caduque antes de tiempo?

Resuélvelo justo antes de enviarlo. Los tokens de reCAPTCHA caducan a los 120 segundos y los de Turnstile a los 300; guardarlos en caché no compensa. Si usas Celery, encola la resolución lo más cerca posible del momento en que vas a consumir el token.

Resumen

Las aplicaciones Django encajan con CaptchaAI a través de una clase de servicio que envuelve el flujo submit/poll y se reutiliza en toda la base de código. Resuelve de forma síncrona en comandos de gestión, con vistas asíncronas en Django 4.1+ y con tareas de Celery cuando el trabajo va en segundo plano. El mismo servicio cubre reCAPTCHA, Turnstile e imágenes, así que no necesitas una integración distinta por cada tipo.

Artículos relacionados

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