Tutorials

Membangun Event Bus Pemecahan CAPTCHA dengan Node.js dan CaptchaAI

Tim otomatisasi yang menjalankan puluhan hingga ratusan task CAPTCHA per jam biasanya menabrak masalah yang sama: logger, penghitung metrik, dan retry handler semuanya butuh tahu kapan sebuah task berpindah status, tapi menumpuk logika itu langsung di dalam fungsi submit() membuat kode cepat kusut dan susah diuji terpisah. Event bus menyelesaikan ini dengan satu titik penyiaran — submitted, pending, solved, failed, timeout — yang bisa didengarkan oleh bagian mana pun dari aplikasi Anda tanpa mengubah kode penyelesaian CAPTCHA itu sendiri.

Kenapa Event Bus, Bukan Sekadar Callback atau Polling?

Callback URL dan polling res.php sudah cukup untuk mengetahui hasil akhir sebuah task. Masalahnya muncul begitu Anda perlu lebih dari sekadar "solved atau failed": dashboard operasional yang menampilkan task mana yang masih pending, alert yang menyala saat timeout terlalu sering terjadi, atau retry logic yang harus jalan otomatis tanpa memblokir task lain. Menambal semua kebutuhan itu langsung di fungsi submit membuat satu perubahan kecil — misalnya menambah metrik baru — berisiko merusak alur penyelesaian yang sudah stabil.

Skenario ini umum di agensi pemantauan harga e-commerce dan tim scraping freelance di Indonesia yang menjalankan worker CaptchaAI 24 jam di region cloud seperti AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta). Ketika belasan proses submit task berjalan paralel, event bus jadi cara paling praktis untuk melacak status tiap task secara real-time tanpa membangun sistem logging terpisah dari nol — logger dan metrics listener tinggal didaftarkan, tanpa menyentuh kode submit() atau _poll() sama sekali.

Arsitektur Event Bus

[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]

Setiap listener mendaftar secara independen ke event yang relevan saja. Logger mendengarkan semua lima event untuk audit trail, Metrics hanya butuh submitted, solved, dan failed untuk menghitung success rate, sedangkan Retry Handler cukup mendengarkan failed. Menambahkan fitur baru — misalnya notifikasi Slack saat timeout — tidak memerlukan satu baris pun perubahan pada kode submit() atau _poll().

Membangun Kelas CaptchaBus di JavaScript

Kelas CaptchaBus di bawah ini meng-extend EventEmitter bawaan Node.js. submit() mengirim task ke in.php, membuat taskId lokal untuk pelacakan, lalu memanggil _poll() di background — setiap perubahan status diteruskan lewat this.emit(...), bukan lewat return value atau exception.

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;

Memasang Listener untuk Setiap Perubahan Status

Setelah kelas CaptchaBus siap, pasang listener sebanyak yang Anda butuhkan — satu untuk logging ke console, satu lagi untuk mengumpulkan metrics ke objek terpisah. Keduanya berjalan independen dan tidak saling tahu satu sama lain:

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

Versi Python: Pola Sama, Tanpa EventEmitter Bawaan

Node.js punya EventEmitter di modul inti, tapi Python tidak menyediakan setara langsung — jadi kelas CaptchaBus versi Python mengimplementasikan pola publish-subscribe sendiri lewat dict berisi daftar callback per event, plus threading.Thread untuk polling di background agar tidak memblokir thread utama:

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

Retry Otomatis Cukup dengan Satu Listener Tambahan

Karena failed sudah disiarkan lewat event bus, retry logic tidak perlu ditanam di dalam submit() — cukup daftarkan listener baru yang memanggil ulang bus.submit() dengan parameter task asli, dibatasi maksimum 3 percobaan agar tidak retry tanpa henti:

// 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,
  });
});

Bungkus Event Bus dengan Promise API

Kalau kode pemanggil Anda lebih nyaman dengan await daripada mendengarkan event satu per satu, bungkus CaptchaBus di dalam sebuah Promise — event bus tetap jadi mesin di baliknya, hanya API di permukaan yang berubah:

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

Masalah Umum dan Cara Mengatasinya

Berikut error yang paling sering muncul saat pertama kali mengintegrasikan event bus ini ke aplikasi produksi, beserta akar penyebab dan cara memperbaikinya:

Masalah Penyebab Solusi
Listener tidak pernah terpanggil Nama event salah ketik (mis. "solve" alih-alih "solved") Cocokkan ulang string event di emit() dan on() — JavaScript tidak memberi peringatan untuk nama event yang typo
Muncul peringatan memory leak di console Terlalu banyak listener terdaftar pada satu event yang sama Panggil setMaxListeners() untuk menaikkan batas, atau removeListener() setelah task selesai
Console dibanjiri event pending pollInterval diset terlalu pendek (misal 500 ms) Naikkan pollInterval ke 5000 ms atau lebih — solve time reCAPTCHA v2 dan Turnstile umumnya tidak di bawah beberapa detik
Status task hilang saat retry taskId baru dibuat setiap kali submit() dipanggil ulang Teruskan originalParams dari event failed supaya retry terhubung ke riwayat task yang sama
Listener terpanggil dua kali untuk task yang sama Listener didaftarkan ulang tanpa dibersihkan lebih dulu (umum di hot-reload dev server) Pastikan removeListener() dipanggil sebelum bus.on() yang baru, atau gunakan pola cleanup seperti pada promise wrapper

Pertanyaan Umum

Apakah event bus ini menambah beban ke thread CaptchaAI saya?

Tidak. CaptchaBus murni logika di sisi aplikasi Anda — emit() dan on() tidak mengirim request apa pun ke API CaptchaAI. Konsumsi thread tetap ditentukan oleh berapa banyak task submit() yang berjalan bersamaan sesuai paket Anda (mis. BASIC 5 thread, STANDARD 15 thread), bukan oleh jumlah listener yang Anda daftarkan.

Perlukah saya pakai message broker seperti Kafka atau RabbitMQ, bukan EventEmitter bawaan?

Untuk aplikasi single-process, event bus in-process seperti pada panduan ini lebih sederhana dan tidak menambah komponen infrastruktur baru. Pertimbangkan Kafka, RabbitMQ, atau Redis Pub/Sub hanya kalau ada beberapa proses atau service terpisah yang sama-sama perlu bereaksi terhadap event CAPTCHA yang sama.

Bisakah satu CaptchaBus menangani reCAPTCHA v2, Turnstile, dan GeeTest v3 sekaligus?

Ya. Parameter method pada submit() menentukan tipe CAPTCHA per task (userrecaptcha, turnstile, geetest, dan seterusnya), sementara event yang di-emit — submitted, pending, solved, failed — sama untuk semua tipe. Listener logger dan metrics Anda tidak perlu tahu tipe CAPTCHA apa yang sedang diproses.

Bagaimana cara menyimpan riwayat event untuk audit atau debugging?

Tambahkan satu listener yang menulis setiap event ke file JSONL atau database, tanpa mengubah logika submit()/_poll() sama sekali. Ini membuat jejak audit yang berguna saat Anda perlu menelusuri kenapa sebuah task gagal beberapa jam sebelumnya.

Apa yang terjadi kalau listener tidak dibersihkan setelah task selesai?

Listener yang menumpuk tanpa removeListener() bisa memicu peringatan MaxListenersExceededWarning pada volume task tinggi, dan berpotensi memanggil callback lama untuk task yang sudah tidak relevan. Untuk task berumur pendek, ikuti pola cleanup pada promise wrapper di atas — daftarkan listener, lalu lepas begitu event solved/failed/timeout diterima.

Artikel Terkait

Langkah Selanjutnya

Event bus ini adalah fondasi untuk pipeline CAPTCHA yang benar-benar event-driven — logger, metrics, dan retry handler tinggal ditambah sebagai listener baru kapan pun dibutuhkan, tanpa menyentuh kode solve yang sudah stabil. Ambil API key CaptchaAI Anda dan mulai pasang CaptchaBus di proyek Anda.

Komentar dinonaktifkan untuk artikel ini.