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
- Cómo diseñar pipelines CAPTCHA para clientes con CaptchaAI
- Medir los tiempos de resolución de CAPTCHA con CaptchaAI
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: