Tutorials

Événements envoyés par le serveur pour les notifications de résolution CAPTCHA en temps réel

Pour prévenir un navigateur qu'un CAPTCHA vient d'être résolu, le plus économe reste une connexion HTTP ouverte que votre serveur alimente : les Server-Sent Events (SSE). Plus de requête toutes les cinq secondes pour savoir si le token est prêt : le client attend, et le résultat arrive dès que le callback CaptchaAI frappe votre endpoint.

Cas typique : un back-office de QA hébergé chez OVHcloud, où un testeur relance la validation d'un formulaire et attend le token dans l'onglet ouvert, pas au prochain rafraîchissement. Ce guide monte la chaîne en Flask, puis en Express.

SSE, WebSocket ou polling : que choisir pour un résultat CAPTCHA ?

Critère SSE WebSocket Polling
Sens du flux Serveur → client Bidirectionnel Client → serveur
Reconnexion automatique Native (EventSource) À coder Sans objet
Requêtes inutiles Aucune Aucune Nombreuses

Un résultat CAPTCHA ne circule que dans un sens : WebSocket résout un problème que vous n'avez pas, et le polling paie l'attente en requêtes vides.

Le trajet d'un token, de in.php jusqu'au navigateur

[Client] ← SSE stream ← [Your Server] ← Callback ← [CaptchaAI]
   ↓                          ↑
   Submit task → [CaptchaAI] ──┘ (pingback URL points to your server)
  1. Le client ouvre l'endpoint SSE de votre serveur (connexion HTTP persistante)
  2. Il envoie une tâche à CaptchaAI avec un pingback pointant vers votre serveur
  3. CaptchaAI résout, puis appelle votre URL de callback avec le token
  4. Votre serveur pousse le token dans le flux SSE du client concerné

Le navigateur ne parle jamais à res.php : la clé API ne quitte pas le serveur.

Étape 1 : diffuser les résultats depuis un serveur Flask

Le serveur tient une file d'attente par client. L'endpoint /events/<client_id> bloque sur cette file et n'écrit qu'à l'arrivée d'un token ; après 30 secondes de silence, il envoie un keepalive contre les coupures de proxy.

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)

Derrière nginx, X-Accel-Buffering: no est indispensable : sans lui, la réponse est mise en tampon et les événements arrivent groupés.

Le client navigateur

Côté page, EventSource ouvre le flux, écoute l'événement captcha-solved et se reconnecte seul.

<!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>

Étape 2 : la même chaîne en Node.js avec Express

Logique identique, avec un objet Response conservé par client : les en-têtes partent une fois, puis chaque callback écrit dans le flux ouvert.

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

Passer à l'échelle derrière un load balancer

Une connexion SSE est avec état. En plusieurs instances derrière un load balancer, le callback peut atterrir sur l'instance B alors que le navigateur écoute l'instance A : le token est reçu, puis perdu. La réponse habituelle : un bus de messages. Le callback publie sur un canal Redis nommé d'après le client, auquel le flux SSE est abonné.

# 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"

Le plafond de six connexions par domaine

En HTTP/1.1, les navigateurs limitent à six les connexions simultanées par domaine, flux SSE compris. Passez en HTTP/2, ou multiplexez toutes les tâches d'un client dans un flux unique.

Dépannage

Problème Cause probable Correctif
Connexion coupée toutes les 30 secondes Timeout du proxy ou du load balancer Keepalive + timeout proxy relevé
Aucun résultat côté client Callback reçu par une autre instance Redis Pub/Sub entre callback et flux SSE
Erreur CORS dans la console En-tête manquant sur l'endpoint SSE Ajouter Access-Control-Allow-Origin
Reconnexions en boucle Événement SSE mal formé Double fin de ligne obligatoire

FAQ

Et si le callback n'arrive jamais ?

Prévoyez un fallback : côté serveur, si rien n'est arrivé au bout de votre délai habituel, interrogez res.php une seule fois pour la tâche concernée. Un réseau qui filtre les entrées reste la cause la plus fréquente.

Que se passe-t-il si l'utilisateur perd la connexion pendant la résolution ?

EventSource relance la connexion seul, mais l'événement émis pendant la coupure est perdu. Gardez le dernier résultat par client quelques minutes côté serveur, et renvoyez-le à la reconnexion.

SSE fonctionne-t-il derrière Cloudflare ?

Oui, à condition de désactiver la mise en mémoire tampon des réponses : X-Accel-Buffering: no suffit le plus souvent. Validez toujours depuis un vrai navigateur.

Puis-je diffuser des résultats hCaptcha avec ce montage ?

Non : hCaptcha et FunCaptcha ne sont pas pris en charge. Le montage vaut pour reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, les CAPTCHA image/OCR, et pour CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta).

Et pour un worker sans navigateur ?

Traitez le callback directement, ou passez par une file d'attente. SSE vaut surtout quand un humain regarde un écran.

Prochaines étapes

Ouvrez un flux, branchez le pingback : vos tokens s'affichent dès leur résolution — créez votre clé API CaptchaAI pour tester.

Guides associés :

Les commentaires sont désactivés pour cet article.