Tutorials

Дедупликация запросов на решение CAPTCHA с блокировкой базы данных

Пять параллельных воркеров с одинаковым sitekey и pageurl — и CaptchaAI выставит счёт за пять решений одной и той же CAPTCHA вместо одного. Слой дедупликации перехватывает такие повторные запросы до отправки на решение и отдаёт им уже готовый результат: экономит кредиты API и убирает лишнюю задержку.

Откуда берутся дубликаты

Прежде чем проектировать ключ, стоит понять, какие сценарии реально плодят повторные решения:

  • Повтор до получения результата. Слишком агрессивная логика ретраев — CAPTCHA фактически оплачивается 2–5 раз.
  • Несколько воркеров бьют в одну цель. Нет координации между воркерами — параллельные решения одной и той же задачи.
  • Повторный рендер страницы на фронтенде. Ретрай по таймауту на клиенте — лишнее решение на каждое обновление.
  • Сообщение очереди доставлено повторно. Гарантия доставки «хотя бы один раз» — дублирующее решение на каждый повтор.

На тарифе STANDARD ($30/мес, 15 потоков) пять воркеров с агрессивным ретраем без координации спокойно съедают половину доступных потоков на дубли, а не на новые задачи — дедупликация возвращает эти потоки в оборот без апгрейда тарифа.

Как спроектировать ключ дедупликации

Сгенерируйте уникальный ключ из параметров запроса:

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]}"

Из каких параметров собирать ключ для разных типов CAPTCHA:

  1. reCAPTCHA v2 — method + sitekey + pageurl.
  2. reCAPTCHA v3 — method + sitekey + pageurl + action.
  3. hCaptcha — method + sitekey + pageurl.
  4. Cloudflare Turnstile — method + sitekey + pageurl.
  5. Капча-изображение — method + хеш body (содержимого изображения).

Для reCAPTCHA v3 обязательно включайте action в ключ — иначе запросы с разных этапов сценария (например, login и checkout) склеятся в один дубликат и станут возвращать чужой токен.

Дедупликация на основе Redis

Реализация на 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"}

Реализация на 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" };
}

PostgreSQL: блокировки без Redis

Если Redis не входит в стек — то же самое можно сделать через advisory locks PostgreSQL:

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_advisory_lock — блокирующий вызов: воркер, который не успел первым, просто ждёт освобождения блокировки и затем читает уже закэшированный результат из captcha_cache. Это избавляет от отдельного TTL-таймера, но требует, чтобы соединение с базой не обрывалось на время ожидания.

Redis или PostgreSQL: что выбрать

Если Redis уже есть в стеке (а у большинства команд, парсящих сайты с прокси и очередями, он есть), берите его — TTL из коробки проще, чем следить за живыми соединениями для advisory-локов. PostgreSQL-вариант оправдан, когда команда сознательно не хочет держать ещё один сервис ради одной задачи дедупликации и уже пишет результаты решений в реляционную базу. Для команд с воркерами в нескольких регионах (например, EU + Казахстан/Центральная Азия — типичная топология для CIS-инфраструктуры) держите Redis-инстанс ближе к воркерам с наибольшим объёмом трафика: лишние 50–100 мс round-trip на каждую проверку ключа складываются в заметную задержку при высокой параллельности.

Метрики эффективности дедупликации

Отслеживайте, сколько запросов реально дедуплицируется:

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
    }

Держите эти счётчики хотя бы неделю (в примере — 7 * 86400 секунд) — недельного окна обычно хватает, чтобы увидеть долю дублей по будням и в пиковые часы отдельно.

Типичные проблемы и их решение

Если после внедрения дедупликации что-то идёт не так, разбирайте по симптому:

  • Коллизии ключей дедупликации. Причина — слишком короткий хеш или не все параметры учтены. Решение: включайте все специфичные для типа CAPTCHA параметры в ключ и увеличьте длину хеша.
  • Ожидающий воркер уходит по таймауту. Причина — решающий воркер упал, не отдав результат. Решение: TTL статуса solving автоматически истекает (180 с в примере), новая попытка запускается сама.
  • Устаревший закэшированный результат. Причина — токен уже истёк, а кеш ещё жив. Решение: ставьте TTL кеша результата короче времени жизни токена (60 с для reCAPTCHA).
  • Гонка при установке ключа. Причина — два воркера проверяют ключ одновременно. Решение: используйте SET NX (атомарная установка «если не существует») для блокировки.

Часто задаваемые вопросы

Вопросы, которые чаще всего возникают уже после внедрения дедупликации в продакшен-пайплайн.

Сколько держать TTL блокировки дедупликации?

Больше, чем реально занимает решение этого типа CAPTCHA, но с запасом на сетевые задержки. 180 секунд из примера — это запас на все GA-типы, включая случаи, когда воркер завис по сети, а не только на само решение.

Что делать, если воркер, который решает CAPTCHA, падает или зависает?

Ничего вручную: TTL статуса solving в Redis истекает сам, и следующий запрос с тем же ключом запускает новую попытку вместо бесконечного ожидания. В варианте с PostgreSQL эту роль играет сама advisory-блокировка — она снимается автоматически при разрыве соединения воркера.

Нужно ли дедуплицировать капчи с изображением?

Да, но ключ нужно строить из хеша содержимого изображения, а не из URL страницы или sitekey — одинаковые изображения дают одинаковый ответ, и дедупликация реально работает.

Как дедупликация сказывается на потоках CaptchaAI?

Каждое совпадение по ключу — это задача, которая не занимает поток заново: воркер получает результат из кеша, а не ждёт нового решения через API. На тарифах с фиксированным числом потоков, например ADVANCE ($90/мес, 50 потоков), это освобождает потоки под новые запросы вместо повторных решений одной и той же CAPTCHA.

Следующие шаги

Комментарии для этой статьи отключены.