Tutorials

Deduplikasi Request Solve CAPTCHA dengan Database Locking

Jawaban singkat: bentuk dedup key dari hash SHA-256 kombinasi method + sitekey + pageurl, simpan sebagai key Redis ber-TTL atau lock ID PostgreSQL — begitu worker kedua menemukan key yang sama, ia memakai hasil yang sudah ada tanpa memanggil API CaptchaAI lagi.

Kalau lebih dari satu worker scraping Anda menargetkan sitekey dan halaman yang sama, kemungkinan besar Anda sedang membayar untuk CAPTCHA yang sudah diselesaikan detik itu juga oleh worker lain. Retry agresif, refresh halaman saat timeout, dan pesan queue yang di-replay semuanya berujung pada request solve kembar — dan setiap kembaran tetap menghabiskan kredit API secara penuh. Jawabannya bukan mengurangi jumlah worker, melainkan menambahkan satu lapisan dedup yang mengenali request kembar dalam hitungan milidetik.

Pola ini relevan buat tim scraping freelance atau agensi price-monitoring di Indonesia yang menjalankan beberapa worker paralel di region seperti AWS ap-southeast-1 (Singapura) atau GCP asia-southeast2 (Jakarta) — makin banyak worker yang jalan bersamaan memantau produk yang sama, makin besar peluang dua worker menabrak CAPTCHA identik nyaris bersamaan.

Kenapa Worker Paralel Membuat Anda Bayar CAPTCHA Berkali-Kali

Empat pola berikut adalah penyebab paling umum di balik request solve CAPTCHA yang terduplikasi pada pipeline automation:

Skenario Penyebab Dampak Biaya
Retry sebelum hasil selesai Retry logic terlalu agresif Biaya 2–5x per CAPTCHA
Banyak worker menyasar target sama Tidak ada koordinasi antar-worker Solve paralel yang mubazir
Refresh halaman memicu ulang Retry di sisi frontend saat timeout Solve tambahan per refresh
Pesan queue di-replay Jaminan pengiriman at-least-once Solve duplikat per replay

Dampaknya bertambah cepat begitu skala worker naik:

  • Satu pola saja yang dibiarkan sudah bisa menggandakan biaya solve untuk CAPTCHA yang sama persis.
  • Pada pipeline dengan puluhan worker paralel, akumulasi keempat pola ini bisa jadi porsi signifikan dari total pemakaian thread bulanan Anda.

Menyusun Dedup Key yang Andal

Dedup key dibentuk dari kombinasi parameter request yang membuat dua CAPTCHA benar-benar identik, bukan sekadar mirip. Untuk sebagian besar tipe CAPTCHA, kombinasi method, sitekey, dan pageurl sudah cukup unik. Hash SHA-256 memampatkan kombinasi ini jadi key pendek yang aman disimpan sebagai key Redis atau lock ID PostgreSQL:

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

16 karakter pertama dari hash biasanya sudah cukup untuk menghindari collision pada volume worker yang wajar; kalau volume Anda sangat tinggi, naikkan panjangnya. Komponen key berbeda sedikit tergantung tipe CAPTCHA:

Tipe CAPTCHA Komponen Key
reCAPTCHA v2 method + sitekey + pageurl
reCAPTCHA v3 method + sitekey + pageurl + action
hCaptcha method + sitekey + pageurl
Turnstile method + sitekey + pageurl
Image CAPTCHA method + hash body (isi gambar)

Untuk reCAPTCHA v3, jangan lewatkan action — action berbeda tetap dihitung sebagai request berbeda meski sitekey dan pageurl-nya identik.

Redis untuk Deduplikasi Real-Time

Redis paling praktis untuk dedup karena punya operasi atomik SET NX dan TTL bawaan. Worker pertama yang mengunci key langsung submit ke CaptchaAI; worker berikutnya yang datang dalam window yang sama cukup menunggu hasil dari worker pertama, tanpa mengirim request baru.

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

Implementasi Node.js

Logikanya identik di Node.js — cek status di Redis, kunci key kalau belum ada worker lain yang sedang memprosesnya, submit ke in.php, lalu polling res.php sampai token siap:

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

Alternatif Tanpa Redis: PostgreSQL Advisory Lock

Kalau infrastruktur Anda sudah punya PostgreSQL tapi belum punya Redis, advisory lock bisa jadi lapisan dedup tanpa komponen tambahan. pg_try_advisory_lock mengunci secara non-blocking; worker yang gagal mendapat lock otomatis menunggu lewat pg_advisory_lock, lalu membaca hasil dari tabel cache:

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,))

Pendekatan ini sedikit lebih lambat dibanding Redis pada throughput sangat tinggi, tapi solid untuk volume menengah dan menghindari dependency tambahan di stack Anda.

Mengukur Penghematan dari Dedup

Angka savings_pct di bawah ini murni hasil pengukuran internal pipeline Anda, bukan klaim resmi CaptchaAI — hasilnya bergantung pola traffic dan jumlah worker paralel yang Anda jalankan.

Catat setiap sumber hasil — dedup_cache, dedup_wait, atau api — supaya Anda tahu persis berapa persen request yang berhasil dihindari:

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
    }

Laporan harian sesederhana ini biasanya cukup untuk meyakinkan tim bahwa dedup layer sepadan dengan biaya maintenance-nya.

Masalah Umum dan Cara Mengatasinya

Hal yang paling sering meleset saat tim pertama kali menerapkan dedup layer ini:

Masalah Penyebab Solusi
Dedup key bentrok (collision) Hash terlalu pendek atau parameter kurang lengkap Sertakan semua parameter spesifik tipe CAPTCHA dalam key; naikkan panjang hash
Worker menunggu sampai timeout Worker yang sedang memprosesnya crash sebelum selesai TTL pada status solving otomatis kedaluwarsa (180 detik)
Hasil cache sudah basi Token sudah kedaluwarsa tapi cache masih dianggap valid Set TTL cache hasil lebih pendek dari umur token (60 detik untuk reCAPTCHA)
Race condition saat submit Dua worker memeriksa key secara bersamaan Gunakan SET NX (set-if-not-exists) supaya akuisisi lock atomik

Kalau worker Anda berjalan di banyak region sekaligus, pastikan semuanya terhubung ke instance Redis atau PostgreSQL yang sama — dedup key yang tersebar di instance yang tidak saling tahu tidak akan pernah saling mendeteksi duplikat.

Yang Sering Ditanyakan Tentang Dedup Request CAPTCHA

Apakah dedup bikin proses solve jadi lebih lambat?

Tidak untuk request yang memang duplikat — begitu dedup key ditemukan di cache, hasil dikembalikan dalam hitungan milidetik tanpa menyentuh API sama sekali. Request pertama yang benar-benar baru tetap melalui waktu solve normal karena memang belum ada hasil yang bisa dipakai ulang.

Idealnya, berapa lama TTL cache hasil solve?

Selalu lebih pendek dari umur token itu sendiri. Pada contoh di atas, TTL 60 detik aman untuk reCAPTCHA karena tokennya biasanya kedaluwarsa dalam rentang waktu serupa; sesuaikan turun kalau tipe CAPTCHA Anda punya token dengan umur lebih singkat.

Bagaimana mencegah race condition saat dua worker submit hampir bersamaan?

Jangan pakai pola cek-lalu-set biasa — pakai operasi atomik:

  • Redis: SET NX (set-if-not-exists) dalam satu perintah.
  • PostgreSQL: pg_try_advisory_lock untuk akuisisi lock non-blocking.

Operasi atomik memastikan hanya satu worker yang benar-benar menang mengunci key; worker lainnya otomatis masuk jalur menunggu hasil.

Haruskah proxy yang berbeda dipisahkan dalam dedup key?

Tidak perlu. Token hasil solve tetap berlaku terlepas dari proxy mana yang dipakai untuk menyelesaikannya, jadi menyertakan proxy di dedup key justru menggagalkan tujuan deduplikasi itu sendiri.

Apakah image CAPTCHA (OCR) juga perlu dedup?

Perlu, dengan sedikit modifikasi: gunakan hash dari konten gambar sebagai bagian dedup key, bukan sitekey atau pageurl. Gambar yang identik selalu menghasilkan teks yang sama, jadi dedup tetap efektif untuk tipe ini.

Mulai Terapkan Dedup di Pipeline Anda

Setiap request solve duplikat yang lolos adalah kredit API yang terbuang percuma — dan pada plan thread-based seperti BASIC ($15/bulan, 5 thread) atau STANDARD ($30/bulan, 15 thread), thread yang harusnya bisa melayani request baru malah habis untuk menyelesaikan CAPTCHA yang jawabannya sebenarnya sudah ada. Dapatkan API key CaptchaAI Anda dan terapkan dedup layer ini hari ini. Panduan terkait:

Komentar dinonaktifkan untuk artikel ini.