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

Nhận kết quả giải CAPTCHA theo thời gian thực bằng Server-Sent Events

Khi dashboard gọi res.php mỗi 5 giây để hỏi "xong chưa?", token có thể sẵn sàng ở giây thứ 6 nhưng người dùng phải đợi tới giây thứ 10. Server-Sent Events (SSE) đảo ngược hướng đó: server giữ một kết nối HTTP mở và đẩy kết quả xuống trình duyệt ngay khi CaptchaAI gọi callback.

Vì sao SSE hợp với luồng giải CAPTCHA

Kết quả giải CAPTCHA chỉ đi một chiều: từ server xuống client. Đó đúng là bài toán EventSource sinh ra để giải, rẻ hơn hẳn WebSocket.

Tiêu chí SSE WebSocket Polling
Hướng Server → client Hai chiều Client → server
Giao thức HTTP/1.1+ WS/WSS HTTP
Tự kết nối lại Có sẵn Tự viết Không
Độ phức tạp Thấp Trung bình Thấp
Request lãng phí Không Không Nhiều
Cho kết quả CAPTCHA Phù hợp nhất Thừa Tốn

Kiến trúc bốn bước

[Client] ← SSE stream ← [Your Server] ← Callback ← [CaptchaAI]
   ↓                          ↑
   Submit task → [CaptchaAI] ──┘ (pingback URL points to your server)
  1. Client mở kết nối tới endpoint SSE và giữ nguyên kết nối đó.
  2. Client gửi task tới CaptchaAI, kèm pingback trỏ về server của bạn.
  3. CaptchaAI giải xong và gọi vào callback.
  4. Server đẩy token xuống đúng client qua luồng SSE.

pingback thay thế vòng lặp polling: bạn vẫn gửi task như cũ, chỉ khác là không còn ai đi hỏi kết quả.

Triển khai Python (Flask)

Phía server

Server giữ một hàng đợi cho mỗi client: endpoint SSE chặn ở đó tới khi có kết quả, callback là nơi CaptchaAI ghi vào.

import os
import queue
import threading
import requests
from flask import Flask, Response, request, jsonify

app = Flask(__name__)

API_KEY = os.environ["CAPTCHAAI_API_KEY"]

# Per-client event queues: client_id -> Queue
client_queues = {}
queues_lock = threading.Lock()

@app.route("/events/<client_id>")
def sse_stream(client_id):
    """SSE endpoint — clients connect here for real-time results."""
    q = queue.Queue()

    with queues_lock:
        client_queues[client_id] = q

    def generate():
        try:
            while True:
                # Block until a result arrives (timeout for keepalive)
                try:
                    data = q.get(timeout=30)
                    yield f"event: captcha-solved\ndata: {data}\n\n"
                except queue.Empty:
                    # Send keepalive comment to prevent connection timeout
                    yield ": keepalive\n\n"
        finally:
            with queues_lock:
                client_queues.pop(client_id, None)

    return Response(
        generate(),
        mimetype="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no"  # Disable nginx buffering
        }
    )

@app.route("/submit", methods=["POST"])
def submit_captcha():
    """Submit a CAPTCHA task with callback to this server."""
    data = request.json
    client_id = data["client_id"]
    sitekey = data["sitekey"]
    pageurl = data["pageurl"]

    callback_url = f"{request.host_url}callback?client_id={client_id}"

    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "pingback": callback_url,
        "json": 1
    })
    result = resp.json()

    if result.get("status") == 1:
        return jsonify({"task_id": result["request"]})
    return jsonify({"error": result.get("request")}), 400

@app.route("/callback")
def captcha_callback():
    """Receive CaptchaAI callback and push to SSE stream."""
    client_id = request.args.get("client_id")
    task_id = request.args.get("id")
    solution = request.args.get("code")

    import json
    message = json.dumps({
        "task_id": task_id,
        "solution": solution
    })

    with queues_lock:
        q = client_queues.get(client_id)
        if q:
            q.put(message)

    return "OK", 200

if __name__ == "__main__":
    app.run(port=5000, threaded=True)

Hai chi tiết dễ bỏ sót: mỗi event kết thúc bằng một dòng trống, và X-Accel-Buffering: no ngăn nginx gom buffer.

Phía trình duyệt

<!DOCTYPE html>
<html>
<body>
  <button onclick="submitCaptcha()">Solve CAPTCHA</button>
  <div id="results"></div>

  <script>
    const clientId = crypto.randomUUID();
    const resultsDiv = document.getElementById("results");

    // Connect SSE stream
    const eventSource = new EventSource(`/events/${clientId}`);

    eventSource.addEventListener("captcha-solved", (event) => {
      const data = JSON.parse(event.data);
      resultsDiv.innerHTML += `<p>Task ${data.task_id}: ${data.solution.substring(0, 30)}...</p>`;
    });

    eventSource.onerror = () => {
      console.log("SSE connection lost, reconnecting...");
    };

    async function submitCaptcha() {
      const response = await fetch("/submit", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          client_id: clientId,
          sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
          pageurl: "https://example.com"
        })
      });
      const result = await response.json();
      resultsDiv.innerHTML += `<p>Submitted: ${result.task_id}</p>`;
    }
  </script>
</body>
</html>

EventSource tự kết nối lại khi mạch đứt — lợi thế lớn nhất so với WebSocket tự viết, nhất là khi người dùng chuyển giữa Wi-Fi và 4G.

Triển khai Node.js (Express)

Cùng mô hình, thay hàng đợi bằng một Map giữ Response của từng client.

const express = require("express");
const axios = require("axios");

const app = express();
app.use(express.json());

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";

// Per-client SSE connections: clientId -> Response object
const clients = new Map();

// SSE endpoint
app.get("/events/:clientId", (req, res) => {
  const clientId = req.params.clientId;

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no",
  });

  clients.set(clientId, res);

  // Keepalive every 30 seconds
  const keepalive = setInterval(() => {
    res.write(": keepalive\n\n");
  }, 30000);

  req.on("close", () => {
    clearInterval(keepalive);
    clients.delete(clientId);
  });
});

// Submit CAPTCHA
app.post("/submit", async (req, res) => {
  const { client_id, sitekey, pageurl } = req.body;
  const callbackUrl = `${BASE_URL}/callback?client_id=${client_id}`;

  try {
    const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
      params: {
        key: API_KEY,
        method: "userrecaptcha",
        googlekey: sitekey,
        pageurl: pageurl,
        pingback: callbackUrl,
        json: 1,
      },
    });

    if (resp.data.status === 1) {
      return res.json({ task_id: resp.data.request });
    }
    res.status(400).json({ error: resp.data.request });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// CaptchaAI callback → push to SSE
app.get("/callback", (req, res) => {
  const clientId = req.query.client_id;
  const taskId = req.query.id;
  const solution = req.query.code;

  const clientRes = clients.get(clientId);
  if (clientRes) {
    const data = JSON.stringify({ task_id: taskId, solution: solution });
    clientRes.write(`event: captcha-solved\ndata: ${data}\n\n`);
  }

  res.sendStatus(200);
});

app.listen(3000, () => console.log("SSE server running on :3000"));

Với Node.js, res.write() là toàn bộ cơ chế đẩy dữ liệu; đừng gọi res.end() tới khi client ngắt.

Ví dụ: dashboard QA của một team outsourcing ở TP.HCM

Một team QA chạy hồi quy trên staging của chính khách hàng, mỗi đêm khoảng 3.000 lượt đăng nhập có reCAPTCHA v2. Mỗi worker polling res.php 5 giây một lần tạo ra hàng chục nghìn request rỗng và một dashboard luôn trễ vài giây; chuyển sang pingback kết hợp SSE, con số đó về gần bằng không.

Hóa đơn không đổi: CaptchaAI tính tiền theo thread chứ không theo lượt giải — gói ADVANCE ($90/tháng, 50 thread) cho phép 50 CAPTCHA giải đồng thời, số lần gọi res.php không phải đơn vị tính tiền. SSE tiết kiệm độ trễ và log rác, không tiết kiệm tiền. Lợi ích phụ với các đội để mắt tới Nghị định 13/2023/NĐ-CP: kết quả đi qua đúng một handler, nên chỉ có một chỗ ghi audit log.

Cách này áp dụng y hệt cho các loại khác CaptchaAI hỗ trợ — Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, image/OCR — chỉ khác giá trị method.

Chạy nhiều instance: Redis Pub/Sub

Kết nối SSE là có trạng thái. Chạy hai instance sau load balancer thì callback có thể rơi vào instance B trong khi client treo kết nối ở instance A. Đặt một message bus ở giữa:

# Callback handler publishes to Redis
import redis
r = redis.Redis()
r.publish(f"captcha:{client_id}", json.dumps(message))

# SSE handler subscribes to Redis
pubsub = r.pubsub()
pubsub.subscribe(f"captcha:{client_id}")
for msg in pubsub.listen():
    if msg["type"] == "message":
        yield f"data: {msg['data'].decode()}\n\n"

Handler callback chỉ publish, handler SSE subscribe theo client_id. Instance nào giữ kết nối sẽ nhận, số còn lại bỏ qua.

Giới hạn 6 kết nối trên mỗi domain

Trình duyệt chỉ cho phép 6 kết nối HTTP/1.1 đồng thời tới một domain, mỗi luồng SSE chiếm một suất — tab thứ bảy sẽ treo. Hai lối ra: bật HTTP/2, hoặc gộp mọi kết quả của một client vào một luồng.

Bảng lỗi thường gặp

Vấn đề Nguyên nhân Cách xử lý
Kết nối rớt sau 30 giây Proxy/load balancer đóng kết nối idle Gửi : keepalive định kỳ; tăng timeout proxy
Không nhận được kết quả Callback rơi vào instance khác Thêm Redis Pub/Sub giữa callback và SSE
Console báo lỗi đỏ Thiếu header CORS Thêm Access-Control-Allow-Origin cho endpoint SSE
Kết nối lại liên tục Định dạng event sai Mỗi event kết thúc bằng dòng trống, data một dòng
Dữ liệu chỉ hiện khi đóng kết nối nginx buffer phản hồi Đặt X-Accel-Buffering: no

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

Khi nào nên chọn SSE, khi nào nên giữ polling?

Chọn SSE khi có giao diện web đang chờ kết quả. Giữ polling khi phía tiêu thụ là script CLI hay job chạy nền: vòng lặp res.php dễ debug hơn hẳn.

Một server chịu được bao nhiêu kết nối SSE cùng lúc?

Node.js xử lý hàng chục nghìn kết nối khá thoải mái vì mỗi kết nối chỉ là một HTTP keep-alive nhẹ. Flask theo thread chạm giới hạn sớm hơn — cần đồng thời cao thì dùng FastAPI.

Nếu client mất mạng đúng lúc CaptchaAI gọi callback thì sao?

Token rơi vào khoảng trống, vì EventSource chỉ kết nối lại chứ không phát lại event đã lỡ. Cách chống: lưu kết quả vào Redis kèm TTL ngắn, đẩy lại task còn treo khi client quay lại.

Dùng SSE có làm tăng chi phí CaptchaAI không?

Không. CaptchaAI tính tiền theo số thread đồng thời, không theo số lần gọi API kiểm tra trạng thái. Gói nhỏ nhất là BASIC ($15/tháng, 5 thread); muốn giải song song nhiều hơn thì nâng thread.

Bước tiếp theo

Lấy API key CaptchaAI, trỏ pingback về /callback, rồi mở EventSource từ dashboard.

Đọc thêm:

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