¿Tu flota de workers resuelve el mismo CAPTCHA varias veces y pagas por cada intento? La solución es una capa de deduplicación: un candado compartido —en Redis o con advisory locks de PostgreSQL— que reconoce solicitudes idénticas, deja que solo una llegue a la API y reparte el mismo token entre el resto. El ahorro es doble: menos threads en trabajo redundante y menos latencia para el worker que espera.
Piensa en una agencia que monitorea precios en marketplaces regionales (del estilo de MercadoLibre o Amazon.es) con diez procesos en paralelo. Si tres golpean la misma página protegida a la vez, sin deduplicación pagas tres resoluciones por un token que sirve para las tres. A escala, ese desperdicio se nota en la factura mensual en USD.
¿Cuándo merece la pena una capa de deduplicación?
La respuesta corta: cuando varios workers apuntan a la misma combinación de sitekey y pageurl. Incluso una tasa del 10 % ahorra créditos de forma notable a escala y elimina tiempo de resolución desperdiciado. Si tu automatización es de un solo proceso y nunca repite objetivos, aún no hace falta.
Por dónde se cuelan los duplicados
Conviene saber de dónde salen las solicitudes repetidas antes de blindar el flujo. Los cuatro patrones más habituales:
| Escenario | Causa | Desperdicio |
|---|---|---|
| Reintento antes de recibir el resultado | Lógica de reintento demasiado agresiva | Coste de 2 a 5 veces por CAPTCHA |
| Varios workers, mismo objetivo | Falta de coordinación entre workers | Resoluciones duplicadas en paralelo |
| La recarga de página vuelve a disparar | Reintento del frontend al agotar el tiempo | Una resolución extra por recarga |
| Mensaje de cola reprocesado | Entrega con garantía at-least-once | Resolución duplicada en cada repetición |
Cómo construir la clave de deduplicación
Cada worker debe llegar a la misma clave para el mismo CAPTCHA. Lo más fiable es derivar un hash SHA-256 de los parámetros que lo identifican —método, sitekey y pageurl— y usar ese valor como identificador compartido:
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]}"
Qué parámetros entran según el tipo
Qué entra en la clave depende del tipo de CAPTCHA. Incluye siempre lo que distingue una solicitud de otra; si falta un componente, dos CAPTCHAs distintos colisionarán en la misma clave:
| Tipo de CAPTCHA | Componentes de la clave |
|---|---|
| reCAPTCHA v2 | method + sitekey + pageurl |
| reCAPTCHA v3 | method + sitekey + pageurl + action |
| Turnstile | method + sitekey + pageurl |
| CAPTCHA de imagen | method + hash del body (contenido de la imagen) |
Deduplicación con Redis
Redis es la opción natural cuando ya forma parte de tu stack. Escribe la clave con estado solving de forma atómica; si otro worker la encuentra, espera el resultado en vez de lanzar un envío nuevo. Al llegar el token, se cachea unos segundos para que el resto lo reutilice antes de que caduque.
El patrón en tres pasos
- Reclamar la clave. El primer worker escribe el estado
solvingconSET NXy un TTL de seguridad por si el proceso se cae. - Resolver una sola vez. Solo quien reclamó la clave envía el CAPTCHA y sondea el resultado.
- Compartir el token. Al resolverse, se cachea con un TTL corto y el resto lo lee sin volver a pagar.
Implementación en Python
El flujo completo comprueba la clave, marca el estado, envía a CaptchaAI, sondea el resultado y guarda el token para los demás workers:
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"}
Implementación en Node.js
La misma lógica en Node.js con ioredis y axios. Mantén idénticos la clave y el TTL en toda tu flota para que compartan el candado:
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" };
}
Alternativa sin Redis: advisory locks de PostgreSQL
Si no quieres sumar Redis, PostgreSQL ofrece advisory locks: candados de sesión que no bloquean filas ni tablas y se liberan solos al cerrar la conexión.
Adquirir el lock y leer el caché
Deriva un identificador numérico de la clave, intenta adquirir el lock sin bloquear y, si otro worker lo tiene, espera y lee el resultado cacheado:
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,))
Cómo medir el ahorro real
Sin números, la deduplicación es una corazonada. Cuenta cuántas solicitudes se sirvieron desde el caché o la espera frente a las que llegaron a la API y calcula el porcentaje ahorrado:
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
}
Diagnóstico de problemas comunes
Cuando la deduplicación no rinde lo esperado, casi siempre es una de estas cuatro causas:
| Problema | Causa | Solución |
|---|---|---|
| Colisiones en la clave de deduplicación | Hash demasiado corto o faltan parámetros | Incluye todos los parámetros propios del CAPTCHA en la clave y alarga el hash |
| El worker en espera agota su tiempo | El worker que resolvía se cayó | El TTL del estado solving expira solo (180 s) y libera la clave |
| Resultados obsoletos en el caché | El token caducó pero el caché sigue vigente | Fija el TTL del caché de resultados por debajo de la vida del token (60 s para reCAPTCHA) |
| Condición de carrera al escribir | Dos workers comprueban la clave a la vez | Usa SET NX (set-if-not-exists) para una adquisición atómica del candado |
Deduplicación y facturación por threads en CaptchaAI
CaptchaAI factura por threads concurrentes con resoluciones ilimitadas por thread, no por resolución individual. Eso cambia la lógica del ahorro: un duplicado no suma un cargo suelto, pero ocupa un thread que podría atender trabajo nuevo. Deduplicar te deja exprimir el plan que ya pagas: empieza con el plan BASIC ($15/mes, 5 threads) y sube de escalón solo cuando tu concurrencia real lo pida.
Preguntas frecuentes
¿Conviene usar Redis o los advisory locks de PostgreSQL?
Depende de tu infraestructura. Si ya tienes Redis, su modelo SET NX con TTL es el camino más simple. Si prefieres no añadir dependencias, los advisory locks de PostgreSQL resuelven lo mismo sin servicios extra.
¿La deduplicación reduce mi factura de CaptchaAI?
Sí, de forma indirecta. CaptchaAI factura por threads concurrentes, no por resolución, así que cada duplicado que evitas libera un thread para trabajo útil. Con mucho solapamiento, eso puede significar quedarte en el plan BASIC ($15/mes, 5 threads) en vez de saltar al siguiente escalón.
¿Qué pasa si dos workers consultan la clave a la vez?
Ese es el caso que el candado atómico previene. Con SET NX en Redis o pg_try_advisory_lock en PostgreSQL, solo un worker gana la clave y envía a la API; el resto detecta el estado solving y espera. Sin esa atomicidad tendrías una condición de carrera y pagarías duplicados.
¿Debo incluir el proxy en la clave de deduplicación?
No. El token resuelto es válido sin importar qué proxy se usó, así que sumar el proxy a la clave fragmentaría la deduplicación y resolverías lo mismo para cada salida de red. Deja la clave con method, sitekey y pageurl.