Integraciones

Cree un microservicio de resolución de CAPTCHA con FastAPI y CaptchaAI

En cuanto tienes dos o tres proyectos llamando a la misma API de resolución, la respuesta corta es sí: saca esa lógica de cada repositorio y ponla detrás de un único servicio HTTP interno. Tus scrapers, tus suites de QA y tus workers dejan de saber qué es in.php; hacen POST /solve/recaptcha-v2 y reciben un token.

FastAPI encaja aquí por un motivo concreto: resolver un CAPTCHA consiste, casi todo el rato, en esperar una respuesta remota. Con async y httpx, un solo proceso mantiene decenas de solves en vuelo sin dedicar un hilo del sistema operativo a cada uno. Esto es lo que vas a montar:

  • un módulo solver que habla con in.php y res.php;
  • una app FastAPI con una ruta por tipo de CAPTCHA;
  • pruebas con curl y el dimensionado de threads que lo sostiene.

Por qué un servicio y no una librería compartida

Una librería interna parece más simple, pero arrastra tres costes que el servicio elimina:

  • La clave API vive en un solo sitio, no copiada en cinco archivos .env.
  • Cambias el solver una vez, sin redesplegar cada consumidor.
  • El consumo se mide de verdad: latencia, errores y gasto por equipo en un solo punto.

A cambio introduces un salto de red y algo más que mantener. Con un solo script gana la librería; a partir del segundo consumidor, gana el servicio.

Lo que necesitas

Requisito Detalles
Clave API de CaptchaAI captchaai.com
Python 3.9+
FastAPI + httpx Para manejo HTTP asíncrono

Instala las dependencias:

pip install fastapi uvicorn httpx

Estructura del proyecto

captcha-service/
├── main.py          # FastAPI app with endpoints
├── solver.py        # CaptchaAI solving logic
└── requirements.txt

Módulo solver: envío a in.php y sondeo de res.php

Toda la conversación con CaptchaAI cabe en dos funciones. submit_task envía la tarea al endpoint in.php y devuelve un identificador; poll_result espera y consulta res.php hasta que el token está listo. El resto son envoltorios finos por tipo, con su tiempo de espera inicial: 20 segundos para reCAPTCHA, 10 para Turnstile y 5 para imagen. Sondear antes solo suma llamadas que devolverán CAPCHA_NOT_READY.

# solver.py
import os
import httpx
import asyncio

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
BASE_URL = "https://ocr.captchaai.com"


async def submit_task(params: dict) -> str:
    """Submit a CAPTCHA task and return the task ID."""
    params["key"] = API_KEY
    params["json"] = 1

    async with httpx.AsyncClient() as client:
        response = await client.post(f"{BASE_URL}/in.php", data=params)
        data = response.json()

    if data.get("status") != 1:
        raise ValueError(f"Submit error: {data.get('request')}")
    return data["request"]


async def poll_result(task_id: str, initial_wait: int = 15, max_attempts: int = 30) -> dict:
    """Poll for the CAPTCHA result."""
    await asyncio.sleep(initial_wait)

    async with httpx.AsyncClient() as client:
        for _ in range(max_attempts):
            response = await client.get(f"{BASE_URL}/res.php", params={
                "key": API_KEY, "action": "get", "id": task_id, "json": 1
            })
            data = response.json()

            if data.get("status") == 1:
                return {
                    "token": data["request"],
                    "user_agent": data.get("user_agent", "")
                }
            if data.get("request") != "CAPCHA_NOT_READY":
                raise ValueError(f"Solve error: {data['request']}")

            await asyncio.sleep(5)

    raise TimeoutError("Solve timed out")


async def solve_recaptcha_v2(sitekey: str, pageurl: str, enterprise: bool = False) -> dict:
    params = {"method": "userrecaptcha", "googlekey": sitekey, "pageurl": pageurl}
    if enterprise:
        params["enterprise"] = 1
    task_id = await submit_task(params)
    return await poll_result(task_id, initial_wait=20)


async def solve_recaptcha_v3(sitekey: str, pageurl: str, action: str, enterprise: bool = False) -> dict:
    params = {
        "method": "userrecaptcha", "version": "v3",
        "googlekey": sitekey, "pageurl": pageurl, "action": action
    }
    if enterprise:
        params["enterprise"] = 1
    task_id = await submit_task(params)
    return await poll_result(task_id, initial_wait=20)


async def solve_turnstile(sitekey: str, pageurl: str) -> dict:
    task_id = await submit_task({"method": "turnstile", "sitekey": sitekey, "pageurl": pageurl})
    return await poll_result(task_id, initial_wait=10)


async def solve_image(image_base64: str) -> dict:
    task_id = await submit_task({"method": "base64", "body": image_base64})
    return await poll_result(task_id, initial_wait=5, max_attempts=15)

La clave se lee de CAPTCHAAI_API_KEY. Nunca la escribas en el código: es el único secreto que este servicio custodia por todos los demás.

La aplicación FastAPI: un endpoint por tipo

Cada tipo recibe su modelo de Pydantic y su ruta: el contrato queda explícito —quien llama sabe que reCAPTCHA v3 exige action— y /docs se genera sola. Los fallos del solver salen como 502: el error viene de aguas arriba, no de una solicitud mal formada. El /health existe para tu orquestador.

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import solver

app = FastAPI(title="CaptchaAI Solver Service")


class RecaptchaV2Request(BaseModel):
    sitekey: str
    pageurl: str
    enterprise: bool = False


class RecaptchaV3Request(BaseModel):
    sitekey: str
    pageurl: str
    action: str
    enterprise: bool = False


class TurnstileRequest(BaseModel):
    sitekey: str
    pageurl: str


class ImageRequest(BaseModel):
    image_base64: str


class SolveResponse(BaseModel):
    token: str
    user_agent: Optional[str] = ""


@app.post("/solve/recaptcha-v2", response_model=SolveResponse)
async def solve_recaptcha_v2(req: RecaptchaV2Request):
    try:
        result = await solver.solve_recaptcha_v2(req.sitekey, req.pageurl, req.enterprise)
        return SolveResponse(**result)
    except (ValueError, TimeoutError) as e:
        raise HTTPException(status_code=502, detail=str(e))


@app.post("/solve/recaptcha-v3", response_model=SolveResponse)
async def solve_recaptcha_v3(req: RecaptchaV3Request):
    try:
        result = await solver.solve_recaptcha_v3(req.sitekey, req.pageurl, req.action, req.enterprise)
        return SolveResponse(**result)
    except (ValueError, TimeoutError) as e:
        raise HTTPException(status_code=502, detail=str(e))


@app.post("/solve/turnstile", response_model=SolveResponse)
async def solve_turnstile(req: TurnstileRequest):
    try:
        result = await solver.solve_turnstile(req.sitekey, req.pageurl)
        return SolveResponse(**result)
    except (ValueError, TimeoutError) as e:
        raise HTTPException(status_code=502, detail=str(e))


@app.post("/solve/image", response_model=SolveResponse)
async def solve_image(req: ImageRequest):
    try:
        result = await solver.solve_image(req.image_base64)
        return SolveResponse(**result)
    except (ValueError, TimeoutError) as e:
        raise HTTPException(status_code=502, detail=str(e))


@app.get("/health")
async def health():
    return {"status": "ok"}

Levantar el servicio

uvicorn main:app --host 0.0.0.0 --port 8000

En producción, publica el proceso solo en tu red interna y pon delante nginx o Traefik. Un servicio que convierte solicitudes en saldo consumido no debería quedar expuesto a internet sin autenticación.

Ejemplos de uso

Resolver reCAPTCHA v2

curl -X POST http://localhost:8000/solve/recaptcha-v2 \
  -H "Content-Type: application/json" \
  -d '{"sitekey": "6Le-wvkS...", "pageurl": "https://staging.example.com/qa-login"}'

Resolver Cloudflare Turnstile

curl -X POST http://localhost:8000/solve/turnstile \
  -H "Content-Type: application/json" \
  -d '{"sitekey": "0x4AAAA...", "pageurl": "https://example.com/form"}'

Respuesta:

{
  "token": "03AGdBq24PBCqLmOx2V4...",
  "user_agent": "Mozilla/5.0..."
}

El campo user_agent importa en reCAPTCHA: el token se resolvió con ese navegador declarado, así que reenvíalo junto al token al enviar el formulario.

Qué rutas tiene sentido exponer

No expongas un endpoint por cada tipo que exista, sino por los que CaptchaAI resuelve:

Ruta Tipo Velocidad de referencia
/solve/recaptcha-v2 reCAPTCHA v2, invisible y Enterprise <60 s
/solve/recaptcha-v3 reCAPTCHA v3 y v3 Enterprise <4 s
/solve/turnstile Cloudflare Turnstile <10 s
/solve/image imagen/OCR y grid <0.5 s (grid: <1 s)

Puedes añadir rutas para GeeTest v3, Cloudflare Challenge y BLS con el mismo patrón. CaptchaFox (beta), Friendly Captcha (beta) y Lemin (beta) van etiquetados como beta en tu documentación interna. hCaptcha y FunCaptcha (Arkose Labs) no son compatibles y GeeTest v4 figura como próximamente: no crees rutas que prometan lo que la API todavía no devuelve.

Dimensionar los threads del plan

El límite de concurrencia no lo pone el servicio: lo pone tu plan. CaptchaAI factura por threads —cada thread es un solve en curso y cada plan incluye solves ilimitados por thread—, así que el coste mensual es fijo y predecible en USD, algo que agradecen los equipos que facturan en monedas volátiles.

Plan Precio Threads Encaja con
BASIC $15/mes 5 un equipo de QA con pruebas nocturnas
STANDARD $30/mes 15 varios scrapers compartiendo el servicio
ADVANCE $90/mes 50 monitorización continua a escala

Caso típico en el mercado hispanohablante: un equipo vigila precios en marketplaces regionales y comprueba cada mañana los trámites de portales públicos con CAPTCHA (cita previa en España, portales fiscales en México o Argentina). Ambos flujos beben del mismo pool de threads y ves en una gráfica cuál se come el plan. Si el sondeo se encola, sube de plan antes de tocar max_attempts, y respeta los términos de servicio del sitio y la normativa de protección de datos aplicable.

Errores frecuentes y cómo resolverlos

Síntoma Causa Qué hacer
Respuesta 502 CaptchaAI devolvió un error Lee el campo detail: ahí viaja el error concreto.
Tiempo de espera agotado El solve superó la ventana de sondeo Sube max_attempts o revisa sitekey y pageurl.
Conexión rechazada El servicio no está en marcha Comprueba que uvicorn escucha en el puerto esperado.
Respuestas lentas bajo carga I/O bloqueante Asegúrate de usar httpx.AsyncClient y no requests.
ERROR_ZERO_BALANCE Saldo agotado Renueva el plan; hasta entonces el servicio devolverá 502.

Preguntas frecuentes

¿Qué hago si res.php responde CAPCHA_NOT_READY una y otra vez?

Es normal mientras el solve está en curso: el bucle sigue consultando cada 5 segundos. Solo al agotarse max_attempts conviene tratarlo como fallo y reintentar la tarea desde cero, nunca en paralelo.

¿Puedo guardar un token y reutilizarlo?

No. Un token de reCAPTCHA o Turnstile es de un solo uso y caduca en pocos minutos: devuélvelo al formulario que lo pidió y descártalo. Lo que sí conviene reutilizar es la conexión httpx.

¿Cuántas solicitudes simultáneas aguanta el servicio?

FastAPI sostiene miles de conexiones abiertas; el techo real son los threads de tu plan. Cuando se saturan, las tareas esperan turno y la latencia sube.

¿Puedo exponer una ruta para hCaptcha o FunCaptcha?

No. CaptchaAI no es compatible con hCaptcha ni con FunCaptcha (Arkose Labs). Sí cubre reCAPTCHA v2/v3, Cloudflare Turnstile y Challenge, GeeTest v3, imagen/OCR, grid y BLS.

¿Necesito httpx o me vale requests?

Necesitas un cliente asíncrono. requests bloquea el event loop en cada espera y convierte tu servicio async en uno secuencial; httpx o aiohttp son las opciones correctas.

Pon tu microservicio en marcha

Obtén tu clave API en captchaai.com, despliega el servicio en tu red interna y apunta el primer scraper a /solve/recaptcha-v2. Cada proyecto nuevo se integra después en una línea.

Guías relacionadas

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