Tutorials

Membangun Antrian Pemecahan CAPTCHA di Node.js

Kirim 50 task reCAPTCHA lewat Promise.all naif, dan yang terjadi bukan penyelesaian lebih cepat — melainkan ERROR_NO_SLOT_AVAILABLE bertubi-tubi karena semua request menabrak jatah thread CaptchaAI Anda dalam hitungan detik. Artikel ini membahas lima pola antrean Node.js, dari Promise.allSettled sederhana sampai sistem job production-grade dengan retry dan dead-letter.

Pilih pola sesuai skala task:

  • Promise.allSettled — batch kecil, uji coba awal.
  • ConcurrencyQueue — task tembus ratusan, butuh kontrol thread.
  • CaptchaQueue (EventEmitter) — perlu progress real-time.
  • PriorityCaptchaQueue — task tidak sama penting, checkout vs scraping.
  • RetryQueue — keandalan production, dead-letter untuk task gagal permanen.

Batch Sederhana dengan Promise.allSettled

Untuk task dalam jumlah kecil — belasan sampai beberapa puluh — pola paling mudah dipelihara adalah Promise.allSettled. Setiap CAPTCHA diselesaikan lewat solveSingle(), yang mengirim task ke in.php, lalu polling res.php setiap 5 detik sampai statusnya 1 (selesai) atau muncul ERROR_CAPTCHA_UNSOLVABLE. Karena allSettled menunggu semua promise beres — baik yang berhasil maupun gagal — Anda tidak kehilangan hasil task lain hanya karena satu CAPTCHA timeout.

const API_KEY = "YOUR_API_KEY";

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function solveSingle(method, params) {
  const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
    method: "POST",
    body: new URLSearchParams({ key: API_KEY, method, json: "1", ...params }),
  });
  const submitData = await submitResp.json();
  if (submitData.status !== 1) throw new Error(submitData.request);
  const taskId = submitData.request;

  for (let i = 0; i < 30; i++) {
    await sleep(5000);
    const pollResp = await fetch(
      `https://ocr.captchaai.com/res.php?${new URLSearchParams({
        key: API_KEY,
        action: "get",
        id: taskId,
        json: "1",
      })}`
    );
    const data = await pollResp.json();
    if (data.status === 1) return data.request;
    if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
  }
  throw new Error("Timed out");
}

// Solve all at once
async function solveBatch(tasks) {
  const results = await Promise.allSettled(
    tasks.map((task) => solveSingle(task.method, task.params))
  );

  return results.map((result, i) => ({
    taskId: tasks[i].id,
    status: result.status,
    value: result.status === "fulfilled" ? result.value : null,
    error: result.status === "rejected" ? result.reason.message : null,
  }));
}

// Usage
const tasks = Array.from({ length: 10 }, (_, i) => ({
  id: i,
  method: "userrecaptcha",
  params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));

const results = await solveBatch(tasks);
console.log(`Solved: ${results.filter((r) => r.status === "fulfilled").length}/10`);

Pola ini cukup untuk uji coba awal atau volume rendah. Masalah baru muncul saat jumlah task naik ke ratusan: seluruh request ditembakkan bersamaan, dan CaptchaAI membalas ERROR_NO_SLOT_AVAILABLE begitu jumlah thread aktif melebihi jatah paket Anda.


Antrean dengan Batas Concurrency

ConcurrencyQueue menyelesaikan masalah di atas dengan mengontrol berapa banyak task yang berjalan bersamaan lewat maxConcurrent. Task baru menunggu di this.queue sampai ada slot kosong; begitu satu solve selesai — berhasil maupun gagal — #process() otomatis menarik task berikutnya. Ini sekaligus jadi mekanisme backpressure alami: antrean tidak pernah membanjiri CaptchaAI dengan lebih banyak request daripada yang bisa ditangani jatah thread Anda.

class ConcurrencyQueue {
  constructor(maxConcurrent = 5) {
    this.maxConcurrent = maxConcurrent;
    this.running = 0;
    this.queue = [];
    this.results = [];
  }

  add(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      this.#process();
    });
  }

  async #process() {
    if (this.running >= this.maxConcurrent || this.queue.length === 0) return;

    this.running++;
    const { fn, resolve, reject } = this.queue.shift();

    try {
      const result = await fn();
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.running--;
      this.#process();
    }
  }

  async addBatch(fns) {
    return Promise.allSettled(fns.map((fn) => this.add(fn)));
  }
}

// Usage
const queue = new ConcurrencyQueue(5);

const tasks = Array.from({ length: 20 }, (_, i) => () =>
  solveSingle("userrecaptcha", {
    googlekey: `KEY_${i}`,
    pageurl: `https://example.com/${i}`,
  })
);

const results = await queue.addBatch(tasks);
const solved = results.filter((r) => r.status === "fulfilled");
console.log(`Solved: ${solved.length}/${results.length}`);

Contoh nyata: agensi price-monitoring di Jakarta yang memantau ratusan halaman produk e-commerce tiap malam biasanya menyamakan maxConcurrent dengan jatah thread paket CaptchaAI mereka — 5 thread untuk paket BASIC ($15/bulan), naik ke 50 thread begitu pindah ke ADVANCE ($90/bulan). Karena setiap paket menyertakan solve tak terbatas per thread, menaikkan maxConcurrent sampai batas thread tidak menambah tagihan bulanan — yang berubah hanya throughput.


Antrean Berbasis EventEmitter untuk Progress Real-Time

Kalau Anda perlu menampilkan progress ke dashboard internal atau log terstruktur — bukan sekadar menunggu Promise selesai — EventEmitter bawaan Node.js pas dipakai. CaptchaQueue memancarkan event submitted, solved, failed, dan complete, masing-masing membawa stats terkini. Anda tinggal pasang .on() di listener mana pun tanpa mengubah logika solve-nya.

const { EventEmitter } = require("events");

class CaptchaQueue extends EventEmitter {
  #apiKey;
  #maxConcurrent;
  #pending;
  #active;

  constructor(apiKey, maxConcurrent = 5) {
    super();
    this.#apiKey = apiKey;
    this.#maxConcurrent = maxConcurrent;
    this.#pending = [];
    this.#active = 0;
    this.stats = { submitted: 0, solved: 0, failed: 0 };
  }

  submit(id, method, params) {
    this.#pending.push({ id, method, params });
    this.stats.submitted++;
    this.emit("submitted", { id, total: this.stats.submitted });
    this.#drain();
  }

  async #drain() {
    while (this.#active < this.#maxConcurrent && this.#pending.length > 0) {
      const task = this.#pending.shift();
      this.#active++;
      this.#solve(task).finally(() => {
        this.#active--;
        this.#drain();
        if (this.#active === 0 && this.#pending.length === 0) {
          this.emit("complete", this.stats);
        }
      });
    }
  }

  async #solve(task) {
    try {
      const token = await solveSingle(task.method, task.params);
      this.stats.solved++;
      this.emit("solved", { id: task.id, token, stats: { ...this.stats } });
    } catch (error) {
      this.stats.failed++;
      this.emit("failed", { id: task.id, error: error.message, stats: { ...this.stats } });
    }
  }
}

// Usage
const queue = new CaptchaQueue("YOUR_API_KEY", 5);

queue.on("submitted", ({ id, total }) => {
  console.log(`Submitted #${id} (total: ${total})`);
});

queue.on("solved", ({ id, stats }) => {
  console.log(`Solved #${id} — ${stats.solved}/${stats.submitted}`);
});

queue.on("failed", ({ id, error }) => {
  console.log(`Failed #${id}: ${error}`);
});

queue.on("complete", (stats) => {
  const rate = ((stats.solved / stats.submitted) * 100).toFixed(1);
  console.log(`Done: ${stats.solved}/${stats.submitted} (${rate}%)`);
});

// Submit tasks
for (let i = 0; i < 15; i++) {
  queue.submit(i, "userrecaptcha", {
    googlekey: `KEY_${i}`,
    pageurl: `https://example.com/${i}`,
  });
}

Pola ini enak dipisah ke worker terpisah: satu proses mengurus antrean dan solve, proses lain — atau dashboard — cukup mendengarkan event tanpa polling status secara manual.


Antrean Prioritas: Checkout Didahulukan, Scraping Menyusul

Tidak semua task CAPTCHA sama pentingnya. Task checkout yang menahan transaksi pelanggan jelas lebih mendesak dibanding job scraping katalog yang bisa selesai kapan saja. PriorityCaptchaQueue menyusun task lewat angka prioritas — makin kecil angkanya, makin dulu dieksekusi begitu ada slot maxConcurrent kosong.

class PriorityQueue {
  #items = [];

  enqueue(item, priority) {
    this.#items.push({ item, priority });
    this.#items.sort((a, b) => a.priority - b.priority);
  }

  dequeue() {
    return this.#items.shift()?.item;
  }

  get length() {
    return this.#items.length;
  }
}

class PriorityCaptchaQueue {
  #apiKey;
  #maxConcurrent;
  #queue;
  #active;
  #results;

  constructor(apiKey, maxConcurrent = 5) {
    this.#apiKey = apiKey;
    this.#maxConcurrent = maxConcurrent;
    this.#queue = new PriorityQueue();
    this.#active = 0;
    this.#results = new Map();
  }

  submit(id, method, params, priority = 5) {
    return new Promise((resolve, reject) => {
      this.#queue.enqueue({ id, method, params, resolve, reject }, priority);
      this.#drain();
    });
  }

  async #drain() {
    while (this.#active < this.#maxConcurrent && this.#queue.length > 0) {
      const task = this.#queue.dequeue();
      this.#active++;

      solveSingle(task.method, task.params)
        .then((token) => {
          this.#results.set(task.id, { status: "solved", token });
          task.resolve(token);
        })
        .catch((err) => {
          this.#results.set(task.id, { status: "error", error: err.message });
          task.reject(err);
        })
        .finally(() => {
          this.#active--;
          this.#drain();
        });
    }
  }
}

// Usage: high-priority checkout, low-priority scraping
const pq = new PriorityCaptchaQueue("YOUR_API_KEY", 3);

// Priority 1 (highest) — checkout
const checkoutToken = pq.submit(
  "checkout_1",
  "turnstile",
  { sitekey: "KEY", pageurl: "https://shop.com/checkout" },
  1
);

// Priority 5 (normal) — product scraping
for (let i = 0; i < 5; i++) {
  pq.submit(
    `product_${i}`,
    "userrecaptcha",
    { googlekey: "KEY", pageurl: `https://shop.com/p/${i}` },
    5
  );
}

Pada contoh di atas, task checkout_1 bertipe turnstile mendapat prioritas 1 (tertinggi), sementara lima task scraping produk berjalan di prioritas 5 (normal). Kalau kedua jenis task berebut thread yang sama, checkout selalu menang antre.


Retry dan Dead-Letter Supaya Task Gagal Tidak Menumpuk

Task CAPTCHA gagal tidak selalu berarti ada yang salah dengan kode Anda — sitekey berubah, halaman lambat merender, atau koneksi sempat putus. RetryQueue mencoba ulang task yang gagal sampai maxRetries, lalu memindahkan task yang tetap gagal ke #deadLetter supaya tidak menyumbat antrean utama selamanya.

class RetryQueue {
  #apiKey;
  #maxRetries;
  #results;
  #deadLetter;

  constructor(apiKey, maxRetries = 3) {
    this.#apiKey = apiKey;
    this.#maxRetries = maxRetries;
    this.#results = [];
    this.#deadLetter = [];
  }

  async processBatch(tasks, maxConcurrent = 5) {
    const queue = tasks.map((t) => ({ ...t, attempts: 0 }));

    while (queue.length > 0) {
      const batch = queue.splice(0, maxConcurrent);
      const results = await Promise.allSettled(
        batch.map((task) => this.#solveWithRetry(task))
      );

      for (let i = 0; i < results.length; i++) {
        const result = results[i];
        const task = batch[i];

        if (result.status === "fulfilled") {
          this.#results.push({ id: task.id, token: result.value });
        } else {
          task.attempts++;
          if (task.attempts < this.#maxRetries) {
            queue.push(task); // Retry
            console.log(`Retry ${task.attempts}/${this.#maxRetries}: ${task.id}`);
          } else {
            this.#deadLetter.push({
              id: task.id,
              error: result.reason.message,
              attempts: task.attempts,
            });
          }
        }
      }
    }

    return {
      solved: this.#results,
      failed: this.#deadLetter,
    };
  }

  async #solveWithRetry(task) {
    return solveSingle(task.method, task.params);
  }
}

Kabar baiknya: karena semua paket CaptchaAI menagih per thread aktif per bulan, bukan per percobaan solve, retry yang wajar tidak menambah biaya bulanan Anda. Yang perlu dijaga hanyalah maxRetries — supaya task yang memang tidak bisa diselesaikan tidak berputar tanpa henti dan menahan thread yang seharusnya dipakai task lain.


Dashboard Pemantauan Antrean

QueueMonitor merekam angka dasar yang biasa ditanyakan tim ops: berapa task yang sedang berjalan, rata-rata waktu penyelesaian, throughput per menit, dan tingkat keberhasilan. Panggil recordSubmit(), recordSolved(), recordFailed() di titik yang sesuai, lalu report() kapan saja untuk snapshot kondisi antrean.

class QueueMonitor {
  #startTime;
  #solveTimes;

  constructor() {
    this.#startTime = Date.now();
    this.#solveTimes = [];
    this.counts = { submitted: 0, solving: 0, solved: 0, failed: 0 };
  }

  recordSubmit() {
    this.counts.submitted++;
    this.counts.solving++;
  }

  recordSolved(solveTime) {
    this.counts.solving--;
    this.counts.solved++;
    this.#solveTimes.push(solveTime);
  }

  recordFailed() {
    this.counts.solving--;
    this.counts.failed++;
  }

  report() {
    const elapsed = (Date.now() - this.#startTime) / 1000;
    const avgTime =
      this.#solveTimes.length > 0
        ? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length
        : 0;
    const throughput = this.counts.solved / (elapsed / 60);
    const successRate =
      this.counts.solved + this.counts.failed > 0
        ? (this.counts.solved / (this.counts.solved + this.counts.failed)) * 100
        : 0;

    return {
      elapsed: `${elapsed.toFixed(0)}s`,
      submitted: this.counts.submitted,
      solving: this.counts.solving,
      solved: this.counts.solved,
      failed: this.counts.failed,
      avgSolveTime: `${(avgTime / 1000).toFixed(1)}s`,
      throughput: `${throughput.toFixed(1)}/min`,
      successRate: `${successRate.toFixed(1)}%`,
    };
  }
}

Beberapa sinyal dari report() yang menandakan saatnya naik paket:

  • Throughput mentok padahal tingkat keberhasilan masih tinggi → maxConcurrent sudah menyentuh batas thread, bukan batas kemampuan solve.
  • ERROR_NO_SLOT_AVAILABLE sering muncul di log → saatnya menaikkan paket, bukan menambah retry.
  • Dead-letter queue terus bertambah walau avgSolveTime normal → cek parameter task, bukan soal kapasitas thread.

Masalah Umum dan Cara Mengatasinya

Kesalahan yang paling sering muncul setelah antrean berjalan di production:

Gejala Penyebab Solusi
Semua promise ditolak bersamaan Rate limit API tercapai Turunkan maxConcurrent
Memory terus bertambah Hasil terakumulasi Proses dan hapus hasil secara berkala
Antrean terkuras tapi task tertinggal Panggilan drain() tidak ada setelah selesai Periksa pemicu drain di blok finally
ERROR_NO_SLOT_AVAILABLE Terlalu banyak panggilan API concurrent Tambahkan jeda antar pengiriman
Dead-letter queue terisi Error berulang Periksa tipe error — mungkin perlu perbaikan parameter
Waktu solve melonjak di jaringan seluler Kondisi mobile-first (umum di banyak lokasi Indonesia) menambah latensi request Naikkan timeout polling dan maxRetries sedikit, jangan turunkan maxConcurrent

Pertanyaan yang Sering Diajukan

Kapan sebaiknya pakai antrean prioritas, bukan concurrency queue biasa?

Kalau semua task CAPTCHA sama pentingnya — misalnya sama-sama job scraping — concurrency queue biasa sudah cukup. Pakai priority queue begitu ada task yang menahan pengalaman pengguna langsung, seperti checkout atau login, yang harus selesai lebih dulu dibanding job scraping latar belakang.

Apakah retry otomatis membuat saya membayar CaptchaAI dua kali?

Tidak. Semua paket CaptchaAI menagih berdasarkan jumlah thread aktif per bulan dengan solve tak terbatas per thread, bukan per percobaan. Retry yang wajar hanya memakai waktu thread sedikit lebih lama, bukan biaya tambahan — asal maxRetries dibatasi supaya task yang gagal permanen tidak menahan thread selamanya.

Berapa banyak thread aman dijalankan bersamaan lewat maxConcurrent?

Samakan dengan jatah thread paket Anda — 5 untuk BASIC, 15 untuk STANDARD, 50 untuk ADVANCE, dan seterusnya sesuai paket yang dipakai. Kalau ERROR_NO_SLOT_AVAILABLE sering muncul, itu sinyal maxConcurrent sudah melewati jatah thread, bukan berarti ada yang salah di aplikasi Anda.

Perlu pakai library seperti p-queue atau BullMQ, atau cukup kelas custom di atas?

Untuk satu proses dengan volume task menengah, pola bawaan di atas — ConcurrencyQueue, EventEmitter, atau PriorityQueue — sudah cukup dan lebih mudah dipahami tim kecil. Pindah ke bull/bullmq begitu Anda butuh antrean persisten berbasis Redis yang bertahan lewat restart atau dipakai bersama beberapa server.

Apakah dashboard QueueMonitor wajib dipasang di production?

Tidak wajib, tapi sangat disarankan begitu volume task naik. Tanpa angka throughput dan tingkat keberhasilan yang terekam, sulit membedakan antrean yang lambat karena jatah thread habis dari antrean yang lambat karena ada bug di kode solve Anda.


Kesimpulan: Pilih Pola Sesuai Skala Task

Node.js unggul di I/O concurrency, jadi lima pola di atas jarang perlu diganti total — biasanya Anda naik satu tingkat begitu volume task bertambah: Promise.allSettled untuk uji coba, ConcurrencyQueue begitu task tembus ratusan, EventEmitter saat butuh visibilitas real-time, priority queue begitu ada task yang harus didahulukan, dan retry queue begitu keandalan production jadi prioritas. Semuanya jalan di atas API CaptchaAI yang sama — tinggal pilih lapisan orkestrasi yang sesuai skala Anda.

Panduan Terkait

Komentar dinonaktifkan untuk artikel ini.