Dès qu'il faut résoudre des dizaines de CAPTCHA en parallèle, une simple boucle for finit par saturer l'API et faire gonfler la mémoire. La réponse tient en un mot : une file d'attente. Elle plafonne le nombre de résolutions simultanées, relance les échecs et vous donne une visibilité claire sur le débit.
Node.js s'y prête particulièrement bien. Il est monothread, mais taillé pour la concurrence I/O : il enchaîne les appels réseau pendant que l'API CaptchaAI travaille. Ce guide part du lot le plus simple avec Promise.allSettled, puis monte progressivement vers une file de production dotée de priorités, de nouvelles tentatives et d'une supervision.
Lot simple avec Promise.allSettled
Le moyen le plus direct de traiter un lot : lancez toutes les résolutions d'un coup et récupérez chaque résultat, réussite ou échec, sans qu'une tâche fautive fasse tomber les autres. Promise.allSettled ne rejette jamais globalement — il renvoie l'état de chaque promesse, ce qui vous laisse trier les résultats après coup. Ce modèle convient tant que le lot reste modeste ; au-delà de quelques dizaines de tâches, il faut brider la concurrence.
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`);
Limiter la concurrence des résolutions
Promise.allSettled envoie tout en même temps, ce que vous ne voulez surtout pas face à une API à quota. La classe ci-dessous garde au plus maxConcurrent résolutions actives et met le reste en file jusqu'à ce qu'un créneau se libère.
Calez maxConcurrent sur le nombre de threads de votre plan CaptchaAI, qui facture au thread concurrent et non à la résolution : 5 avec le plan BASIC ($15/mois, 5 threads), 15 avec STANDARD ($30/mois, 15 threads), 50 avec ADVANCE ($90/mois, 50 threads). Au-delà de votre allocation, l'API renvoie ERROR_NO_SLOT_AVAILABLE sans rien résoudre de plus :
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}`);
Suivre la progression avec EventEmitter
Sur un lot qui dure plusieurs minutes, vous voulez savoir où il en est sans attendre la fin. En étendant EventEmitter, la file émet des événements submitted, solved, failed et complete que vous branchez sur vos logs, un tableau de bord ou vos métriques :
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}`,
});
}
Prioriser les résolutions critiques
Toutes les tâches ne se valent pas. Un CAPTCHA qui bloque un parcours de paiement doit passer avant un lot de scraping de fiches produits. Une file prioritaire trie les tâches par importance avant de les dépiler, si bien qu'une résolution urgente ne reste jamais coincée derrière une centaine de tâches de fond.
Scénario concret : vous testez en staging le checkout d'un site e-commerce pendant qu'un job nocturne, hébergé sur OVHcloud ou Scaleway, parcourt le catalogue. Le token du checkout (priorité 1) doit sortir avant les résolutions de scraping (priorité 5), même s'il arrive dans la file bien après elles.
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
);
}
Nouvelles tentatives et file de lettres mortes
En production, une résolution échoue parfois pour une raison passagère : un timeout, un pic de charge, un token expiré. Plutôt que d'abandonner à la première erreur, réessayez un nombre borné de fois, puis rangez les échecs définitifs dans une file de lettres mortes (dead-letter) que vous inspecterez à froid. Vous rejouez ainsi les échecs récupérables sans jamais retraiter les tâches déjà réussies.
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);
}
}
Surveiller le débit et le taux de réussite
Une file sans mesure est une boîte noire. Ce moniteur agrège le nombre de tâches soumises, en cours, résolues et en échec, puis en déduit le temps de résolution moyen, le débit par minute et le taux de réussite. Ces chiffres vous disent quand ajuster maxConcurrent ou passer à un plan offrant plus de threads.
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)}%`,
};
}
}
Dépannage
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Toutes les promesses échouent en même temps | Limite de débit de l'API atteinte | Réduisez maxConcurrent |
| La mémoire grimpe au fil du temps | Les résultats s'accumulent en mémoire | Traitez puis videz les résultats régulièrement |
| La file se vide mais des tâches restent en attente | Appel à drain() manquant après la fin d'une tâche |
Vérifiez le déclenchement de drain() dans le bloc finally |
ERROR_NO_SLOT_AVAILABLE |
Trop d'appels API simultanés pour votre plan | Ajoutez un délai entre les soumissions ou baissez la concurrence |
| La file de lettres mortes se remplit | Erreurs persistantes sur les mêmes tâches | Inspectez les types d'erreurs : souvent un paramètre (sitekey, pageurl) à corriger |
Questions fréquemment posées
Faut-il aligner la concurrence sur le nombre de threads de mon plan ?
Oui. maxConcurrent ne devrait pas dépasser les threads de votre plan CaptchaAI : 5 avec BASIC ($15/mois, 5 threads), 50 avec ADVANCE ($90/mois, 50 threads). Au-delà, les résolutions supplémentaires patientent sans améliorer le débit.
Que faire quand l'API renvoie ERROR_NO_SLOT_AVAILABLE ?
C'est le signe que vous lancez plus de résolutions simultanées que votre plan ne l'autorise. Baissez maxConcurrent, espacez les soumissions, ou passez à un plan offrant davantage de threads.
Quand remplacer ces classes par bull ou bullmq ?
Les modèles ci-dessus suffisent pour un processus unique. Dès que la file doit survivre à un redémarrage ou être partagée entre plusieurs serveurs, adoptez bull ou bullmq avec Redis pour la persistance.
Comment relancer uniquement les CAPTCHA en échec ?
Passez par la file de nouvelles tentatives : chaque tâche compte ses essais et repart dans la file tant qu'elle n'a pas atteint maxRetries, avant d'être classée en lettre morte. Vous rejouez les échecs sans jamais retraiter les réussites.
Résumé
Node.js brille sur l'I/O concurrent, ce qui en fait un socle solide pour une file de résolution CAPTCHA adossée à CaptchaAI. Retenez la progression : Promise.allSettled pour les petits lots, EventEmitter pour le suivi en temps réel, les files prioritaires pour les flux critiques et les files de nouvelles tentatives pour la fiabilité. Calez toujours la concurrence sur les threads de votre plan.