Tutoriales

Construyendo un bus de eventos de resolución CAPTCHA con Node.js y CaptchaAI

Un bus de eventos convierte cada CAPTCHA en una señal que cualquier módulo de tu aplicación puede escuchar. En lugar de que el código de resolución sepa quién registra logs, quién cuenta métricas o quién reintenta, publicas cambios de estado —enviado, pendiente, resuelto, fallido, timeout— y cada oyente reacciona por su cuenta.

Esto importa cuando el volumen crece. Si un scraper resuelve cientos de CAPTCHA por hora —monitoreando precios en un marketplace regional o validando un flujo de checkout de prueba— necesitas ver en tiempo real qué se envió, qué se resolvió y qué falló, sin enredar esa lógica dentro de la llamada a la API. Los callbacks y el sondeo devuelven un resultado puntual; un bus de eventos te da visibilidad de todo el ciclo de vida.

Arquitectura del bus de eventos

El bus expone un único punto donde se publican los estados y varios oyentes que se suscriben de forma independiente:

[CaptchaBus]
   ├── emit("submitted", { taskId, type, pageurl })
   ├── emit("pending", { taskId, elapsed })
   ├── emit("solved", { taskId, solution, duration })
   ├── emit("failed", { taskId, error, duration })
   └── emit("timeout", { taskId, elapsed })
        ↓          ↓           ↓
   [Logger]    [Metrics]   [Retry Handler]

Cada oyente se registra por separado. Sumar una capacidad nueva —por ejemplo, recolección de métricas o un reintento automático— no toca ni una línea del código de resolución: solo añades otro on(...).

La clase CaptchaBus en Node.js

El patrón es directo: extiende EventEmitter, envía la tarea a in.php, arranca el sondeo contra res.php y emite un evento en cada transición de estado. Así queda la clase completa:

const EventEmitter = require("events");
const axios = require("axios");

class CaptchaBus extends EventEmitter {
  constructor(apiKey, options = {}) {
    super();
    this.apiKey = apiKey;
    this.pollInterval = options.pollInterval || 5000;
    this.maxWait = options.maxWait || 300000; // 5 minutes
    this.pending = new Map();
  }

  async submit(params) {
    const { method, sitekey, pageurl, ...extra } = params;
    const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

    const submitParams = {
      key: this.apiKey,
      method: method || "userrecaptcha",
      googlekey: sitekey,
      pageurl: pageurl,
      json: 1,
      ...extra,
    };

    try {
      const resp = await axios.post(
        "https://ocr.captchaai.com/in.php",
        null,
        { params: submitParams }
      );

      if (resp.data.status !== 1) {
        this.emit("failed", {
          taskId,
          error: resp.data.request,
          duration: 0,
        });
        return null;
      }

      const captchaId = resp.data.request;
      const startTime = Date.now();

      this.emit("submitted", {
        taskId,
        captchaId,
        method: method || "userrecaptcha",
        pageurl,
      });

      // Start polling
      this._poll(taskId, captchaId, startTime);
      return taskId;
    } catch (err) {
      this.emit("failed", { taskId, error: err.message, duration: 0 });
      return null;
    }
  }

  async _poll(taskId, captchaId, startTime) {
    const check = async () => {
      const elapsed = Date.now() - startTime;

      if (elapsed > this.maxWait) {
        this.emit("timeout", { taskId, elapsed });
        return;
      }

      this.emit("pending", { taskId, elapsed });

      try {
        const resp = await axios.get("https://ocr.captchaai.com/res.php", {
          params: {
            key: this.apiKey,
            action: "get",
            id: captchaId,
            json: 1,
          },
        });

        if (resp.data.status === 1) {
          this.emit("solved", {
            taskId,
            captchaId,
            solution: resp.data.request,
            duration: Date.now() - startTime,
          });
        } else if (resp.data.request === "CAPCHA_NOT_READY") {
          setTimeout(check, this.pollInterval);
        } else {
          this.emit("failed", {
            taskId,
            error: resp.data.request,
            duration: Date.now() - startTime,
          });
        }
      } catch (err) {
        this.emit("failed", {
          taskId,
          error: err.message,
          duration: Date.now() - startTime,
        });
      }
    };

    setTimeout(check, this.pollInterval);
  }
}

module.exports = CaptchaBus;

El detalle clave: submit no espera al resultado. Devuelve el taskId de inmediato y _poll emite solved, failed o timeout cuando corresponda.

Registrar los oyentes del bus

Con la clase lista, conectas los oyentes. Aquí van dos: uno que escribe logs legibles y otro que acumula métricas. Ambos escuchan submitted sin pisarse, porque un EventEmitter admite varios oyentes por evento.

const CaptchaBus = require("./captcha-bus");

const bus = new CaptchaBus(process.env.CAPTCHAAI_API_KEY, {
  pollInterval: 5000,
  maxWait: 120000,
});

// Logging listener
bus.on("submitted", (e) => {
  console.log(`[SUBMIT] ${e.taskId} → ${e.method} on ${e.pageurl}`);
});

bus.on("pending", (e) => {
  console.log(`[PENDING] ${e.taskId} — ${(e.elapsed / 1000).toFixed(1)}s`);
});

bus.on("solved", (e) => {
  console.log(
    `[SOLVED] ${e.taskId} in ${(e.duration / 1000).toFixed(1)}s — ${e.solution.substring(0, 30)}...`
  );
});

bus.on("failed", (e) => {
  console.error(`[FAILED] ${e.taskId} — ${e.error}`);
});

bus.on("timeout", (e) => {
  console.error(
    `[TIMEOUT] ${e.taskId} after ${(e.elapsed / 1000).toFixed(1)}s`
  );
});

// Metrics listener
const metrics = { submitted: 0, solved: 0, failed: 0, totalDuration: 0 };

bus.on("submitted", () => metrics.submitted++);
bus.on("solved", (e) => {
  metrics.solved++;
  metrics.totalDuration += e.duration;
});
bus.on("failed", () => metrics.failed++);

// Submit a CAPTCHA
bus.submit({
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

El objeto metrics se llena solo. Cuando quieras exponer una tasa de éxito o un tiempo de resolución promedio, ya tienes los datos sin tocar la clase CaptchaBus.

La misma lógica en Python

Si tu backend corre en Python, el patrón se traslada sin fricción. No hay EventEmitter nativo, así que se implementa un registro de oyentes mínimo sobre un defaultdict y el sondeo corre en un hilo en segundo plano:

import os
import time
import threading
from collections import defaultdict
import requests


class CaptchaBus:
    def __init__(self, api_key, poll_interval=5, max_wait=300):
        self.api_key = api_key
        self.poll_interval = poll_interval
        self.max_wait = max_wait
        self._listeners = defaultdict(list)

    def on(self, event, callback):
        """Register a listener for an event."""
        self._listeners[event].append(callback)
        return self

    def emit(self, event, data):
        """Emit an event to all registered listeners."""
        for callback in self._listeners.get(event, []):
            try:
                callback(data)
            except Exception as e:
                print(f"Listener error on {event}: {e}")

    def submit(self, sitekey, pageurl, method="userrecaptcha", **extra):
        """Submit a CAPTCHA and begin tracking."""
        task_id = f"task_{int(time.time())}_{id(sitekey) % 10000}"

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": method,
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": 1,
            **extra
        })
        data = resp.json()

        if data.get("status") != 1:
            self.emit("failed", {
                "task_id": task_id,
                "error": data.get("request"),
                "duration": 0
            })
            return None

        captcha_id = data["request"]
        start_time = time.time()

        self.emit("submitted", {
            "task_id": task_id,
            "captcha_id": captcha_id,
            "method": method,
            "pageurl": pageurl
        })

        # Poll in a background thread
        thread = threading.Thread(
            target=self._poll,
            args=(task_id, captcha_id, start_time),
            daemon=True
        )
        thread.start()
        return task_id

    def _poll(self, task_id, captcha_id, start_time):
        while True:
            elapsed = time.time() - start_time

            if elapsed > self.max_wait:
                self.emit("timeout", {"task_id": task_id, "elapsed": elapsed})
                return

            time.sleep(self.poll_interval)
            self.emit("pending", {"task_id": task_id, "elapsed": elapsed})

            resp = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": captcha_id,
                "json": 1
            })
            data = resp.json()

            if data.get("status") == 1:
                self.emit("solved", {
                    "task_id": task_id,
                    "solution": data["request"],
                    "duration": time.time() - start_time
                })
                return
            elif data.get("request") != "CAPCHA_NOT_READY":
                self.emit("failed", {
                    "task_id": task_id,
                    "error": data.get("request"),
                    "duration": time.time() - start_time
                })
                return


# Usage
bus = CaptchaBus(os.environ["CAPTCHAAI_API_KEY"])

bus.on("submitted", lambda e: print(f"[SUBMIT] {e['task_id']}"))
bus.on("solved", lambda e: print(f"[SOLVED] {e['task_id']} in {e['duration']:.1f}s"))
bus.on("failed", lambda e: print(f"[FAILED] {e['task_id']} — {e['error']}"))
bus.on("timeout", lambda e: print(f"[TIMEOUT] {e['task_id']}"))

bus.submit("6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-", "https://example.com")

La interfaz pública —on, emit, submit— es idéntica a la de Node.js, así que portas oyentes entre ambos lenguajes casi sin cambios.

Reintentos automáticos como oyente

Un reintento no tiene por qué vivir dentro de la función de resolución. Es solo otro oyente de failed que vuelve a encolar la tarea hasta un tope de intentos:

// Automatic retry on failure
bus.on("failed", async (e) => {
  if (e.retryCount >= 3) {
    console.error(`[GIVE UP] ${e.taskId} after 3 retries`);
    return;
  }

  console.log(`[RETRY] ${e.taskId} — attempt ${(e.retryCount || 0) + 1}`);
  await bus.submit({
    ...e.originalParams,
    _retryCount: (e.retryCount || 0) + 1,
  });
});

Al ser un oyente independiente, activas o desactivas los reintentos sin tocar submit ni _poll.

Envolver el bus en una promesa

A veces quieres esperar un resultado concreto con await en lugar de reaccionar a eventos sueltos. Envuelve el bus en una promesa que se resuelve o se rechaza según el taskId:

function solveCaptcha(bus, params) {
  return new Promise((resolve, reject) => {
    const taskId = bus.submit(params);

    function onSolved(e) {
      if (e.taskId === taskId) {
        cleanup();
        resolve(e.solution);
      }
    }

    function onFailed(e) {
      if (e.taskId === taskId) {
        cleanup();
        reject(new Error(e.error));
      }
    }

    function cleanup() {
      bus.removeListener("solved", onSolved);
      bus.removeListener("failed", onFailed);
      bus.removeListener("timeout", onFailed);
    }

    bus.on("solved", onSolved);
    bus.on("failed", onFailed);
    bus.on("timeout", onFailed);
  });
}

// Usage
const solution = await solveCaptcha(bus, {
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

El cleanup es lo que evita fugas: elimina los tres oyentes en cuanto la promesa se cierra. Sin él, cada llamada dejaría oyentes colgados y acabarías con el aviso de fuga de memoria de Node.js.

Problemas frecuentes y cómo resolverlos

Problema Causa Solución
El oyente no se dispara El nombre del evento no coincide (por ejemplo, solve en vez de solved) Revisa que los nombres usados en emit y en on sean idénticos
Aviso de fuga de memoria Demasiados oyentes sobre un mismo evento Usa setMaxListeners() o limpia los oyentes cuando dejen de hacer falta
La consola se llena de eventos pending Intervalo de sondeo demasiado corto Sube pollInterval a 5000 ms o más
Se pierden eventos al reintentar Se genera un nuevo taskId en cada reintento Reenvía los parámetros originales para reconectar el estado

Preguntas frecuentes

¿Cómo escalo el bus a varios procesos o workers?

El EventEmitter en proceso solo alcanza dentro de una misma instancia de Node.js. Cuando varios procesos o servicios deben reaccionar a los mismos eventos, mueve la publicación a un broker como Redis (pub/sub), RabbitMQ o Kafka y deja el bus local como capa de suscripción en cada proceso.

¿El bus de eventos consume threads de mi plan de CaptchaAI?

No por sí mismo. CaptchaAI factura por thread concurrente —un CAPTCHA en vuelo—, no por evento ni por oyente. Lo que ocupa un thread es cada tarea que aún no se ha resuelto. El plan BASIC ($15/mes, 5 threads) admite 5 resoluciones simultáneas: si emites cientos de eventos pending por segundo pero solo tienes 3 CAPTCHA en vuelo, usas 3 threads.

¿Puedo usar el mismo bus para reCAPTCHA v2, v3 y Turnstile a la vez?

Sí. El method viaja en cada llamada a submit, así que un único bus enruta reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest v3 o CAPTCHA de imagen sin cambiar la arquitectura. Los eventos son los mismos para todos los tipos; solo cambia el sitekey y el method que envías.

¿Qué pasa con las tareas pendientes si el proceso se reinicia?

Se pierden, porque el bus vive en memoria. Para sobrevivir a reinicios, añade un oyente que persista cada submitted con su captchaId en Redis o una base de datos y reanuda el sondeo al arrancar. Ese mismo oyente te sirve como pista de auditoría para depurar.

Artículos relacionados


Lleva tu resolución de CAPTCHA a un flujo orientado a eventos: obtén tu API key de CaptchaAI y conecta cada estado a tu bus.

Guías relacionadas:

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