Tutorials

Construindo um barramento de eventos CAPTCHA Solve com Node.js e CaptchaAI

Você já registrou callbacks e implementou polling, mas ainda descobre o estado de uma tarefa CAPTCHA só vasculhando logs manualmente? A saída é tratar o ciclo de vida do CAPTCHA como eventos, e deixar que cada parte da aplicação escute só o que precisa. Os cinco estados possíveis:

  • enviado — tarefa submetida ao endpoint in.php;
  • pendente — polling aguardando resultado em res.php;
  • resolvido — token pronto para uso;
  • falhou — a API retornou erro ou a submissão não saiu;
  • expirado — o maxWait foi atingido sem resposta.

Este tutorial monta esse barramento em Node.js com o EventEmitter nativo — sem Kafka, sem RabbitMQ — e traz o equivalente em Python para quem roda workers fora do ecossistema JavaScript.

Como o barramento organiza o ciclo de vida do CAPTCHA

Cada chamada à API da CaptchaAI passa por até cinco estados possíveis, e cada estado vira um evento que qualquer ouvinte pode assinar de forma independente:

[CaptchaBus]
   ├── emit("submitted", { taskId, type, pageurl })
   ├── emit("pending", { taskId, elapsed })
   ├── emit("solved", { taskId, solution, duration })
   ├── emit("failed", { taskId, error, duration })
   └── emit("timeout", { taskId, elapsed })
        ↓          ↓           ↓
   [Logger]    [Metrics]   [Retry Handler]

Cada ouvinte cuida de uma responsabilidade só, sem saber da existência dos outros:

  • Logger grava cada transição de estado no console, útil para depurar;
  • Metrics acumula contadores agregados (submitted, solved, failed);
  • Retry Handler decide, isoladamente, se vale reenviar uma tarefa que falhou.

Adicionar uma nova função — latência por região, alerta quando failed passa de um limite — não exige tocar na lógica de resolução: basta registrar mais um ouvinte.

Implementando a classe CaptchaBus em Node.js

A classe abaixo estende o EventEmitter nativo do Node.js. Ela envia a tarefa para in.php, faz o polling em res.php e emite o evento correspondente em cada etapa — sem bloquear a aplicação, já que o polling roda com setTimeout em vez de um laço síncrono:

const EventEmitter = require("events");
const axios = require("axios");

class CaptchaBus extends EventEmitter {
  constructor(apiKey, options = {}) {
    super();
    this.apiKey = apiKey;
    this.pollInterval = options.pollInterval || 5000;
    this.maxWait = options.maxWait || 300000; // 5 minutes
    this.pending = new Map();
  }

  async submit(params) {
    const { method, sitekey, pageurl, ...extra } = params;
    const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

    const submitParams = {
      key: this.apiKey,
      method: method || "userrecaptcha",
      googlekey: sitekey,
      pageurl: pageurl,
      json: 1,
      ...extra,
    };

    try {
      const resp = await axios.post(
        "https://ocr.captchaai.com/in.php",
        null,
        { params: submitParams }
      );

      if (resp.data.status !== 1) {
        this.emit("failed", {
          taskId,
          error: resp.data.request,
          duration: 0,
        });
        return null;
      }

      const captchaId = resp.data.request;
      const startTime = Date.now();

      this.emit("submitted", {
        taskId,
        captchaId,
        method: method || "userrecaptcha",
        pageurl,
      });

      // Start polling
      this._poll(taskId, captchaId, startTime);
      return taskId;
    } catch (err) {
      this.emit("failed", { taskId, error: err.message, duration: 0 });
      return null;
    }
  }

  async _poll(taskId, captchaId, startTime) {
    const check = async () => {
      const elapsed = Date.now() - startTime;

      if (elapsed > this.maxWait) {
        this.emit("timeout", { taskId, elapsed });
        return;
      }

      this.emit("pending", { taskId, elapsed });

      try {
        const resp = await axios.get("https://ocr.captchaai.com/res.php", {
          params: {
            key: this.apiKey,
            action: "get",
            id: captchaId,
            json: 1,
          },
        });

        if (resp.data.status === 1) {
          this.emit("solved", {
            taskId,
            captchaId,
            solution: resp.data.request,
            duration: Date.now() - startTime,
          });
        } else if (resp.data.request === "CAPCHA_NOT_READY") {
          setTimeout(check, this.pollInterval);
        } else {
          this.emit("failed", {
            taskId,
            error: resp.data.request,
            duration: Date.now() - startTime,
          });
        }
      } catch (err) {
        this.emit("failed", {
          taskId,
          error: err.message,
          duration: Date.now() - startTime,
        });
      }
    };

    setTimeout(check, this.pollInterval);
  }
}

module.exports = CaptchaBus;

Dois detalhes fazem diferença em produção:

  • o taskId é gerado localmente, antes da resposta da API — dá para rastrear a tarefa mesmo se a requisição falhar antes de chegar à CaptchaAI;
  • o maxWait (5 minutos por padrão) evita que uma tarefa travada fique consultando res.php para sempre: ela emite timeout e libera o ouvinte.

Registrando os ouvintes de eventos

Com a classe pronta, registre quantos ouvintes fizerem sentido para o seu caso. O exemplo cobre os três usos mais comuns: log estruturado no console, métricas agregadas em memória e o envio da primeira tarefa:

const CaptchaBus = require("./captcha-bus");

const bus = new CaptchaBus(process.env.CAPTCHAAI_API_KEY, {
  pollInterval: 5000,
  maxWait: 120000,
});

// Logging listener
bus.on("submitted", (e) => {
  console.log(`[SUBMIT] ${e.taskId} → ${e.method} on ${e.pageurl}`);
});

bus.on("pending", (e) => {
  console.log(`[PENDING] ${e.taskId} — ${(e.elapsed / 1000).toFixed(1)}s`);
});

bus.on("solved", (e) => {
  console.log(
    `[SOLVED] ${e.taskId} in ${(e.duration / 1000).toFixed(1)}s — ${e.solution.substring(0, 30)}...`
  );
});

bus.on("failed", (e) => {
  console.error(`[FAILED] ${e.taskId} — ${e.error}`);
});

bus.on("timeout", (e) => {
  console.error(
    `[TIMEOUT] ${e.taskId} after ${(e.elapsed / 1000).toFixed(1)}s`
  );
});

// Metrics listener
const metrics = { submitted: 0, solved: 0, failed: 0, totalDuration: 0 };

bus.on("submitted", () => metrics.submitted++);
bus.on("solved", (e) => {
  metrics.solved++;
  metrics.totalDuration += e.duration;
});
bus.on("failed", () => metrics.failed++);

// Submit a CAPTCHA
bus.submit({
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

Latência regional e auditoria de eventos

Se os workers rodam em uma região como sa-east-1 (São Paulo) na AWS, acrescente a latência de rede ao objeto metrics — a diferença entre a duração de solved e o tempo de resposta bruto da API separa "a CaptchaAI demorou" de "a rede até a CaptchaAI demorou". Ao persistir eventos em arquivo ou banco para auditoria, trate pageurl e qualquer dado de usuário final com as mesmas cautelas de retenção e acesso que a LGPD exige para dados coletados em produção.

Versão em Python: mesma arquitetura, outro runtime

Se o seu stack de automação roda em Python — comum em pipelines de scraping e QA —, o barramento segue o mesmo modelo de publish/subscribe, só que sobre um dicionário de callbacks em vez do EventEmitter. A lógica de submissão, polling e emissão de eventos é idêntica; muda só a sintaxe:

import os
import time
import threading
from collections import defaultdict
import requests


class CaptchaBus:
    def __init__(self, api_key, poll_interval=5, max_wait=300):
        self.api_key = api_key
        self.poll_interval = poll_interval
        self.max_wait = max_wait
        self._listeners = defaultdict(list)

    def on(self, event, callback):
        """Register a listener for an event."""
        self._listeners[event].append(callback)
        return self

    def emit(self, event, data):
        """Emit an event to all registered listeners."""
        for callback in self._listeners.get(event, []):
            try:
                callback(data)
            except Exception as e:
                print(f"Listener error on {event}: {e}")

    def submit(self, sitekey, pageurl, method="userrecaptcha", **extra):
        """Submit a CAPTCHA and begin tracking."""
        task_id = f"task_{int(time.time())}_{id(sitekey) % 10000}"

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": method,
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": 1,
            **extra
        })
        data = resp.json()

        if data.get("status") != 1:
            self.emit("failed", {
                "task_id": task_id,
                "error": data.get("request"),
                "duration": 0
            })
            return None

        captcha_id = data["request"]
        start_time = time.time()

        self.emit("submitted", {
            "task_id": task_id,
            "captcha_id": captcha_id,
            "method": method,
            "pageurl": pageurl
        })

        # Poll in a background thread
        thread = threading.Thread(
            target=self._poll,
            args=(task_id, captcha_id, start_time),
            daemon=True
        )
        thread.start()
        return task_id

    def _poll(self, task_id, captcha_id, start_time):
        while True:
            elapsed = time.time() - start_time

            if elapsed > self.max_wait:
                self.emit("timeout", {"task_id": task_id, "elapsed": elapsed})
                return

            time.sleep(self.poll_interval)
            self.emit("pending", {"task_id": task_id, "elapsed": elapsed})

            resp = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": captcha_id,
                "json": 1
            })
            data = resp.json()

            if data.get("status") == 1:
                self.emit("solved", {
                    "task_id": task_id,
                    "solution": data["request"],
                    "duration": time.time() - start_time
                })
                return
            elif data.get("request") != "CAPCHA_NOT_READY":
                self.emit("failed", {
                    "task_id": task_id,
                    "error": data.get("request"),
                    "duration": time.time() - start_time
                })
                return


# Usage
bus = CaptchaBus(os.environ["CAPTCHAAI_API_KEY"])

bus.on("submitted", lambda e: print(f"[SUBMIT] {e['task_id']}"))
bus.on("solved", lambda e: print(f"[SOLVED] {e['task_id']} in {e['duration']:.1f}s"))
bus.on("failed", lambda e: print(f"[FAILED] {e['task_id']} — {e['error']}"))
bus.on("timeout", lambda e: print(f"[TIMEOUT] {e['task_id']}"))

bus.submit("6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-", "https://example.com")

A principal diferença de implementação é que o polling roda em uma threading.Thread separada (com daemon=True, para não travar o encerramento do processo), enquanto na versão Node.js o setTimeout já é assíncrono por natureza da linguagem.

Retentativa automática como um ouvinte de eventos

Um dos maiores ganhos do modelo de eventos aparece aqui: a retentativa não precisa morar dentro da função submit(). Basta escutar o evento failed e decidir, de fora, se vale reenviar a tarefa:

// Automatic retry on failure
bus.on("failed", async (e) => {
  if (e.retryCount >= 3) {
    console.error(`[GIVE UP] ${e.taskId} after 3 retries`);
    return;
  }

  console.log(`[RETRY] ${e.taskId} — attempt ${(e.retryCount || 0) + 1}`);
  await bus.submit({
    ...e.originalParams,
    _retryCount: (e.retryCount || 0) + 1,
  });
});

Simplificando com um wrapper de Promise

Se o restante do seu código já usa async/await, expor o barramento como uma Promise evita que você precise escutar eventos manualmente toda vez que resolver um CAPTCHA:

function solveCaptcha(bus, params) {
  return new Promise((resolve, reject) => {
    const taskId = bus.submit(params);

    function onSolved(e) {
      if (e.taskId === taskId) {
        cleanup();
        resolve(e.solution);
      }
    }

    function onFailed(e) {
      if (e.taskId === taskId) {
        cleanup();
        reject(new Error(e.error));
      }
    }

    function cleanup() {
      bus.removeListener("solved", onSolved);
      bus.removeListener("failed", onFailed);
      bus.removeListener("timeout", onFailed);
    }

    bus.on("solved", onSolved);
    bus.on("failed", onFailed);
    bus.on("timeout", onFailed);
  });
}

// Usage
const solution = await solveCaptcha(bus, {
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

Problemas comuns e como resolver

A tabela reúne os problemas mais comuns ao colocar esse padrão em produção — a maioria aparece na primeira semana de uso, quando o volume de tarefas simultâneas ainda está sendo ajustado ao plano de threads contratado.

Problema Causa Correção
Ouvinte não dispara Nome do evento não bate (ex.: "solve" em vez de "solved") Confira os nomes exatos usados em emit/on
Aviso de vazamento de memória Muitos ouvintes registrados no mesmo evento Use setMaxListeners() ou remova ouvintes após o uso
Console inundado de eventos "pending" Intervalo de polling curto demais Aumente pollInterval para 5000 ms ou mais
Eventos perdidos numa retentativa Um novo taskId é gerado a cada retry Repasse os parâmetros originais para reconectar o estado

Se o problema for volume — muitas tarefas em pending ao mesmo tempo —, o gargalo pode estar no plano de threads, não no barramento: o BASIC (US$ 15/mês, 5 threads) processa 5 CAPTCHAs simultâneos; para escalar sem formar fila, migre para ADVANCE (US$ 90/mês, 50 threads) ou um plano superior.

Perguntas frequentes

Quantas threads da CaptchaAI eu preciso para essa arquitetura?

Depende de quanto o barramento processa em paralelo. Cada tarefa em pending ocupa uma thread até virar solved, failed ou timeout. Até 5 CAPTCHAs simultâneos, o BASIC (US$ 15/mês, 5 threads) basta; picos maiores pedem STANDARD (US$ 30/mês, 15 threads) ou ADVANCE (US$ 90/mês, 50 threads).

Preciso de Kafka ou RabbitMQ, ou o EventEmitter nativo já resolve?

Para uma aplicação de processo único, o EventEmitter nativo é suficiente e mais rápido de implementar. Considere Kafka, RabbitMQ ou Redis só quando múltiplos processos precisarem reagir aos mesmos eventos.

O que acontece se dois eventos "solved" chegarem para a mesma tarefa?

Na implementação deste tutorial isso não deveria ocorrer — o polling para assim que recebe status === 1. Se você adaptar a lógica, guarde os taskIds já resolvidos em um Set e ignore duplicados, por segurança.

Como testo o fluxo de eventos sem gastar créditos de verdade na API?

Substitua as chamadas HTTP por mocks (axios ou requests). Como o barramento é só um EventEmitter, dá para chamar bus.emit("solved", {...}) direto no teste e verificar se o ouvinte reage como esperado.

Esse padrão funciona fora do Node.js, por exemplo em um worker Python?

Sim — a versão em Python deste tutorial implementa o mesmo modelo de publish/subscribe com um dicionário de callbacks. A ideia central (emitir estado, ouvintes desacoplados) independe da linguagem; só a sintaxe muda.

Artigos relacionados

Próximas etapas

Comece a construir seu barramento de eventos hoje: obtenha sua chave de API da CaptchaAI e conecte o primeiro ouvinte ao seu pipeline de resolução.

Guias relacionados:

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