API Tutorials

Node.js Promise.allSettled pour la résolution de CAPTCHA par lots

Pour traiter un lot de CAPTCHA en parallèle en Node.js, utilisez Promise.allSettled : elle attend toutes les promesses et vous rend, pour chacune, soit un token, soit une erreur.

Concrètement, sur un lot de 200 tâches dont 13 partent en timeout, vous repartez avec les 187 tokens résolus. Avec Promise.all, ils seraient tous perdus.

Dans un lot, les causes d'échec sont hétérogènes : sitekey mal copié, proxy qui coupe, file d'attente saturée. Le code ci-dessous part d'un lot minimal, puis ajoute les trois couches qui comptent en production : plafond de concurrence, retry sélectif, tri des erreurs.

Promise.all ou Promise.allSettled : ce que vous perdez au premier échec

Même tableau de promesses en entrée ; toute la différence tient au traitement du premier rejet.

// Promise.all — REJECTS if ANY task fails
const results = await Promise.all(tasks.map(solve)); // Throws on first error

// Promise.allSettled — RESOLVES always, with status for each
const results = await Promise.allSettled(tasks.map(solve));
// [{status: "fulfilled", value: "..."}, {status: "rejected", reason: Error}]
Méthode Au premier échec Ce que vous récupérez Usage pertinent
Promise.all Rejette aussitôt Une exception, rien d'autre Traitements tout-ou-rien
Promise.allSettled Poursuit Un status par tâche, succès comme échec Lots de CAPTCHA, appels réseau en masse

Retenez la deuxième ligne : avec Promise.all, les tâches déjà envoyées continuent de consommer vos threads, mais leurs tokens sont perdus. Vous payez la résolution sans jamais en voir le résultat.

Le socle : soumettre, interroger, agréger un lot de CAPTCHA

solveCaptcha fait le travail unitaire — un POST sur in.php, puis l'interrogation régulière de res.php jusqu'au token ou au timeout.

batchSolve l'enveloppe dans Promise.allSettled et sépare les résultats en deux tableaux. L'exemple cible reCAPTCHA v2 (method=userrecaptcha) ; le squelette vaut aussi pour Cloudflare Turnstile ou GeeTest v3.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function solveCaptcha(sitekey, pageurl) {
  // Submit
  const submitResp = await axios.post(
    "https://ocr.captchaai.com/in.php",
    null,
    {
      params: {
        key: API_KEY,
        method: "userrecaptcha",
        googlekey: sitekey,
        pageurl: pageurl,
        json: 1,
      },
    }
  );

  if (submitResp.data.status !== 1) {
    throw new Error(submitResp.data.request);
  }

  const captchaId = submitResp.data.request;

  // Poll
  for (let i = 0; i < 60; i++) {
    await sleep(5000);
    const result = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (result.data.status === 1) return result.data.request;
    if (result.data.request !== "CAPCHA_NOT_READY") {
      throw new Error(result.data.request);
    }
  }

  throw new Error("TIMEOUT");
}

async function batchSolve(tasks) {
  const promises = tasks.map((task) =>
    solveCaptcha(task.sitekey, task.pageurl).then((solution) => ({
      ...task,
      solution,
    }))
  );

  const results = await Promise.allSettled(promises);

  const solved = [];
  const failed = [];

  for (let i = 0; i < results.length; i++) {
    if (results[i].status === "fulfilled") {
      solved.push(results[i].value);
    } else {
      failed.push({
        task: tasks[i],
        error: results[i].reason.message,
      });
    }
  }

  return { solved, failed };
}

// Usage
(async () => {
  const tasks = [
    {
      sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
      pageurl: "https://example.com/page/1",
    },
    {
      sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
      pageurl: "https://example.com/page/2",
    },
    {
      sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
      pageurl: "https://example.com/page/3",
    },
  ];

  const { solved, failed } = await batchSolve(tasks);
  console.log(`Solved: ${solved.length}, Failed: ${failed.length}`);

  for (const s of solved) {
    console.log(`  ✓ ${s.pageurl}: ${s.solution.substring(0, 30)}...`);
  }
  for (const f of failed) {
    console.log(`  ✗ ${f.task.pageurl}: ${f.error}`);
  }
})();

L'index i relie chaque rejet à sa tâche d'origine — une information que Promise.allSettled ne transporte pas.

Aligner la concurrence sur les threads de votre plan

L’envoi simultané de 1 000 CAPTCHA ne les résout pas plus vite : vous saturez le pool de sockets de Node.js et dépassez la capacité de votre plan.

La facturation CaptchaAI repose sur les threads — un thread = un CAPTCHA en vol, résolutions illimitées par thread :

  • BASIC ($15/mois, 5 threads)
  • STANDARD ($30/mois, 15 threads)
  • ADVANCE ($90/mois, 50 threads)
  • PREMIUM ($170/mois, 100 threads)

Le bon plafond de concurrence, c'est votre nombre de threads, pas la taille du lot.

Le pool ci-dessous consomme les tâches à concurrence fixe et écrit chaque résultat à son index d'origine :

async function batchSolveWithLimit(tasks, concurrency = 10) {
  const results = [];
  let index = 0;

  async function worker() {
    while (index < tasks.length) {
      const i = index++;
      const task = tasks[i];

      try {
        const solution = await solveCaptcha(task.sitekey, task.pageurl);
        results[i] = { status: "fulfilled", value: { ...task, solution } };
      } catch (err) {
        results[i] = { status: "rejected", reason: err };
      }
    }
  }

  // Launch concurrent workers
  const workers = Array.from({ length: concurrency }, () => worker());
  await Promise.allSettled(workers);

  const solved = results
    .filter((r) => r.status === "fulfilled")
    .map((r) => r.value);
  const failed = results
    .filter((r) => r.status === "rejected")
    .map((r, i) => ({ task: tasks[i], error: r.reason.message }));

  return { solved, failed };
}

// Solve 100 CAPTCHAs, 10 at a time
const { solved, failed } = await batchSolveWithLimit(tasks, 10);

Relancer uniquement les erreurs transitoires

Toutes les erreurs ne méritent pas une nouvelle tentative. Le tri se fait sur le message de l'API :

  • TransitoiresTIMEOUT isolé, ERROR_NO_SLOT_AVAILABLE, ERROR_TOO_MUCH_REQUESTS : la tâche repassera au tour suivant.
  • DéfinitivesERROR_ZERO_BALANCE, clé du site erronée, pageurl invalide : elles se reproduiront et gaspillent vos threads.

La boucle ci-dessous ne relance donc que la première catégorie, sur deux tentatives par défaut :

async function batchSolveWithRetry(tasks, maxRetries = 2, concurrency = 10) {
  let currentTasks = [...tasks];
  let allSolved = [];

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    if (currentTasks.length === 0) break;

    console.log(
      `Attempt ${attempt + 1}: solving ${currentTasks.length} tasks...`
    );

    const { solved, failed } = await batchSolveWithLimit(
      currentTasks,
      concurrency
    );

    allSolved = [...allSolved, ...solved];

    // Only retry transient errors
    const retryable = failed.filter(
      (f) =>
        f.error === "TIMEOUT" ||
        f.error === "ERROR_NO_SLOT_AVAILABLE" ||
        f.error === "ERROR_TOO_MUCH_REQUESTS"
    );

    currentTasks = retryable.map((f) => f.task);

    if (retryable.length > 0) {
      console.log(`  Retrying ${retryable.length} failed tasks...`);
    }
  }

  const finalFailed = currentTasks; // Anything left after all retries
  return { solved: allSolved, failed: finalFailed };
}

Suivre l'avancement d'un lot long

Un lot de plusieurs milliers de tâches tourne de longues minutes sans rien afficher : impossible de savoir s'il avance.

Incrémentez des compteurs dans le .then() et le .catch() de chaque promesse, puis réécrivez la ligne d'avancement sur la sortie standard. Dans un job planifié, remplacez process.stdout.write par un log structuré :

async function batchSolveWithProgress(tasks, concurrency = 10) {
  let completed = 0;
  let succeeded = 0;
  let failed = 0;

  const wrapped = tasks.map((task) =>
    solveCaptcha(task.sitekey, task.pageurl)
      .then((solution) => {
        succeeded++;
        completed++;
        process.stdout.write(
          `\rProgress: ${completed}/${tasks.length} (${succeeded} ok, ${failed} err)`
        );
        return { ...task, solution };
      })
      .catch((err) => {
        failed++;
        completed++;
        process.stdout.write(
          `\rProgress: ${completed}/${tasks.length} (${succeeded} ok, ${failed} err)`
        );
        throw err;
      })
  );

  const results = await Promise.allSettled(wrapped);
  console.log("\nDone.");
  return results;
}

Classer les résultats : résolus, transitoires, définitifs

Un tableau brut de settled n'aide personne à 3 h du matin. Découpez-le en trois catégories qui correspondent à trois décisions :

  • les tokens à consommer tout de suite ;
  • les tâches à remettre en file d'attente ;
  • les échecs définitifs à remonter dans votre supervision.

Ce découpage alimente aussi vos métriques — taux de réussite par type, temps de résolution médian, volume de retries :

function categorizeResults(settled, originalTasks) {
  const categories = {
    solved: [],
    transientErrors: [],
    permanentErrors: [],
  };

  const TRANSIENT = new Set([
    "TIMEOUT",
    "ERROR_NO_SLOT_AVAILABLE",
    "ERROR_TOO_MUCH_REQUESTS",
  ]);

  for (let i = 0; i < settled.length; i++) {
    const r = settled[i];
    if (r.status === "fulfilled") {
      categories.solved.push(r.value);
    } else {
      const error = r.reason.message;
      const entry = { task: originalTasks[i], error };

      if (TRANSIENT.has(error)) {
        categories.transientErrors.push(entry);
      } else {
        categories.permanentErrors.push(entry);
      }
    }
  }

  return categories;
}

Cas concret : le lot nocturne d'une équipe de veille tarifaire

Un job planifié à 2 h du matin sur une instance OVHcloud ou Scaleway doit relever les prix publics de 4 000 fiches produits avant 5 h. Chaque page protégée déclenche un défi CAPTCHA.

Avec un plan ADVANCE ($90/mois, 50 threads), la concurrence se fixe à 50. Le lot part en tranches de 500 tâches via batchSolveWithRetry, et le rapport final sépare échecs transitoires et définitifs.

Les chiffres ci-dessous sont des plafonds annoncés, pas des moyennes. Les résultats varient selon l'environnement, le volume et le moment de la journée.

Sur reCAPTCHA v2, la résolution est annoncée en moins de 60 secondes avec un taux de réussite élevé ; Cloudflare Turnstile passe sous les 10 secondes. Calez votre fenêtre nocturne sur ces plafonds.

Côté RGPD, limitez-vous aux données publiques et gardez les logs de lot exempts de données personnelles.

Dépannage

Problème observé Cause probable Correctif
Tout le lot part en TIMEOUT Concurrence supérieure aux threads du plan Ramener le plafond au nombre de threads
ERR_SOCKET_EXHAUSTION, ECONNRESET Trop de connexions HTTP ouvertes Un http.Agent avec keepAlive et maxSockets
Résultats décalés L'ordre d'achèvement n'est pas celui de soumission Écrire chaque résultat à son index
Mémoire qui grimpe Toutes les promesses restent référencées Découper en tranches de 100 à 500
ERROR_ZERO_BALANCE en cours de lot Solde épuisé pendant l'exécution Vérifier le solde avant, ne pas relancer

FAQ

Combien de résolutions simultanées mon plan autorise-t-il ?

Exactement le nombre de threads de votre plan : 5 avec BASIC, 15 avec STANDARD, 50 avec ADVANCE, 100 avec PREMIUM. Fixez concurrency sur cette valeur ; au-delà, les tâches attendent un slot libre sans rien accélérer.

Pourquoi mon lot ne se termine-t-il jamais ?

Parce que Promise.allSettled ne rend la main qu'à la dernière tâche réglée, et qu'une promesse sans borne de temps attend indéfiniment. Chaque appel unitaire doit porter son timeout : la boucle d'interrogation sort après 60 tours et lève TIMEOUT.

CaptchaAI prend-il en charge hCaptcha dans un traitement par lots ?

Non — hCaptcha et FunCaptcha (Arkose Labs) ne sont pas pris en charge, et GeeTest v4 reste annoncé comme à venir. Ce que le lot couvre : reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, les CAPTCHA image/OCR et les grilles d'images. CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta) sont disponibles au stade bêta uniquement.

Une librairie comme p-limit vaut-elle mieux qu'un pool maison ?

Les deux tiennent la charge. Le pool montré plus haut évite une dépendance ; p-limit ou p-queue deviennent utiles dès que vous ajoutez des priorités, une file persistante ou un backoff exponentiel partagé.

Faut-il garder les tokens résolus pour les réutiliser ?

Non. Un token de CAPTCHA a une durée de vie courte et doit partir vers le formulaire cible dans la foulée. Résolvez à la demande, traitez l'expiration comme une erreur transitoire.

Pour aller plus loin

Un lot robuste tient en quatre décisions : Promise.allSettled, une concurrence calée sur vos threads, un retry limité aux erreurs transitoires, un tri des résultats lisible par votre supervision. Récupérez votre clé API CaptchaAI et validez le script sur un petit lot.

Guides associés :

Les commentaires sont désactivés pour cet article.