Un pipeline qui ne journalise pas ses résolutions CAPTCHA travaille à l'aveugle : le jour où le taux de réussite perd 20 points entre 9 h et 11 h, rien ne dit si le sitekey a changé, si un proxy sature ou si vos threads sont tous occupés. Une collection MongoDB suffit à répondre : chaque tentative — envoyée, résolue, en erreur ou expirée — devient un document interrogeable, que les pipelines d'agrégation transforment en indicateurs.
Au programme : le schéma du document, les index utiles, la politique de rétention, puis les requêtes d'analyse en Python et en Node.js.
Pourquoi MongoDB convient au journal des résolutions
Les enregistrements n'ont pas tous les mêmes champs : reCAPTCHA v2 s'appuie sur googlekey, Cloudflare Turnstile sur un sitekey, un CAPTCHA image sur un body encodé. En relationnel, cette hétérogénéité finit en colonnes vides ou en migrations ; les documents sans schéma imposé de MongoDB absorbent ces variantes.
Trois autres propriétés comptent ici :
- Le framework d'agrégation calcule taux de réussite, médianes et volumes horaires côté base, sans rapatrier des millions de documents.
- Les index TTL purgent les vieux enregistrements sans tâche cron maison.
- Les documents imbriqués portent un objet
metadatalibre (projet, worker, domaine cible), vite l'axe d'analyse le plus utile.
Un rappel de facturation : CaptchaAI facture des threads simultanés, pas des résolutions à l'unité. Sur BASIC ($15/mois, 5 threads), votre limite est le nombre de résolutions en vol — exactement ce que mesure cette collection. Le champ cost ci-dessous relève de votre comptabilité interne.
Le document à stocker pour chaque tentative
{
"_id": "ObjectId",
"captcha_id": "12345678",
"type": "recaptcha_v2",
"method": "userrecaptcha",
"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
"pageurl": "https://example.com/form",
"status": "solved",
"solution": "03AGdBq26...",
"error": null,
"submitted_at": "2026-04-04T10:15:30.000Z",
"solved_at": "2026-04-04T10:15:45.000Z",
"elapsed_ms": 15000,
"polls": 3,
"proxy_used": true,
"cost": 0.00299,
"metadata": {
"project": "price-monitor",
"worker_id": "worker-3",
"target_domain": "example.com"
}
}
Deux principes guident ce schéma. L'horodatage double (submitted_at et solved_at) rend le temps de résolution calculable a posteriori, sans compteur en mémoire perdu au redémarrage du worker. Et metadata se renseigne dès le premier jour : ajouter project six mois plus tard ne rétroagit pas sur l'existant.
Rétention : décidez avant d'écrire la première ligne
| Stratégie | Index TTL | Cas d'usage |
|---|---|---|
| Rétention 30 jours | expireAfterSeconds: 2592000 |
Développement et recette |
| Rétention 90 jours | expireAfterSeconds: 7776000 |
Analyse de production |
| Conservation longue (avec archivage) | Pas de TTL ; collection plafonnée ou stockage froid | Conformité et audit |
Côté RGPD, le raisonnement est simple : un journal de résolutions n'a aucune raison de contenir des données personnelles. Trois champs méritent une règle écrite :
pageurl: une URL de formulaire embarque parfois un identifiant client ou un e-mail en paramètre — tronquez la query string avant insertion.solution: le token perd toute valeur analytique en quelques heures.metadata: des identifiants techniques uniquement (projet, worker, domaine).
Pour une équipe dont les workers tournent chez OVHcloud ou Scaleway, l'index TTL devient la preuve technique de la durée de conservation.
Mettre en place le suivi en Python
Connexion et configuration
import os
import time
from datetime import datetime, timezone
from pymongo import MongoClient, ASCENDING, DESCENDING
import requests
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
client = MongoClient(MONGO_URI)
db = client["captcha_tracking"]
solves = db["solves"]
Gardez la clé API dans une variable d'environnement, jamais dans le code ni dans un document MongoDB. Si vous démarrez, créez la clé depuis votre tableau de bord CaptchaAI et validez-la par un premier appel.
Créez les index avant le premier gros volume
def setup_indexes():
solves.create_index([("submitted_at", DESCENDING)])
solves.create_index([("type", ASCENDING), ("status", ASCENDING)])
solves.create_index([("metadata.project", ASCENDING)])
solves.create_index([("metadata.target_domain", ASCENDING)])
solves.create_index(
[("submitted_at", ASCENDING)],
expireAfterSeconds=90 * 24 * 3600, # Auto-delete after 90 days
name="ttl_cleanup"
)
setup_indexes()
L'index descendant sur submitted_at sert les tableaux de bord « dernières 24 h », le composé type + status les comparaisons par type. L'index TTL applique la rétention décidée plus haut : 90 jours.
Envoyez la résolution et écrivez son cycle de vie
def solve_and_store(sitekey, pageurl, captcha_type="recaptcha_v2", metadata=None):
record = {
"type": captcha_type,
"method": "userrecaptcha",
"sitekey": sitekey,
"pageurl": pageurl,
"status": "submitted",
"submitted_at": datetime.now(timezone.utc),
"metadata": metadata or {}
}
result = solves.insert_one(record)
doc_id = result.inserted_id
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
solves.update_one(
{"_id": doc_id},
{"$set": {"status": "error", "error": data.get("request")}}
)
return None
captcha_id = data["request"]
solves.update_one(
{"_id": doc_id},
{"$set": {"captcha_id": captcha_id, "status": "polling"}}
)
# Poll for result
polls = 0
for _ in range(60):
time.sleep(5)
polls += 1
poll_resp = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get",
"id": captcha_id, "json": 1
}).json()
if poll_resp.get("status") == 1:
solved_at = datetime.now(timezone.utc)
elapsed_ms = int(
(solved_at - record["submitted_at"]).total_seconds() * 1000
)
solves.update_one({"_id": doc_id}, {"$set": {
"status": "solved",
"solution": poll_resp["request"],
"solved_at": solved_at,
"elapsed_ms": elapsed_ms,
"polls": polls
}})
return poll_resp["request"]
if poll_resp.get("request") != "CAPCHA_NOT_READY":
solves.update_one({"_id": doc_id}, {"$set": {
"status": "error",
"error": poll_resp.get("request"),
"polls": polls
}})
return None
solves.update_one({"_id": doc_id}, {"$set": {
"status": "timeout", "polls": polls
}})
return None
Le document est inséré avant l'appel à in.php, en statut submitted, puis mis à jour à chaque étape : polling, puis solved, error ou timeout. C'est ce qui rend les échecs visibles — n'écrire qu'en cas de succès produit un taux de réussite artificiellement parfait. L'intervalle d'interrogation de 5 secondes convient à reCAPTCHA v2 ; sur Cloudflare Turnstile, plus rapide, polls descend souvent à une ou deux itérations.
Les requêtes d'analyse qui servent vraiment
def get_success_rate(hours=24):
"""Success rate for the last N hours."""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
pipeline = [
{"$match": {"submitted_at": {"$gte": cutoff}}},
{"$group": {
"_id": "$status",
"count": {"$sum": 1}
}}
]
results = {r["_id"]: r["count"] for r in solves.aggregate(pipeline)}
total = sum(results.values())
solved = results.get("solved", 0)
return (solved / total * 100) if total else 0
def get_avg_solve_time_by_type():
"""Average solve time grouped by CAPTCHA type."""
pipeline = [
{"$match": {"status": "solved"}},
{"$group": {
"_id": "$type",
"avg_time_ms": {"$avg": "$elapsed_ms"},
"min_time_ms": {"$min": "$elapsed_ms"},
"max_time_ms": {"$max": "$elapsed_ms"},
"count": {"$sum": 1}
}},
{"$sort": {"count": -1}}
]
return list(solves.aggregate(pipeline))
def get_hourly_solve_volume(days=7):
"""Hourly solve volume for charting."""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
pipeline = [
{"$match": {"submitted_at": {"$gte": cutoff}}},
{"$group": {
"_id": {
"date": {"$dateToString": {"format": "%Y-%m-%d", "date": "$submitted_at"}},
"hour": {"$hour": "$submitted_at"}
},
"total": {"$sum": 1},
"solved": {"$sum": {"$cond": [{"$eq": ["$status", "solved"]}, 1, 0]}}
}},
{"$sort": {"_id.date": 1, "_id.hour": 1}}
]
return list(solves.aggregate(pipeline))
def get_error_breakdown(hours=24):
"""Error frequency by error code."""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
pipeline = [
{"$match": {"submitted_at": {"$gte": cutoff}, "status": "error"}},
{"$group": {"_id": "$error", "count": {"$sum": 1}}},
{"$sort": {"count": -1}}
]
return list(solves.aggregate(pipeline))
Taux de réussite glissant, temps de résolution par type, volume horaire, répartition des codes d'erreur : ces quatre pipelines couvrent l'essentiel. Le troisième est le plus parlant en exploitation — un pic de volume à heure fixe suivi d'une hausse de timeout signale une saturation de threads, pas un problème de résolution.
La même chaîne en Node.js
const { MongoClient } = require("mongodb");
const axios = require("axios");
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017";
const API_KEY = process.env.CAPTCHAAI_API_KEY;
let db, solves;
async function connect() {
const client = await MongoClient.connect(MONGO_URI);
db = client.db("captcha_tracking");
solves = db.collection("solves");
await solves.createIndex({ submitted_at: -1 });
await solves.createIndex({ type: 1, status: 1 });
await solves.createIndex({ "metadata.project": 1 });
await solves.createIndex(
{ submitted_at: 1 },
{ expireAfterSeconds: 90 * 24 * 3600 }
);
}
async function solveAndStore(sitekey, pageurl, type = "recaptcha_v2", metadata = {}) {
const submittedAt = new Date();
const { insertedId } = await solves.insertOne({
type, method: "userrecaptcha", sitekey, pageurl,
status: "submitted", submitted_at: submittedAt, metadata,
});
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await solves.updateOne({ _id: insertedId }, { $set: { status: "error", error: submit.data.request } });
return null;
}
const captchaId = submit.data.request;
await solves.updateOne({ _id: insertedId }, { $set: { captcha_id: captchaId, status: "polling" } });
let polls = 0;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
polls++;
const poll = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (poll.data.status === 1) {
const solvedAt = new Date();
await solves.updateOne({ _id: insertedId }, { $set: {
status: "solved", solution: poll.data.request,
solved_at: solvedAt, elapsed_ms: solvedAt - submittedAt, polls,
}});
return poll.data.request;
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
await solves.updateOne({ _id: insertedId }, { $set: { status: "error", error: poll.data.request, polls } });
return null;
}
}
await solves.updateOne({ _id: insertedId }, { $set: { status: "timeout", polls } });
return null;
}
async function getSuccessRate(hours = 24) {
const cutoff = new Date(Date.now() - hours * 3600 * 1000);
const pipeline = [
{ $match: { submitted_at: { $gte: cutoff } } },
{ $group: { _id: "$status", count: { $sum: 1 } } },
];
const results = await solves.aggregate(pipeline).toArray();
const total = results.reduce((s, r) => s + r.count, 0);
const solved = results.find((r) => r._id === "solved")?.count || 0;
return total ? ((solved / total) * 100).toFixed(1) : 0;
}
La version Node.js écrit les mêmes documents : des workers Python et Node.js peuvent partager une collection, à condition d'horodater en UTC.
Dépannage
Cinq symptômes reviennent une fois la collection en production.
| Problème | Cause probable | Correctif |
|---|---|---|
| Agrégations lentes au-delà de quelques centaines de milliers de documents | Index absents sur submitted_at et type |
Exécutez setup_indexes() puis vérifiez avec db.solves.explain() |
| Collection qui grossit plus vite que prévu | Le token complet est conservé dans chaque document | Stockez une empreinte du token, ou effacez le champ après usage |
| Le TTL ne purge rien | Le moniteur TTL passe toutes les 60 s et traite les arriérés progressivement | Laissez tourner, puis contrôlez db.solves.getIndexes() |
| Erreurs de pool de connexions sous charge | Trop de résolutions simultanées pour le pool par défaut | Ajustez maxPoolSize dans l'URI de connexion |
elapsed_ms négatif ou aberrant |
Horloges désynchronisées entre workers | Écrivez les dates en UTC côté application, jamais avec l'heure locale du worker |
Sinon, comparez polls et elapsed_ms entre deux périodes : l'écart désigne la file d'attente plus souvent que la base.
FAQ
Faut-il conserver le token de solution complet ?
Rarement plus de 24 à 48 heures. Le token expire côté site cible bien avant : gardez le type, l'horodatage, la durée, le statut et le code d'erreur, et laissez l'index TTL faire le ménage.
Quel volume de stockage prévoir ?
Comptez 500 octets à 2 Ko par document selon la richesse de metadata. À 10 000 résolutions par jour et 90 jours de rétention, cela fait 1 à 2 Go, index compris : le moindre cluster l'absorbe sans réglage.
Que faire des documents restés en statut polling ?
Requalifiez-les. Un worker interrompu laisse un document orphelin : une tâche planifiée qui bascule en timeout tout document polling de plus de dix minutes évite un taux de réussite faussement flatteur.
Comment mesurer une médiane plutôt qu'une moyenne ?
La moyenne de elapsed_ms est trompeuse dès qu'un timeout traîne dans l'échantillon. Utilisez $percentile (ou un $bucket de 5 secondes) pour la médiane et le P90, par type de CAPTCHA.
Faut-il une collection par type de CAPTCHA ?
Non. Une collection unique avec type indexé reste plus simple à interroger et à purger, y compris si vous ajoutez GeeTest v3 ou des CAPTCHA image. Le découpage ne se pose qu'aux volumes où le sharding devient le sujet.
Pour aller plus loin
Une collection bien indexée transforme un pipeline opaque en surface mesurable : la dégradation se voit avant de coûter cher. Récupérez votre clé API CaptchaAI et laissez le script tourner une journée pour obtenir votre première courbe.
Guides associés :