Rotar claves se reduce a tres piezas: una lista de claves, un selector que decide cuál toca ahora y una regla que aparta la que devuelve un error definitivo. Con eso montado, un ERROR_ZERO_BALANCE a las tres de la mañana pasa de tumbar tu cola de tareas a ser una línea en el log. Abajo tienes el rotador en Python y JavaScript.
¿Cuándo compensa repartir el tráfico entre varias claves?
Antes de escribir código, aclaremos la confusión más común: rotar claves no te da más resoluciones por minuto. CaptchaAI factura por threads concurrentes, con resoluciones ilimitadas dentro de ellos. Si lo que te falta es concurrencia, sube de plan.
| Plan | Precio mensual | Threads |
|---|---|---|
| BASIC | $15 | 5 |
| STANDARD | $30 | 15 |
| ADVANCE | $90 | 50 |
| PREMIUM | $170 | 100 |
Entonces, ¿para qué rotar? Por tres motivos que sí lo justifican:
- Continuidad. Una clave sin saldo, o fuera por una lista de IP mal configurada, para tu pipeline en seco si es la única.
- Separación contable. Una clave por cliente reparte los costes de una agencia sin hojas de cálculo intermedias.
- Aislamiento de entornos. Producción, staging y pruebas locales no deberían compartir clave: un bucle mal cerrado en desarrollo no puede gastarse el saldo de producción.
Si ninguno aplica, quédate con una sola clave.
Rotación round-robin: el punto de partida
La estrategia más simple recorre las claves en orden. Sirve cuando todas son equivalentes.
Python
import itertools
import requests
API_KEYS = [
"KEY_ACCOUNT_1",
"KEY_ACCOUNT_2",
"KEY_ACCOUNT_3",
]
key_cycle = itertools.cycle(API_KEYS)
def get_next_key():
return next(key_cycle)
def solve_captcha(sitekey, page_url):
api_key = get_next_key()
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"json": "1",
})
data = resp.json()
if data["status"] != 1:
raise Exception(f"[{api_key[:8]}...] {data['request']}")
print(f"Submitted with key {api_key[:8]}...")
return data["request"], api_key
task_id, used_key = solve_captcha("6Le-SITEKEY", "https://example.com")
itertools.cycle avanza el índice en cada llamada. Con varios hilos, protégelo con un bloqueo: sin él, dos workers reciben la misma clave.
El round-robin te vale mientras se cumplan tres condiciones:
- Las claves están en planes con la misma capacidad de threads.
- Los saldos se recargan a la vez y con importes parecidos.
- Ninguna clave tiene restricciones de IP distintas.
En cuanto una falla, el reparto parejo manda tráfico a la clave equivocada y toca ponderar.
Rotación ponderada por saldo
Con una clave recién recargada y otra a punto de agotarse, el reparto uniforme es justo lo que no quieres. Esta versión consulta el saldo con action=getbalance, pondera la elección por él y aparta la clave que devuelva un error definitivo.
import random
import requests
import threading
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
class KeyRotator:
def __init__(self, keys):
self.keys = {k: {"balance": 0, "failures": 0, "disabled": False} for k in keys}
self._lock = threading.Lock()
self.refresh_balances()
def refresh_balances(self):
for key in self.keys:
try:
resp = requests.get(RESULT_URL, params={
"key": key, "action": "getbalance", "json": "1"
}, timeout=10).json()
if resp["status"] == 1:
self.keys[key]["balance"] = float(resp["request"])
self.keys[key]["disabled"] = False
else:
self.keys[key]["disabled"] = True
except Exception:
self.keys[key]["disabled"] = True
def get_key(self):
with self._lock:
available = {
k: v for k, v in self.keys.items()
if not v["disabled"] and v["balance"] > 0.01
}
if not available:
raise Exception("No API keys with balance available")
# Weighted random by balance
keys = list(available.keys())
weights = [available[k]["balance"] for k in keys]
return random.choices(keys, weights=weights, k=1)[0]
def report_failure(self, key, error_code):
with self._lock:
self.keys[key]["failures"] += 1
if error_code in ("ERROR_WRONG_USER_KEY", "ERROR_KEY_DOES_NOT_EXIST",
"ERROR_ZERO_BALANCE", "ERROR_IP_NOT_ALLOWED"):
self.keys[key]["disabled"] = True
print(f"[rotator] Disabled key {key[:8]}...: {error_code}")
def report_success(self, key, cost=0.003):
with self._lock:
self.keys[key]["balance"] -= cost
self.keys[key]["failures"] = 0
rotator = KeyRotator(["KEY_1", "KEY_2", "KEY_3"])
# Usage
api_key = rotator.get_key()
# ... solve captcha ...
rotator.report_success(api_key)
Dos matices. El descuento de report_success es una estimación local para no dejar el saldo congelado entre refrescos; el valor bueno llega del getbalance siguiente. Y el _lock no es decorativo: sin él, dos hilos leen el diccionario mientras un tercero lo modifica.
Failover: reintentar con la siguiente clave
El rotador elige clave; el failover decide qué pasa cuando esa elección falla. Es un bucle acotado por max_attempts: reporta el fallo, pide otra clave y reenvía la tarea. El límite importa: sin él, un corte de red recorre todas tus claves y las aparta una a una.
Python
def solve_with_failover(sitekey, page_url, max_attempts=3):
for attempt in range(max_attempts):
api_key = rotator.get_key()
try:
resp = requests.post(SUBMIT_URL, data={
"key": api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"json": "1",
}, timeout=15)
data = resp.json()
if data["status"] != 1:
rotator.report_failure(api_key, data["request"])
continue
rotator.report_success(api_key)
return data["request"], api_key
except requests.RequestException:
rotator.report_failure(api_key, "NETWORK_ERROR")
continue
raise Exception(f"All {max_attempts} keys failed")
JavaScript
const axios = require('axios');
class KeyRotator {
constructor(keys) {
this.keys = keys.map(k => ({ key: k, disabled: false, failures: 0 }));
this.index = 0;
}
getKey() {
const available = this.keys.filter(k => !k.disabled);
if (available.length === 0) throw new Error('No API keys available');
const entry = available[this.index % available.length];
this.index++;
return entry.key;
}
disable(key, reason) {
const entry = this.keys.find(k => k.key === key);
if (entry) {
entry.disabled = true;
console.log(`[rotator] Disabled ${key.substring(0, 8)}...: ${reason}`);
}
}
}
const rotator = new KeyRotator(['KEY_1', 'KEY_2', 'KEY_3']);
async function solveWithFailover(sitekey, pageurl, maxAttempts = 3) {
for (let i = 0; i < maxAttempts; i++) {
const apiKey = rotator.getKey();
try {
const resp = await axios.post('https://ocr.captchaai.com/in.php', null, {
params: { key: apiKey, method: 'userrecaptcha', googlekey: sitekey, pageurl, json: 1 }
});
if (resp.data.status !== 1) {
rotator.disable(apiKey, resp.data.request);
continue;
}
return { taskId: resp.data.request, apiKey };
} catch (err) {
rotator.disable(apiKey, 'NETWORK_ERROR');
}
}
throw new Error('All keys failed');
}
La versión de JavaScript deshabilita ante cualquier error, incluidos los de red. Afínala antes de producción: un timeout puntual no debería costarte una clave.
Las claves, fuera del código
Las claves salen del repositorio y entran por el entorno: es la diferencia entre rotar una clave comprometida en un minuto y reescribir el historial de git.
import os
API_KEYS = os.environ["CAPTCHAAI_KEYS"].split(",")
# Set: CAPTCHAAI_KEYS=key1,key2,key3
rotator = KeyRotator(API_KEYS)
const API_KEYS = process.env.CAPTCHAAI_KEYS.split(',');
const rotator = new KeyRotator(API_KEYS);
En contenedores, inyecta esa variable desde el gestor de secretos de tu orquestador, no desde un .env copiado dentro de la imagen. Tres reglas más:
- Nunca imprimas la clave entera en un log; los primeros ocho caracteres bastan.
- Mantén
.envfuera del control de versiones y revisa commits antiguos. - Restringe cada clave a las IP de salida que de verdad la usan.
Refresco programado de saldos
Si el rotador arrancó con saldos de hace diez días, sus pesos ya no significan nada. Basta un hilo en segundo plano que consulte cada cinco minutos.
import threading
def periodic_refresh(rotator, interval=300):
def refresh():
while True:
rotator.refresh_balances()
for key, info in rotator.keys.items():
print(f" {key[:8]}...: ${info['balance']:.2f} "
f"{'(disabled)' if info['disabled'] else '(active)'}")
threading.Event().wait(interval)
t = threading.Thread(target=refresh, daemon=True)
t.start()
periodic_refresh(rotator, interval=300) # every 5 minutes
Ese refresco es el sitio natural para alertar por saldo bajo o encadenar una recarga automática.
Caso práctico: una agencia con tres clientes
Piensa en un equipo pequeño en Ciudad de México que monitoriza precios en marketplaces regionales y hace QA de formularios en portales públicos con CAPTCHA, tipo cita previa. Sus tres clientes se facturan por separado.
El montaje que funciona: una clave por cliente, todas cargadas desde CAPTCHAAI_KEYS, y el rotador filtrando por cliente en lugar de repartir a ciegas. Cada factura sale del getbalance de su clave. Si un cliente crece, esa clave sube de STANDARD ($30/mes, 15 threads) a ADVANCE ($90/mes, 50 threads) sin tocar a los demás. Al cobrarse en USD y por thread, el coste mensual es predecible aunque el volumen fluctúe.
Y el recordatorio de siempre: respeta los términos de servicio de cada sitio y la normativa de protección de datos aplicable.
Qué errores deben apartar una clave (y cuáles no)
Regla práctica: deshabilita solo ante errores que no se arreglan reintentando.
- Definitivos (aparta la clave):
ERROR_WRONG_USER_KEY,ERROR_KEY_DOES_NOT_EXIST,ERROR_ZERO_BALANCE,ERROR_IP_NOT_ALLOWED. - Transitorios (reintenta): tiempos de espera agotados, cortes de red y respuestas 5xx. Tratarlos como definitivos deja tu rotador sin claves activas tras una microcaída.
Añade una ventana de reactivación: la clave apartada por saldo cero debería reintentarse en el siguiente refresco, no esperar a que alguien reinicie el proceso.
Solución de problemas
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Todas las claves deshabilitadas | Saldo agotado en todas | Recarga y revisa los ERROR_ZERO_BALANCE del log |
| Siempre sale la misma clave | El índice del round-robin no avanza | Protege el avance con un bloqueo |
| Una clave se aparta sin motivo | Error transitorio tratado como definitivo | Deshabilita solo con ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_IP_NOT_ALLOWED |
| El saldo mostrado no cuadra | Estimación local sin refresco reciente | Baja el interval del refresco |
Preguntas frecuentes
¿Rotar claves me da más resoluciones por minuto?
No. La concurrencia la fijan los threads de tu plan, no el número de claves. Para más capacidad, sube de plan.
¿Cómo evito que un fallo temporal me deje sin claves activas?
Distingue el tipo de error antes de deshabilitar y exige varios fallos consecutivos: solo los códigos de clave o de saldo justifican apartarla.
¿Puedo tener todas las claves en el mismo servidor?
Sí, siempre que entren por variables de entorno o por un gestor de secretos y nunca lleguen al repositorio. Limita además las IP autorizadas.
¿Qué pasa si todas se quedan sin saldo de madrugada?
El rotador lanza la excepción de "sin claves con saldo" y la cola se detiene. Alerta por umbral de saldo y deja una clave de reserva con recarga automática.
¿Sirve el mismo rotador para Turnstile o GeeTest v3?
Sí. La selección de clave no depende del tipo de CAPTCHA: solo cambia el method que envías a in.php.
Monta tu rotador y quítate el punto único de fallo
Obtén tu clave API en captchaai.com y prueba el rotador con dos claves.