El límite de tu automatización no es Node.js: es cuántas resoluciones mantienes en vuelo a la vez. Lanza 200 de golpe y casi todas esperan, con alguna devolviendo ERROR_NO_SLOT_AVAILABLE; de una en una, un lote de minutos te lleva horas. El punto medio: ocupar tantos slots como threads tenga tu plan.
Esa cola se construye por capas: del lote mínimo con Promise.allSettled a prioridades, reintentos y métricas, con fetch nativo y los endpoints in.php y res.php de CaptchaAI.
Cuántas resoluciones simultáneas te corresponden
CaptchaAI factura por thread concurrente, con resoluciones ilimitadas durante el mes. Un thread es un CAPTCHA en vuelo que, al terminar, queda libre para el siguiente: por eso tu maxConcurrent no debería superar los threads contratados.
| Plan | Precio | Threads | maxConcurrent de partida |
|---|---|---|---|
| BASIC | $15/mes | 5 | 5 |
| STANDARD | $30/mes | 15 | 12–15 |
| ADVANCE | $90/mes | 50 | 40–50 |
El throughput sale de cruzar ese número con el tiempo de resolución: Cloudflare Turnstile suele resolverse en menos de 10 segundos y GeeTest v3 en menos de 12, mientras reCAPTCHA v2 puede acercarse a los 60. Con 15 threads de reCAPTCHA v2 son unas 15 tareas por minuto; con Turnstile, varias veces más. Fija maxConcurrent con tres datos:
- Threads de tu plan
- Tiempo de resolución del tipo que más envías
- Pico de tareas por minuto
Lote simple con Promise.allSettled
Empieza por una función que envía la tarea, la sondea y devuelve el token. Frente a Promise.all, Promise.allSettled no aborta el lote al primer fallo: recuperas tokens y errores en la misma pasada.
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function solveSingle(method, params) {
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({ key: API_KEY, method, json: "1", ...params }),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) throw new Error(submitData.request);
const taskId = submitData.request;
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const data = await pollResp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
}
throw new Error("Timed out");
}
// Solve all at once
async function solveBatch(tasks) {
const results = await Promise.allSettled(
tasks.map((task) => solveSingle(task.method, task.params))
);
return results.map((result, i) => ({
taskId: tasks[i].id,
status: result.status,
value: result.status === "fulfilled" ? result.value : null,
error: result.status === "rejected" ? result.reason.message : null,
}));
}
// Usage
const tasks = Array.from({ length: 10 }, (_, i) => ({
id: i,
method: "userrecaptcha",
params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));
const results = await solveBatch(tasks);
console.log(`Solved: ${results.filter((r) => r.status === "fulfilled").length}/10`);
El bucle espera 5 segundos entre consultas y se rinde en la vuelta 30: consultar más rápido gasta cuota sin acelerar nada. Sirve hasta veinte tareas; por encima, lanzarlas de golpe provoca rechazos en cadena.
Cola con simultaneidad limitada
Aquí entra el techo: la clase lleva un contador de activas y una lista de espera, y solo arranca una tarea cuando otra libera su hueco.
class ConcurrencyQueue {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
this.results = [];
}
add(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.#process();
});
}
async #process() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) return;
this.running++;
const { fn, resolve, reject } = this.queue.shift();
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.#process();
}
}
async addBatch(fns) {
return Promise.allSettled(fns.map((fn) => this.add(fn)));
}
}
// Usage
const queue = new ConcurrencyQueue(5);
const tasks = Array.from({ length: 20 }, (_, i) => () =>
solveSingle("userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
})
);
const results = await queue.addBatch(tasks);
const solved = results.filter((r) => r.status === "fulfilled");
console.log(`Solved: ${solved.length}/${results.length}`);
Con maxConcurrent alineado a tus threads da igual que le eches 20 tareas o 2.000: las solicitudes en vuelo no cambian. También resuelve la contrapresión: el await sobre add() frena al productor.
Seguimiento del progreso con EventEmitter
En lotes largos necesitas saber qué pasa sin esperar al final. Extender EventEmitter emite eventos por tarea enviada, resuelta o fallida, listos para tus logs.
const { EventEmitter } = require("events");
class CaptchaQueue extends EventEmitter {
#apiKey;
#maxConcurrent;
#pending;
#active;
constructor(apiKey, maxConcurrent = 5) {
super();
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#pending = [];
this.#active = 0;
this.stats = { submitted: 0, solved: 0, failed: 0 };
}
submit(id, method, params) {
this.#pending.push({ id, method, params });
this.stats.submitted++;
this.emit("submitted", { id, total: this.stats.submitted });
this.#drain();
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#pending.length > 0) {
const task = this.#pending.shift();
this.#active++;
this.#solve(task).finally(() => {
this.#active--;
this.#drain();
if (this.#active === 0 && this.#pending.length === 0) {
this.emit("complete", this.stats);
}
});
}
}
async #solve(task) {
try {
const token = await solveSingle(task.method, task.params);
this.stats.solved++;
this.emit("solved", { id: task.id, token, stats: { ...this.stats } });
} catch (error) {
this.stats.failed++;
this.emit("failed", { id: task.id, error: error.message, stats: { ...this.stats } });
}
}
}
// Usage
const queue = new CaptchaQueue("YOUR_API_KEY", 5);
queue.on("submitted", ({ id, total }) => {
console.log(`Submitted #${id} (total: ${total})`);
});
queue.on("solved", ({ id, stats }) => {
console.log(`Solved #${id} — ${stats.solved}/${stats.submitted}`);
});
queue.on("failed", ({ id, error }) => {
console.log(`Failed #${id}: ${error}`);
});
queue.on("complete", (stats) => {
const rate = ((stats.solved / stats.submitted) * 100).toFixed(1);
console.log(`Done: ${stats.solved}/${stats.submitted} (${rate}%)`);
});
// Submit tasks
for (let i = 0; i < 15; i++) {
queue.submit(i, "userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
});
}
El evento complete se dispara cuando no queda nada activo ni pendiente: momento de encadenar el siguiente lote.
Cola de prioridad: qué se resuelve primero
No todas las tareas valen lo mismo: una validación con un usuario esperando va antes que un trabajo de fondo. La cola de prioridad ordena por peso antes de repartir huecos.
class PriorityQueue {
#items = [];
enqueue(item, priority) {
this.#items.push({ item, priority });
this.#items.sort((a, b) => a.priority - b.priority);
}
dequeue() {
return this.#items.shift()?.item;
}
get length() {
return this.#items.length;
}
}
class PriorityCaptchaQueue {
#apiKey;
#maxConcurrent;
#queue;
#active;
#results;
constructor(apiKey, maxConcurrent = 5) {
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#queue = new PriorityQueue();
this.#active = 0;
this.#results = new Map();
}
submit(id, method, params, priority = 5) {
return new Promise((resolve, reject) => {
this.#queue.enqueue({ id, method, params, resolve, reject }, priority);
this.#drain();
});
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#queue.length > 0) {
const task = this.#queue.dequeue();
this.#active++;
solveSingle(task.method, task.params)
.then((token) => {
this.#results.set(task.id, { status: "solved", token });
task.resolve(token);
})
.catch((err) => {
this.#results.set(task.id, { status: "error", error: err.message });
task.reject(err);
})
.finally(() => {
this.#active--;
this.#drain();
});
}
}
}
// Usage: high-priority checkout, low-priority scraping
const pq = new PriorityCaptchaQueue("YOUR_API_KEY", 3);
// Priority 1 (highest) — checkout
const checkoutToken = pq.submit(
"checkout_1",
"turnstile",
{ sitekey: "KEY", pageurl: "https://shop.com/checkout" },
1
);
// Priority 5 (normal) — product scraping
for (let i = 0; i < 5; i++) {
pq.submit(
`product_${i}`,
"userrecaptcha",
{ googlekey: "KEY", pageurl: `https://shop.com/p/${i}` },
5
);
}
Con tres huecos, una tarea de prioridad 1 entra en la siguiente ranura libre aunque haya cincuenta de prioridad 5 delante. Si el tráfico interactivo es constante, resérvale un thread fijo.
Reintentos y cola de mensajes fallidos
Un fallo aislado rara vez es definitivo: un sitekey caducado, una página que cambió. Reintenta unas pocas veces y manda el resto a una cola de mensajes fallidos para revisarla después.
class RetryQueue {
#apiKey;
#maxRetries;
#results;
#deadLetter;
constructor(apiKey, maxRetries = 3) {
this.#apiKey = apiKey;
this.#maxRetries = maxRetries;
this.#results = [];
this.#deadLetter = [];
}
async processBatch(tasks, maxConcurrent = 5) {
const queue = tasks.map((t) => ({ ...t, attempts: 0 }));
while (queue.length > 0) {
const batch = queue.splice(0, maxConcurrent);
const results = await Promise.allSettled(
batch.map((task) => this.#solveWithRetry(task))
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const task = batch[i];
if (result.status === "fulfilled") {
this.#results.push({ id: task.id, token: result.value });
} else {
task.attempts++;
if (task.attempts < this.#maxRetries) {
queue.push(task); // Retry
console.log(`Retry ${task.attempts}/${this.#maxRetries}: ${task.id}`);
} else {
this.#deadLetter.push({
id: task.id,
error: result.reason.message,
attempts: task.attempts,
});
}
}
}
}
return {
solved: this.#results,
failed: this.#deadLetter,
};
}
async #solveWithRetry(task) {
return solveSingle(task.method, task.params);
}
}
Si esa cola se llena de ERROR_CAPTCHA_UNSOLVABLE, revisa tus parámetros; si se llena de tiempos de espera agotados, mira la simultaneidad y la red. Distingue qué vale la pena reintentar:
- Tiempos de espera y errores de red
ERROR_NO_SLOT_AVAILABLE, tras una pausa- Nunca un
sitekeyopageurlinválidos: van directos a fallidos
Métricas: throughput, tiempo medio y tasa de éxito
Sin números no sabes si la cola va bien. Bastan tres: tareas por minuto, tiempo medio y tasa de éxito.
class QueueMonitor {
#startTime;
#solveTimes;
constructor() {
this.#startTime = Date.now();
this.#solveTimes = [];
this.counts = { submitted: 0, solving: 0, solved: 0, failed: 0 };
}
recordSubmit() {
this.counts.submitted++;
this.counts.solving++;
}
recordSolved(solveTime) {
this.counts.solving--;
this.counts.solved++;
this.#solveTimes.push(solveTime);
}
recordFailed() {
this.counts.solving--;
this.counts.failed++;
}
report() {
const elapsed = (Date.now() - this.#startTime) / 1000;
const avgTime =
this.#solveTimes.length > 0
? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length
: 0;
const throughput = this.counts.solved / (elapsed / 60);
const successRate =
this.counts.solved + this.counts.failed > 0
? (this.counts.solved / (this.counts.solved + this.counts.failed)) * 100
: 0;
return {
elapsed: `${elapsed.toFixed(0)}s`,
submitted: this.counts.submitted,
solving: this.counts.solving,
solved: this.counts.solved,
failed: this.counts.failed,
avgSolveTime: `${(avgTime / 1000).toFixed(1)}s`,
throughput: `${throughput.toFixed(1)}/min`,
successRate: `${successRate.toFixed(1)}%`,
};
}
}
Registra el informe cada 30 o 60 segundos. Un tiempo medio que sube mientras el throughput baja apunta a saturación; una caída brusca de la tasa de éxito suele venir del sitio de origen. Vigila tres señales:
- Tiempo medio que dobla tu línea base
- Throughput a la mitad
- Caída sostenida de la tasa de éxito
Caso práctico: monitorización de precios en marketplaces regionales
Una agencia de datos en Bogotá revisa cada noche 4.000 fichas de producto en marketplaces regionales: dos muestran Turnstile de forma intermitente y otro pide reCAPTCHA v2 al paginar. Con STANDARD ($30/mes, 15 threads) y maxConcurrent en 12, la prioridad alta va a las 200 fichas del informe de la mañana; lo fallido se reprocesa a las 6:00.
El mismo esquema sirve en QA para vigilar que un portal público de trámites —cita previa, centros de visado— siga respondiendo: pocos flujos, prioridad máxima y alerta si el tiempo de resolución se dispara. Respeta los términos de servicio y la normativa de protección de datos aplicable. El montaje cabe en tres reglas:
maxConcurrentpor debajo de los threads contratados- Prioridad alta solo para lo que alguien espera
- Reproceso diferido de lo fallido
Errores frecuentes y cómo salir de ellos
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Todas las promesas se rechazan a la vez | Límite de solicitudes de la API | Baja maxConcurrent |
| La memoria crece sin parar | Resultados acumulados en el array | Procesa y vacía por lotes |
| La cola se vacía pero quedan tareas | Falta #drain() en el finally |
Comprueba que el drenaje siempre se ejecuta |
ERROR_NO_SLOT_AVAILABLE |
Más llamadas simultáneas que threads | Ajusta maxConcurrent o sube de plan |
| Se llena la cola de mensajes fallidos | Errores persistentes | Casi siempre es un parámetro mal formado |
Preguntas frecuentes
¿Qué plan necesito para unas 3.000 tareas al día?
Depende del tipo. Con STANDARD ($30/mes, 15 threads) y tareas de pocos segundos, ese volumen cabe en una ventana nocturna; si es reCAPTCHA v2, calcula 15 tareas por minuto y valora ADVANCE ($90/mes, 50 threads).
¿Qué significa ERROR_NO_SLOT_AVAILABLE y cómo lo evito?
Que pediste más resoluciones simultáneas de las que sostiene tu plan. Baja maxConcurrent y trata ese código como señal de acelerador, no como fallo de la tarea.
¿Cuándo doy el salto a Redis con bull o bullmq?
Cuando la cola deba sobrevivir a un reinicio o repartirse entre procesos. Estas clases viven en memoria y bastan para un worker único; con dos instancias compitiendo por los mismos threads necesitas un backend compartido.
¿Sirve la misma cola para Turnstile y GeeTest v3?
Sí. Solo cambian el method y los parámetros: enviar a in.php, consultar res.php y devolver el token es idéntico. Mezclar tipos en una cola es habitual; da más prioridad a los que bloquean a un usuario.
En resumen
Una cola de CAPTCHA en Node.js es disciplina con un número: la simultaneidad. Fíjala según los threads de tu plan, añade prioridades cuando el tiempo de respuesta importe y mide el throughput real con CaptchaAI.