Tutoriales

Gestión del estado de sesión CAPTCHA entre workers distribuidos

La forma de evitar que tus workers distribuidos se pisen al resolver CAPTCHA es darles un estado de sesión compartido: un almacén central (Redis) donde guardan y leen cookies, tokens y asignaciones de proxy. Sin ese estado común, cada worker abre su propia sesión, con sus cookies, su IP y su fingerprint, y el sitio de destino ve un usuario incoherente. Esta guía muestra cómo montar ese almacén en Python y Node.js con la API de CaptchaAI.

Por qué la sesión se rompe al escalar

El síntoma aparece en cuanto pasas de un proceso a varios. Cada worker arranca en frío, resuelve su propio CAPTCHA y obtiene una cookie distinta, así que ninguno puede reutilizar el trabajo del otro:

Worker 1 → Login → Solve CAPTCHA → Get cookie A → Submit form ✅
Worker 2 → New session → Solve CAPTCHA → Get cookie B → Submit form ✅
Worker 3 → Reuse cookie A? → Cookie expired → Solve CAPTCHA → Fail ❌

Sin estado compartido, los workers malgastan resoluciones en sesiones caducadas y generan un comportamiento inconsistente que el sitio de destino puede detectar. El objetivo es que todo el clúster se presente como un puñado de sesiones coherentes, no como decenas de visitantes desconectados entre sí.

Arquitectura: Redis como almacén central

Redis actúa como punto único donde todos los workers publican y consultan el estado. Cada tipo de dato usa la estructura que mejor le encaja: un hash para las cookies por dominio, una lista para los tokens por sitekey, un set para el pool de proxies y una clave con TTL para los locks de sesión.

┌──────────────────────────────────────┐
│          Session State Store          │
│              (Redis)                  │
│                                      │
│  cookies:{domain} → Hash             │
│  tokens:{sitekey} → List             │
│  proxies:pool → Set                  │
│  locks:{domain}:{worker} → String    │
└─────┬──────────┬──────────┬──────────┘
      │          │          │
  ┌───▼───┐  ┌──▼────┐  ┌──▼────┐
  │Worker1│  │Worker2│  │Worker3│
  └───────┘  └───────┘  └───────┘

Qué datos viven en el almacén compartido

No todo se comparte igual. Cada pieza tiene una vida útil distinta y una estrategia propia dentro de Redis:

Componente del estado Duración Estrategia para compartirlo
Cookies de autenticación De minutos a horas Redis con TTL
Tokens de CAPTCHA 90–300 segundos Lista de Redis (TTL corto)
Cookie qa_validation_cookie ~30 minutos Hash de Redis
Tokens CSRF Por carga de página No compartir: cada worker genera el suyo
Fingerprint del navegador Permanente Configuración, no estado en ejecución
Asignación de proxy Por sesión Pool de proxies respaldado por Redis

La regla práctica: comparte lo que tiene vida larga y coste alto de regenerar (cookies de login, tokens dentro de su ventana de validez) y deja local lo que es efímero o ligado a una petición concreta (tokens CSRF).

Cuatro patrones según lo que exija el sitio

Antes de escribir código, decide qué necesitas compartir. No siempre hacen falta cookies: para resolución sin estado basta con repartir tokens.

Patrón Cuándo usarlo
Bloqueo de sesión Un worker gestiona el login y el resto consume las cookies
Pool de tokens Alto rendimiento: reutiliza tokens dentro de su TTL entre varios workers
Compartir cookies Los workers necesitan sesiones autenticadas
Afinidad de proxy El sitio de destino vincula la IP con la sesión

Implementación en Python

El almacén de sesión

La clase SessionStore encapsula todas las operaciones sobre Redis: guardar y leer cookies, cachear tokens resueltos con un TTL corto y coordinar el acceso mediante un lock por dominio.

import os
import json
import time
import redis
import requests
from datetime import datetime, timezone

r = redis.Redis(
    host=os.environ.get("REDIS_HOST", "localhost"),
    port=int(os.environ.get("REDIS_PORT", 6379)),
    decode_responses=True
)

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class SessionStore:
    """Shared session state across distributed workers."""

    def __init__(self, domain):
        self.domain = domain
        self.cookie_key = f"session:cookies:{domain}"
        self.token_key = f"session:tokens:{domain}"

    def save_cookies(self, cookies, ttl=1800):
        """Store cookies from a successful session."""
        cookie_data = {name: value for name, value in cookies.items()}
        r.hset(self.cookie_key, mapping=cookie_data)
        r.expire(self.cookie_key, ttl)

    def get_cookies(self):
        """Retrieve shared cookies."""
        cookies = r.hgetall(self.cookie_key)
        return cookies if cookies else None

    def save_token(self, sitekey, token, ttl=80):
        """Store a solved CAPTCHA token."""
        key = f"{self.token_key}:{sitekey}"
        r.rpush(key, token)
        r.expire(key, ttl)

    def get_token(self, sitekey):
        """Pop a cached CAPTCHA token."""
        key = f"{self.token_key}:{sitekey}"
        return r.lpop(key)

    def acquire_session_lock(self, worker_id, ttl=300):
        """Ensure only one worker manages the session at a time."""
        lock_key = f"session:lock:{self.domain}"
        return r.set(lock_key, worker_id, nx=True, ex=ttl)

    def release_session_lock(self, worker_id):
        """Release session lock if this worker holds it."""
        lock_key = f"session:lock:{self.domain}"
        current = r.get(lock_key)
        if current == worker_id:
            r.delete(lock_key)

El worker con estado compartido

El worker carga las cookies compartidas antes de trabajar, consulta la caché de tokens antes de gastar una resolución en la API y, al terminar, devuelve las cookies resultantes al almacén para el resto del clúster.

class CaptchaWorker:
    def __init__(self, worker_id, domain):
        self.worker_id = worker_id
        self.store = SessionStore(domain)
        self.session = requests.Session()

    def setup_session(self):
        """Load shared cookies into this worker's session."""
        cookies = self.store.get_cookies()
        if cookies:
            for name, value in cookies.items():
                self.session.cookies.set(name, value)
            return True
        return False

    def solve_captcha(self, sitekey, pageurl):
        """Solve with token cache and session sharing."""
        # Check for cached token
        cached = self.store.get_token(sitekey)
        if cached:
            return {"solution": cached, "source": "cache"}

        # Solve via CaptchaAI
        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": API_KEY,
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": 1
        })
        data = resp.json()
        if data.get("status") != 1:
            return {"error": data.get("request")}

        captcha_id = data["request"]

        for _ in range(60):
            time.sleep(5)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": API_KEY, "action": "get",
                "id": captcha_id, "json": 1
            }).json()

            if result.get("status") == 1:
                token = result["request"]
                self.store.save_token(sitekey, token)
                return {"solution": token, "source": "api"}

            if result.get("request") != "CAPCHA_NOT_READY":
                return {"error": result.get("request")}

        return {"error": "TIMEOUT"}

    def process_page(self, url, sitekey):
        """Full workflow: setup session → solve CAPTCHA → submit."""
        # Load shared session
        self.setup_session()

        # Solve CAPTCHA
        result = self.solve_captcha(sitekey, url)
        if "error" in result:
            return result

        # Submit form with token
        response = self.session.post(url, data={
            "g-recaptcha-response": result["solution"]
        })

        # Share resulting cookies
        self.store.save_cookies(dict(self.session.cookies))

        return {"status": response.status_code, "source": result["source"]}

Reparto de proxies entre workers

Si todos los workers salen por la misma IP, el sitio de destino los agrupa y los bloquea juntos. Un pool de proxies respaldado por Redis asigna una salida distinta a cada worker y la devuelve al terminar.

class ProxyPool:
    """Distribute proxies across workers to avoid IP conflicts."""

    def __init__(self, proxies):
        self.pool_key = "session:proxy_pool"
        self.assigned_key = "session:proxy_assigned"
        # Initialize pool
        for proxy in proxies:
            r.sadd(self.pool_key, proxy)

    def acquire_proxy(self, worker_id, ttl=600):
        """Assign an unused proxy to a worker."""
        # Check if worker already has one
        existing = r.hget(self.assigned_key, worker_id)
        if existing:
            return existing

        # Pop from available pool
        proxy = r.spop(self.pool_key)
        if proxy:
            r.hset(self.assigned_key, worker_id, proxy)
            r.expire(self.assigned_key, ttl)
            return proxy
        return None

    def release_proxy(self, worker_id):
        """Return proxy to the pool."""
        proxy = r.hget(self.assigned_key, worker_id)
        if proxy:
            r.sadd(self.pool_key, proxy)
            r.hdel(self.assigned_key, worker_id)

Implementación en Node.js

La misma lógica en Node.js con ioredis y axios: el SessionStore gestiona cookies, tokens y locks, y workerSolve reutiliza el token cacheado antes de llamar a la API.

const Redis = require("ioredis");
const axios = require("axios");

const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.CAPTCHAAI_API_KEY;

class SessionStore {
  constructor(domain) {
    this.domain = domain;
    this.cookieKey = `session:cookies:${domain}`;
    this.tokenKey = `session:tokens:${domain}`;
  }

  async saveCookies(cookies, ttl = 1800) {
    const entries = Object.entries(cookies).flat();
    if (entries.length > 0) {
      await redis.hset(this.cookieKey, ...entries);
      await redis.expire(this.cookieKey, ttl);
    }
  }

  async getCookies() {
    return await redis.hgetall(this.cookieKey);
  }

  async saveToken(sitekey, token, ttl = 80) {
    const key = `${this.tokenKey}:${sitekey}`;
    await redis.rpush(key, token);
    await redis.expire(key, ttl);
  }

  async getToken(sitekey) {
    return await redis.lpop(`${this.tokenKey}:${sitekey}`);
  }

  async acquireLock(workerId, ttl = 300) {
    const result = await redis.set(`session:lock:${this.domain}`, workerId, "NX", "EX", ttl);
    return result === "OK";
  }

  async releaseLock(workerId) {
    const current = await redis.get(`session:lock:${this.domain}`);
    if (current === workerId) await redis.del(`session:lock:${this.domain}`);
  }
}

async function workerSolve(store, sitekey, pageurl) {
  const cached = await store.getToken(sitekey);
  if (cached) return { solution: cached, source: "cache" };

  const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: { key: API_KEY, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
  });
  if (submit.data.status !== 1) return { error: submit.data.request };

  const captchaId = submit.data.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const poll = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });
    if (poll.data.status === 1) {
      await store.saveToken(sitekey, poll.data.request);
      return { solution: poll.data.request, source: "api" };
    }
    if (poll.data.request !== "CAPCHA_NOT_READY") return { error: poll.data.request };
  }
  return { error: "TIMEOUT" };
}

Un caso práctico: monitoreo distribuido de precios

Supón que monitorizas precios en varios marketplaces de la región (una plataforma tipo MercadoLibre, Amazon.es) desde un clúster de 20 workers, y algunas páginas están protegidas por reCAPTCHA v2. Si cada worker resuelve por su cuenta, duplicas llamadas a la API y presentas 20 sesiones sin relación. Con estado compartido, un worker resuelve, guarda el token en Redis con TTL corto y el resto lo reutiliza dentro de su ventana de validez; el pool de proxies evita que las 20 IPs choquen.

En coste, esto encaja con el modelo por threads de CaptchaAI: un plan ADVANCE ($90/mes, 50 threads) cubre 50 resoluciones concurrentes con solves ilimitados, holgado para 20 workers. Recuerda respetar 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).

Resolución de problemas

Síntoma Causa Solución
Cada worker acaba con una sesión distinta Las cookies no se comparten por Redis Comprueba que se llama a save_cookies tras cada solicitud correcta
El token caduca antes de que otro worker lo use TTL demasiado largo o latencia de red Reduce el margen del TTL; usa el token en los 10 segundos siguientes a recuperarlo
El bloqueo de sesión no se libera nunca El worker se cayó El TTL del lock lo libera solo (300 segundos por defecto)
El sitio de destino bloquea a los workers Todos usan el mismo proxy Usa un pool de proxies con afinidad por worker

Preguntas frecuentes

¿Cuántos workers puedo ejecutar en paralelo?

Tantos como threads tenga tu plan. En CaptchaAI facturas por thread (un CAPTCHA en curso), no por resolución: el plan BASIC ($15/mes) incluye 5 threads y ADVANCE ($90/mes) llega a 50. El estado compartido reduce el número de resoluciones reales, así que un plan modesto rinde más de lo que su número de threads sugiere.

¿Redis es obligatorio o puedo usar otra base de datos?

Redis es la opción natural por su TTL nativo y sus estructuras (hash, lista, set) que encajan con cada tipo de estado. Cualquier almacén con expiración por clave sirve, pero perderás los locks atómicos y el SPOP del pool de proxies sin escribir esa lógica a mano.

¿Cómo evito condiciones de carrera cuando dos workers escriben cookies a la vez?

Usa el bloqueo de sesión: acquire_session_lock con nx=True deja que solo un worker reautentique y refresque las cookies a la vez. El resto espera y lee el estado ya actualizado, sin sobrescribirlo.

¿Qué pasa si un worker se cae con el bloqueo tomado?

Nada permanente. El lock lleva un TTL (300 segundos por defecto), así que expira solo y otro worker puede adquirirlo. Por eso nunca conviene un lock sin caducidad: un proceso caído dejaría el dominio bloqueado para siempre.


Coordina tus workers CAPTCHA distribuidos sin duplicar resoluciones: obtén tu API key de CaptchaAI.

Guías relacionadas:

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