Hướng Dẫn Thực Hành

Chống trùng lặp yêu cầu giải CAPTCHA bằng khóa cơ sở dữ liệu

CaptchaAI tính phí theo thread đang chạy, không theo từng lần submit — nhưng nếu hai worker cùng gửi một CAPTCHA giống hệt nhau trong cùng một khoảng thời gian, bạn vẫn chiếm dụng hai thread cho một kết quả duy nhất.

Tình huống này quen thuộc với team scraping/QA outsourcing tại TP.HCM và Hà Nội chạy hàng chục worker song song theo dõi giá trên Shopee, Lazada, Tiki — ví dụ một team dùng gói ADVANCE ($90/tháng, 50 thread) rất dễ để nhiều worker vô tình nhắm cùng một sitekey/pageurl trong cùng một đợt crawl.

Ý tưởng cốt lõi: đặt một khóa duy nhất ứng với method/sitekey/pageurl trước khi submit, để worker đến sau tái sử dụng kết quả của worker đến trước thay vì gửi thêm request mới.

Vì sao request giải CAPTCHA bị trùng lặp

Bốn nguyên nhân sau chiếm phần lớn request trùng lặp trong hệ thống nhiều worker:

  • Retry trước khi có kết quả — logic retry quá tích cực gửi lại một CAPTCHA trong khi request trước còn đang chờ, tốn gấp 2–5 lần chi phí.
  • Nhiều worker cùng nhắm một mục tiêu — không có cơ chế phối hợp nên các worker cùng giải song song một sitekey/pageurl, lãng phí thread.
  • Frontend tự kích hoạt lại khi refresh — retry ở giao diện người dùng khi hết timeout khiến mỗi lần làm mới trang tốn thêm một lần giải.
  • Message trong hàng đợi bị phát lại — cơ chế đảm bảo "at-least-once delivery" khiến cùng một message được xử lý và giải trùng nhiều lần.

Thiết kế khóa chống trùng lặp (dedup key)

Tạo một khóa duy nhất từ các tham số của request:

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

Thành phần khóa thay đổi theo từng loại CAPTCHA:

  • reCAPTCHA v2method + sitekey + pageurl
  • reCAPTCHA v3method + sitekey + pageurl + action (action ảnh hưởng điểm số nên phải nằm trong khóa)
  • hCaptchamethod + sitekey + pageurl
  • Cloudflare Turnstilemethod + sitekey + pageurl
  • CAPTCHA hình ảnhmethod + hàm băm của body (nội dung ảnh), không dùng sitekey/pageurl vì ảnh không có hai trường này

Triển khai chống trùng lặp bằng Redis

Cách làm với 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"}

Cách làm với 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" };
}

Dùng khóa PostgreSQL khi không có Redis

Nếu stack scraping/QA của bạn chạy trên PostgreSQL và không muốn thêm Redis chỉ để dedup, advisory lock của Postgres làm được việc tương tự:

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

Đo lường mức tiết kiệm nhờ dedup

Ghi lại số liệu để chứng minh khóa dedup đáng công triển khai:

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
    }

Xử lý sự cố thường gặp

Khóa dedup bị đụng độ (collision)

Tham số băm quá ngắn hoặc thiếu trường. Đưa đủ tham số đặc thù của loại CAPTCHA vào khóa và tăng độ dài chuỗi băm.

Worker chờ bị timeout

Worker đang giải bị crash giữa chừng nên không cập nhật trạng thái. TTL của solving tự hết hạn sau 180 giây, worker chờ sẽ không kẹt mãi.

Kết quả cache đã cũ

Token hết hạn nhưng bản cache vẫn còn hiệu lực. Đặt TTL cache kết quả ngắn hơn vòng đời token — 60 giây là mức an toàn với reCAPTCHA.

Race condition khi set khóa

Hai worker kiểm tra khóa cùng lúc. Dùng SET NX (set-if-not-exists) để lấy khóa một cách nguyên tử (atomic).

Câu hỏi thường gặp

TTL của khóa chống trùng lặp nên đặt bao lâu?

Đặt TTL trạng thái solving bằng thời gian giải tối đa cộng thêm biên an toàn — 180 giây là mức hợp lý cho phần lớn loại CAPTCHA. TTL cache kết quả nên ngắn hơn nhiều, vì token hết hạn nhanh hơn nhiều so với thời gian giải.

Đặt khóa trước khi submit có làm chậm request đầu tiên không?

Không đáng kể. SET NX trên Redis hoặc pg_try_advisory_lock mất vài mili giây, trong khi giải một CAPTCHA mất từ vài giây đến hàng chục giây.

CAPTCHA dạng ảnh có dedup được không?

Có — dùng hàm băm của nội dung ảnh làm một phần của khóa, chứ không chỉ sitekey/pageurl. Hai ảnh giống hệt nhau luôn trả về cùng một văn bản.

Proxy khác nhau cho cùng một CAPTCHA có ảnh hưởng đến dedup không?

Không nên đưa proxy vào khóa dedup. Token kết quả hoạt động bất kể proxy nào đã giải nó — đưa proxy vào khóa chỉ khiến hai worker bị coi là hai request riêng biệt.

Chỉ chạy một worker duy nhất thì có cần dedup không?

Ít cần cho phần điều phối, nhưng khóa dedup vẫn chặn được retry trùng do timeout hoặc do frontend gửi lại request khi người dùng refresh.

Triển khai ngay

Ngừng trả tiền cho các lần giải CAPTCHA trùng lặp — lấy API key CaptchaAI và thêm lớp khóa dedup vào pipeline của bạn ngay hôm nay.

Hướng dẫn liên quan:

Os comentários estão desativados para este artigo.