Un bus d'événements découple la résolution de vos CAPTCHA du reste de votre application : chaque changement d'état est diffusé, et vos modules de logs, de métriques ou de retry y réagissent sans toucher au code qui appelle l'API. Vous émettez cinq événements — soumis, en attente, résolu, échec, expiré — et chaque écouteur s'abonne à ce qui le concerne. Les callbacks et le polling vous renvoient un résultat ; le bus vous donne une visibilité continue sur tout le cycle de vie du défi.
Ce découplage prend tout son sens lorsque plusieurs workers tournent en parallèle, par exemple sur OVHcloud ou Scaleway près de la région eu-west-3 (Paris) : chaque worker émet sur le même contrat, et vos couches d'observabilité restent identiques. Les offres CaptchaAI se facturant au thread et non à la résolution, l'offre BASIC ($15/mois, 5 threads) suffit à faire tourner un bus qui traite plusieurs CAPTCHA de front.
Ce qu'il vous faut avant de commencer
Le bus s'appuie uniquement sur des briques standard, sans dépendance exotique :
- Node.js 18 ou plus récent (côté JavaScript) ou Python 3.9+ (côté Python).
- Une clé API CaptchaAI, exposée dans la variable d'environnement
CAPTCHAAI_API_KEY. - Le client HTTP
axiospour Node.js, ourequestspour Python. - Un sitekey de test et une page cible pour valider le flux de bout en bout.
L'architecture du bus d'événements
[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]
Le bus diffuse cinq événements, un par transition d'état :
submitted— la tâche a été acceptée par l'API et reçoit un identifiant.pending— le résultat n'est pas encore prêt, l'interrogation continue.solved— le token est disponible, avec la durée totale de résolution.failed— l'API a renvoyé une erreur, ou le réseau a lâché.timeout— le délai maximal est dépassé sans réponse.
Les écouteurs s'inscrivent indépendamment les uns des autres. Ajouter une fonctionnalité — la collecte de métriques, par exemple — ne demande aucune modification du code de résolution : vous branchez un nouvel écouteur sur l'événement concerné, et c'est tout.
La classe CaptchaBus en JavaScript
Soumettre puis interroger le résultat
Le cœur du système est une classe qui étend EventEmitter. Elle soumet le CAPTCHA à l'endpoint in.php, puis interroge res.php en arrière-plan et émet un événement à chaque transition d'état.
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;
L'intervalle de polling par défaut est de 5 000 ms et le délai d'expiration de 5 minutes : deux valeurs que vous ajustez via options selon le type de CAPTCHA visé et la charge de votre file d'attente.
Abonner vos écouteurs d'événements
Une fois la classe en place, chaque responsabilité devient un écouteur autonome :
- un écouteur de journalisation trace chaque transition ;
- un écouteur de métriques agrège les compteurs de tâches et les durées.
Aucun des deux ne connaît l'existence de l'autre.
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",
});
Le même événement peut alimenter plusieurs écouteurs : ici, submitted et solved sont consommés à la fois par la journalisation et par les métriques. C'est exactement le point fort du modèle.
La même chose en Python
Un registre d'écouteurs et un thread de polling
Si votre pile est en Python, le principe est identique : une classe qui tient un registre d'écouteurs, une méthode emit qui les appelle, et un thread d'arrière-plan pour l'interrogation du résultat. Le contrat d'événements reste le même que côté Node.js.
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")
Côté Python, chaque soumission lance son propre thread d'interrogation en daemon, ce qui laisse le programme principal libre pendant que les résolutions avancent en arrière-plan. Vous branchez ensuite vos écouteurs avec on, exactement comme en JavaScript.
Aller plus loin : le retry comme simple écouteur
La nouvelle tentative n'a pas besoin d'être codée en dur dans la boucle de résolution. Puisqu'un échec est un événement, il suffit d'y abonner un écouteur qui resoumet la tâche, avec un compteur pour éviter les boucles infinies.
// 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,
});
});
Aller plus loin : envelopper le bus dans une promesse
Le bus reste pratique pour les traitements réactifs, mais parfois vous voulez simplement await un résultat. Cette enveloppe expose une API basée sur les promesses par-dessus le bus, en nettoyant proprement ses écouteurs une fois la tâche terminée.
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",
});
Le cleanup() est essentiel : sans lui, chaque appel laisserait des écouteurs orphelins sur le bus, ce qui finit par déclencher l'avertissement de fuite mémoire de Node.js.
À noter : le bus n'impose aucune limite de concurrence. C'est votre allocation de threads CaptchaAI qui plafonne le nombre de résolutions simultanées, pas le code présenté ici.
Dépannage
| Problème | Cause | Correctif |
|---|---|---|
| L'écouteur ne se déclenche pas | Nom d'événement incohérent (par exemple « solve » au lieu de « solved ») | Vérifiez que les noms passés à emit et à on correspondent exactement |
| Avertissement de fuite mémoire | Trop d'écouteurs abonnés au même événement | Appelez setMaxListeners() ou nettoyez les écouteurs après usage |
| Les événements « pending » saturent la console | Intervalle de polling trop court | Portez pollInterval à 5 000 ms ou plus |
| Événements perdus lors d'un retry | Un nouvel ID de tâche est généré à la nouvelle tentative | Transmettez les paramètres d'origine pour raccrocher l'état de la tâche |
FAQ
Quels types de CAPTCHA ce bus peut-il piloter ?
Tous ceux que CaptchaAI prend en charge via le même flux in.php / res.php : reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest v3 et les CAPTCHA image/OCR. Il suffit d'adapter le paramètre method et les champs de la requête ; le contrat d'événements ne change pas. hCaptcha et FunCaptcha, en revanche, ne sont pas pris en charge.
Faut-il passer à Redis ou Kafka pour plusieurs workers ?
Non, tant que vous restez sur un seul processus : un bus in-process (EventEmitter) est plus simple et plus rapide. Dès que plusieurs processus ou instances — par exemple des workers répartis sur OVHcloud ou Scaleway — doivent réagir aux mêmes événements, faites transiter ceux-ci par Redis, Kafka ou RabbitMQ.
Le bus d'événements est-il compatible avec mes obligations RGPD ?
Le bus lui-même ne collecte rien : ce sont vos écouteurs qui décident quoi journaliser. Si vous écrivez une piste d'audit, minimisez les données personnelles conservées (pas d'URL contenant des identifiants, pas de solution complète) et vérifiez vos obligations RGPD sur la durée de rétention des logs.
Comment éviter que les écouteurs « pending » n'inondent mes logs ?
Ne journalisez pas chaque cycle d'interrogation. Filtrez sur un intervalle (un log toutes les 15 s) ou réservez l'événement pending aux métriques plutôt qu'à la console, et gardez la journalisation détaillée pour solved, failed et timeout.
Articles connexes
- Construire des pipelines CAPTCHA côté client
- Mesurer les temps de résolution CAPTCHA
- Mettre en place une automatisation responsable
Prochaines étapes
Passez à des pipelines CAPTCHA pilotés par les événements — récupérez votre clé API CaptchaAI et câblez votre premier bus.
Guides associés :
- Configurer une URL de callback et un webhook
- Notifications temps réel via SSE
- Gérer les erreurs de callback