La déduplication consiste à reconnaître qu'une requête de résolution CAPTCHA est déjà en cours ou déjà résolue, puis à réutiliser ce résultat plutôt que de relancer une résolution payante. Sur un pipeline de scraping réparti sur plusieurs workers — par exemple une flotte hébergée chez OVHcloud ou Scaleway qui interroge la même page de connexion — deux workers rencontrent le même sitekey au même instant et déclenchent deux résolutions pour un seul token utile.
La couche décrite ici intercepte ces doublons, renvoie un résultat unique et vous fait économiser des crédits API tout en réduisant la latence. Elle repose sur deux briques interchangeables :
- Redis — un cache court et atomique, partagé entre tous les workers, avec expiration native.
- Verrous consultatifs PostgreSQL — la même coordination sans ajouter Redis à votre pile.
Pourquoi les doublons apparaissent
| Scénario | Origine | Coût gaspillé |
|---|---|---|
| Nouvelle tentative avant l'arrivée du résultat | Logique de retry trop agressive | 2 à 5× le coût par CAPTCHA |
| Plusieurs workers, même cible | Aucune coordination entre les workers | Des résolutions parallèles inutiles |
| Rechargement de page | Le frontend relance après un timeout | Une résolution de plus par rechargement |
| Message de file d'attente rejoué | Livraison « au moins une fois » | Un doublon à chaque rejeu |
Le point commun : rien n'informe le deuxième appel que le premier existe déjà. La solution est un identifiant stable, dérivé de la requête, sur lequel tous les workers s'accordent.
Concevoir une clé de déduplication
Générez une clé unique à partir des paramètres de la requête :
import hashlib
def dedup_key(method, sitekey, pageurl):
"""Generate a deduplication key for a CAPTCHA solve request."""
raw = f"{method}:{sitekey}:{pageurl}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
La règle tient en deux invariants :
- Deux requêtes qui produiront le même token doivent aboutir à la même clé.
- Deux requêtes distinctes ne doivent jamais entrer en collision.
Les composants à inclure dépendent donc du type de CAPTCHA :
| Type de CAPTCHA | Composants de la clé |
|---|---|
| reCAPTCHA v2 | method + sitekey + pageurl |
| reCAPTCHA v3 | method + sitekey + pageurl + action |
| Cloudflare Turnstile | method + sitekey + pageurl |
| Cloudflare Challenge | method + pageurl |
| CAPTCHA image / OCR | method + hachage du contenu de l'image (body) |
Pour reCAPTCHA v3, oublier l'action fusionne deux contextes sous la même clé et sert un mauvais score : incluez-la toujours.
Déduplication avec Redis
Redis est le choix naturel : atomique, partagé entre tous vos workers, et doté d'une expiration native (EX) qui nettoie l'état sans tâche de fond. Le principe est une petite machine à états stockée sous la clé de déduplication, avec trois valeurs possibles :
solving— une résolution est en cours ; les autres workers attendent.solved— le token est disponible et servi depuis le cache.error— la résolution a échoué ; une nouvelle tentative est autorisée.
Implémentation Python
import os
import time
import json
import hashlib
import redis
import requests
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", 6379)),
decode_responses=True
)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Dedup window: how long to consider a request "in progress"
DEDUP_TTL = 180 # seconds
def dedup_key(method, sitekey, pageurl, extra=""):
raw = f"{method}:{sitekey}:{pageurl}:{extra}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
def solve_with_dedup(sitekey, pageurl, method="userrecaptcha"):
key = dedup_key(method, sitekey, pageurl)
# Check if this request is already being solved
existing = r.get(key)
if existing:
state = json.loads(existing)
if state["status"] == "solving":
# Wait for the result
return wait_for_result(key)
elif state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_cache"}
elif state["status"] == "error":
pass # Allow retry on error
# Mark as solving
r.set(key, json.dumps({"status": "solving", "started": time.time()}), ex=DEDUP_TTL)
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": method,
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
r.set(key, json.dumps({"status": "error", "error": data.get("request")}), ex=30)
return {"error": data.get("request")}
captcha_id = data["request"]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get",
"id": captcha_id, "json": 1
}).json()
if result.get("status") == 1:
solution = result["request"]
# Cache the result for other workers (short TTL since tokens expire)
r.set(key, json.dumps({
"status": "solved",
"solution": solution,
"solved_at": time.time()
}), ex=60) # Cache result for 60 seconds
return {"solution": solution, "source": "api"}
if result.get("request") != "CAPCHA_NOT_READY":
r.set(key, json.dumps({
"status": "error", "error": result.get("request")
}), ex=30)
return {"error": result.get("request")}
r.set(key, json.dumps({"status": "error", "error": "TIMEOUT"}), ex=30)
return {"error": "TIMEOUT"}
def wait_for_result(key, timeout=120):
"""Wait for another worker to finish solving."""
start = time.time()
while time.time() - start < timeout:
data = r.get(key)
if data:
state = json.loads(data)
if state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_wait"}
if state["status"] == "error":
return {"error": state.get("error", "UNKNOWN")}
time.sleep(2)
return {"error": "DEDUP_WAIT_TIMEOUT"}
Le premier worker marque la clé en solving puis lance la résolution ; les suivants trouvent cet état et attendent. Deux TTL font le travail : 180 s sur solving (si le worker plante, la clé s'efface et un autre reprend), et 60 s sur le résultat en cache — plus court que la durée de vie d'un token reCAPTCHA, pour ne jamais servir un token expiré.
Implémentation JavaScript
const Redis = require("ioredis");
const axios = require("axios");
const crypto = require("crypto");
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const DEDUP_TTL = 180;
function dedupKey(method, sitekey, pageurl) {
const raw = `${method}:${sitekey}:${pageurl}`;
const hash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
return `captcha:dedup:${hash}`;
}
async function solveWithDedup(sitekey, pageurl, method = "userrecaptcha") {
const key = dedupKey(method, sitekey, pageurl);
// Check existing
const existing = await redis.get(key);
if (existing) {
const state = JSON.parse(existing);
if (state.status === "solving") return await waitForResult(key);
if (state.status === "solved") return { solution: state.solution, source: "dedup_cache" };
}
// Mark as solving
await redis.set(key, JSON.stringify({ status: "solving", started: Date.now() }), "EX", DEDUP_TTL);
// Submit
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method, googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await redis.set(key, JSON.stringify({ status: "error", error: submit.data.request }), "EX", 30);
return { error: submit.data.request };
}
const captchaId = submit.data.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
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) {
await redis.set(key, JSON.stringify({ status: "solved", solution: poll.data.request }), "EX", 60);
return { solution: poll.data.request, source: "api" };
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
await redis.set(key, JSON.stringify({ status: "error", error: poll.data.request }), "EX", 30);
return { error: poll.data.request };
}
}
await redis.set(key, JSON.stringify({ status: "error", error: "TIMEOUT" }), "EX", 30);
return { error: "TIMEOUT" };
}
async function waitForResult(key, timeout = 120000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const data = await redis.get(key);
if (data) {
const state = JSON.parse(data);
if (state.status === "solved") return { solution: state.solution, source: "dedup_wait" };
if (state.status === "error") return { error: state.error };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { error: "DEDUP_WAIT_TIMEOUT" };
}
Le point délicat reste l'écriture initiale de l'état solving. Si deux workers lisent une clé absente au même instant, ils lancent tous deux une résolution. En production, remplacez ce SET par un SET NX (set-if-not-exists) : seul le premier obtient le verrou, l'autre bascule directement en attente.
Sans Redis : les verrous consultatifs PostgreSQL
Si votre pile s'appuie déjà sur PostgreSQL et que vous préférez ne pas ajouter Redis, les verrous consultatifs (advisory locks) offrent la même coordination. Un identifiant numérique dérivé de la clé de déduplication sert de verrou partagé entre toutes les connexions :
import psycopg2
def solve_with_pg_dedup(conn, sitekey, pageurl):
"""Use PostgreSQL advisory locks for deduplication."""
# Generate a numeric lock key from the dedup key
lock_id = hash(f"{sitekey}:{pageurl}") & 0x7FFFFFFF
cursor = conn.cursor()
# Try to acquire advisory lock (non-blocking)
cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
acquired = cursor.fetchone()[0]
if not acquired:
# Another worker is solving — wait for result
cursor.execute("SELECT pg_advisory_lock(%s)", (lock_id,))
# Lock acquired means other worker finished — check cache
cursor.execute(
"SELECT solution FROM captcha_cache "
"WHERE sitekey = %s AND pageurl = %s "
"AND created_at > NOW() - INTERVAL '60 seconds'",
(sitekey, pageurl)
)
row = cursor.fetchone()
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
if row:
return {"solution": row[0], "source": "pg_cache"}
return {"error": "NO_CACHED_RESULT"}
try:
# Solve the CAPTCHA
solution = solve_via_api(sitekey, pageurl)
if solution:
cursor.execute(
"INSERT INTO captcha_cache (sitekey, pageurl, solution) "
"VALUES (%s, %s, %s)",
(sitekey, pageurl, solution)
)
conn.commit()
return {"solution": solution} if solution else {"error": "SOLVE_FAILED"}
finally:
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
pg_try_advisory_lock ne bloque pas : le worker qui l'obtient résout et enregistre le résultat ; celui qui échoue passe sur pg_advisory_lock (bloquant) et récupère le token en cache dès le verrou libéré. Purgez périodiquement captcha_cache des lignes plus vieilles que vos tokens.
Mesurer l'efficacité de la déduplication
Sans mesure, impossible de savoir si la couche gagne réellement des crédits. Comptez les résolutions servies par le cache et par l'attente, puis comparez au total :
def track_dedup_stats(source):
"""Increment counters for dedup tracking."""
today = time.strftime("%Y-%m-%d")
r.hincrby(f"dedup:stats:{today}", source, 1)
r.expire(f"dedup:stats:{today}", 7 * 86400)
def get_dedup_report():
today = time.strftime("%Y-%m-%d")
stats = r.hgetall(f"dedup:stats:{today}")
total = sum(int(v) for v in stats.values())
saved = int(stats.get("dedup_cache", 0)) + int(stats.get("dedup_wait", 0))
return {
"total_requests": total,
"deduplicated": saved,
"savings_pct": f"{saved / total * 100:.1f}%" if total else "0%",
"breakdown": stats
}
Ces compteurs n'enregistrent que des totaux agrégés, jamais de données personnelles : un bon réflexe RGPD quand vos logs transitent par plusieurs services. Un savings_pct durablement bas trahit souvent une clé trop spécifique ; un taux très élevé, des retries à revoir en amont.
Dépannage
| Problème | Origine | Correctif |
|---|---|---|
| Collisions de clés de déduplication | Hachage trop court ou paramètres manquants | Incluez tous les paramètres propres au CAPTCHA dans la clé et allongez le hachage |
| Le worker en attente expire | Le worker qui résolvait a planté | Le TTL sur l'état solving expire automatiquement (180 s) |
| Résultat périmé servi depuis le cache | Le token a expiré mais le cache est encore valide | Fixez une durée de vie du cache plus courte que celle du token (60 s pour reCAPTCHA) |
| Condition de course à l'écriture | Deux workers vérifient la clé au même instant | Utilisez SET NX (set-if-not-exists) pour une acquisition atomique du verrou |
FAQ
Redis ou verrous PostgreSQL : lequel choisir ?
Prenez Redis si vos workers sont déjà répartis sur plusieurs machines : l'atomicité et l'expiration native (EX) simplifient tout. Restez sur les verrous consultatifs PostgreSQL si vous voulez éviter une dépendance supplémentaire et que la base est déjà au centre de votre pile — les deux approches assurent la même règle : une seule résolution par clé.
Quel TTL donner au cache de résolution ?
Toujours plus court que la durée de vie du token que vous mettez en cache. Un token reCAPTCHA expire vite : un cache de 60 s évite de servir un token mort tout en absorbant les doublons rapprochés. Réglez ce TTL en fonction du type de CAPTCHA, pas d'une valeur unique appliquée partout.
Comment mesurer les économies réelles ?
Instrumentez la source de chaque résolution (api, dedup_cache, dedup_wait) comme dans get_dedup_report, puis suivez le rapport entre résolutions évitées et total sur quelques jours. C'est le seul moyen de vérifier que la couche gagne réellement des crédits.
Faut-il une clé différente par proxy ?
Non. Le token de résolution est valide quel que soit le proxy utilisé pour l'obtenir : inclure le proxy dans la clé multiplierait les clés et annulerait tout l'intérêt de la déduplication. Gardez la clé centrée sur method, sitekey et pageurl.
Prochaines étapes
Arrêtez de payer pour des résolutions CAPTCHA en double — récupérez votre clé API CaptchaAI et déployez la déduplication dès aujourd'hui.
Guides associés :
- la gestion du TTL des tokens dans Redis
- la coordination de l'état de session entre workers distribués
- la reprise sur erreur des lots partiellement échoués