Cara Memecahkan CAPTCHA di Worker BullMQ (Node.js)

Worker captcha BullMQ berjalan dengan benar saat pertama kali Anda coba, lalu mengecewakan lewat dua cara yang diam-diam. Opsi worker yang mengatur berapa banyak job berjalan sekaligus punya nilai default 1, sehingga tumpukan pemecahan terkuras satu per satu. Dan jika processor sempat menyita event loop, BullMQ memutuskan bahwa job itu stalled, menyerahkannya ke worker lain, dan CAPTCHA yang sama dipecahkan dua kali. Keduanya bukan bug. Keduanya adalah nilai default, dan keduanya cukup diubah dengan satu baris.
Apa yang Anda butuhkan
- Redis, ditambah BullMQ yang terinstal di proyek yang menjalankan worker Anda.
- CapSkip yang berjalan di mesin Windows, dengan client Node di proyek yang sama.
- sitekey dan URL halaman yang datang lewat data job, bukan ditulis langsung di kode, sehingga satu queue melayani semua form.
- Mode koneksi yang sudah diputuskan sebelum Anda melakukan scale out, karena worker biasanya berakhir di lebih banyak mesin daripada solver.
# npm install capskip npm install bullmq capskip
Langkah 1: di mana worker berjalan, dan mode koneksi apa yang dibutuhkan
Perlu diputuskan lebih dulu, karena saran dari BullMQ sendiri adalah menjalankan sekumpulan worker di banyak mesin yang berbeda, dan begitu Anda melakukannya, loopback tidak lagi berarti seperti artinya di laptop Anda.
Ada dua mode koneksi. Mode Local mengikat ke 127.0.0.1 dan hanya melayani perangkat itu, dan itu memang tepat ketika otomatisasi dan solver Anda berada di satu mesin. Mode Server mengikat ke alamat jaringan atau IP publik Anda, sehingga mesin lain, sebuah VPS, atau platform hosting bisa menjangkau mesin Windows yang sama lewat API. Mode Server hanya mengubah alamat mana yang didengarkan solver. Perangkat kerasnya tetap milik Anda dan tetap tanpa kuota. Kedua mode itu ada di pengaturan koneksi.
| Di mana proses worker berjalan | Mode koneksi yang mana |
|---|---|
| Di mesin CapSkip, satu proses | Mode Local. 127.0.0.1 memang benar |
| Sekumpulan worker di jaringan Anda sendiri | Pakai mode Server dengan alamat LAN pemecah CAPTCHA |
| Worker di dalam container, atau di VPS | Pakai mode Server dengan alamat yang bisa dijangkau dan satu aturan firewall |
Untuk worker yang berjalan di VPS, IP publik statis layak disiapkan supaya alamatnya tidak berpindah di bawah deployment yang sedang berjalan. Simpan alamat itu di environment variable, bukan di source code, karena laptop Anda dan worker Anda menginginkan nilai yang berbeda. Client tidak membaca environment variable apa pun dengan sendirinya, jadi baca CAPSKIP_HOST di kode Anda lalu oper nilainya ke client, seperti yang dilakukan worker di bawah.
// npm install capskip
import { CapSkip } from "capskip";
// One client, shared by every job this worker handles.
// CAPSKIP_HOST is 127.0.0.1 locally and the solver's
// address on every other machine.
export const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
});Langkah 2: default concurrency adalah satu job dalam satu waktu
Worker BullMQ memproses satu job dalam satu waktu kecuali Anda menyuruhnya lain. Default itu masuk akal untuk pekerjaan CPU dan sangat keliru untuk pemecahan CAPTCHA, karena satu pemecahan hampir seluruhnya hanya menunggu. BullMQ menyatakannya secara langsung: concurrency hanya mungkin ketika worker melakukan operasi asinkron seperti panggilan ke database atau ke layanan HTTP eksternal. Melakukan polling ke solver lewat HTTP persis berbentuk seperti itu, jadi event loop tetap bebas sepanjang waktu.
Hitungannya layak dilakukan sekali. Jika satu pemecahan reCAPTCHA memakan dua puluh detik, satu worker pada nilai default menghabiskan tiga job per menit. Worker yang sama dengan concurrency dua puluh menghabiskan enam puluh, di perangkat keras yang sama, karena sembilan belas dari job itu sedang duduk menunggu pembacaan socket, bukan berebut CPU.
import { Worker } from "bullmq";
import { solver } from "./solver.js";
// A solve is an awaited HTTP call, so raising this costs
// almost no CPU. Size it for the solver machine and for
// what the target site will accept.
const worker = new Worker("captcha", async (job) => {
const { sitekey, pageUrl } = job.data;
const result = await solver.recaptcha(sitekey, pageUrl);
return await submitForm(pageUrl, result.code);
}, { connection: { host: "127.0.0.1", port: 6379 }, concurrency: 20 });Pada solver yang menagih per pemecahan, angka itu sebenarnya adalah pengendali pengeluaran, dan itulah sebabnya begitu banyak contoh menyetelnya ke angka yang penakut. Di sini angka itu adalah soal kapasitas satu mesin, dan soal secepat apa situs yang Anda kirimi mau menerima request. Yang kedua biasanya batas yang lebih ketat.
Langkah 3: apa yang membuat sebuah job stalled, dan kenapa stalled berarti memecahkan dua kali
Ini kegagalan yang menghabiskan satu pagi penuh, karena log terlihat seperti queue bekerja normal sementara log milik solver sendiri mengatakan sebaliknya.
Ketika sebuah job sampai ke worker, BullMQ memasang lock padanya supaya tidak ada hal lain yang bisa menyentuhnya, dan worker harus terus memberi tahu queue bahwa ia masih bekerja. Lock itu adalah lockDuration, defaultnya 30000 ms, dan worker memperbaruinya pada setengah interval tersebut. Ada penyapuan terpisah, stalledInterval, yang berjalan setiap 30000 ms untuk mencari lock yang tidak diperbarui siapa pun. Jika worker terlalu sibuk untuk memperbarui tepat waktu, job ditandai stalled, dikembalikan ke status waiting, dan diproses lagi oleh worker lain. Begitu job itu stalled lebih sering daripada yang diizinkan maxStalledCount, yang nilai defaultnya satu, ia justru masuk ke failed set.
Jadi job yang stalled bukan job yang gagal, dan pengulangannya bukan retry. Itu adalah queue yang dengan benar menganggap worker pemegang job tersebut sudah mati. Solver Anda melihat dua pengiriman untuk satu CAPTCHA, dan hanya token kedua yang akhirnya dipakai.
| Pengaturan worker | Bawaan | Apa yang ditentukannya |
|---|---|---|
| "concurrency" | 1 | Berapa banyak job yang ditangani satu worker sekaligus |
| "lockDuration" | 30000 ms | Berapa lama lock bertahan tanpa diperbarui |
| "lockRenewTime" | setengah dari lockDuration | Seberapa sering worker memperbaruinya |
| "stalledInterval" | 30000 ms | Seberapa sering lock yang tidak diperbarui disapu bersih |
| "maxStalledCount" | 1 | Berapa kali pengulangan sebelum job dinyatakan gagal |
Penyebabnya selalu sama: processor menahan CPU. Panggilan HTTP yang di-await tidak menahannya, jadi polling bawaan client aman. Loop buatan sendiri yang busy-wait ke endpoint hasil tidak aman, begitu juga dekode gambar besar secara sinkron sebelum Anda mengirimkannya. Biarkan client yang melakukan polling, karena ia mulai dari 250 ms lalu melambat dengan sendirinya, bukan tidur pada interval yang datar. Dan pindahkan langkah apa pun yang benar-benar CPU-bound keluar dari processor, atau ke processor yang di-sandbox.
Menaikkan durasi lock adalah langkah pertama yang keliru. Itu hanya mengobati gejalanya, dan lock yang panjang pada worker yang benar-benar sudah mati membuat job itu tidak tersentuh selama seluruh jendela waktu tersebut.
Langkah 4: retry, dan token yang tidak boleh Anda simpan
BullMQ tidak melakukan retry secara default. Opsi attempts bernilai 1, yang berarti satu kali percobaan lalu masuk ke failed set. Itu biasanya tepat untuk sebuah pemecahan, karena sebagian besar kegagalan di sini berupa kesalahan parameter yang akan gagal dengan cara yang sama selamanya, atau situs yang sudah berubah. Retry baru berguna ketika worker sesaat tidak bisa menjangkau solver, jadi beri mereka backoff dan jaga jumlahnya tetap kecil.
await queue.add("signup", { sitekey, pageUrl }, {
// Two tries, spaced out, for a solver that was
// briefly unreachable. Not for a bad sitekey.
attempts: 2,
backoff: { type: "exponential", delay: 5000 },
// Do not leave finished jobs sitting in Redis forever.
removeOnComplete: { age: 3600, count: 1000 },
});Ada dua aturan yang menyertainya. Jangan pernah membawa token melintasi percobaan: token reCAPTCHA hanya berlaku sekitar dua menit, jadi lakukan pemecahan di dalam percobaan yang mengirimkannya, dan baca panduan kedaluwarsa token jika Anda tergoda menyimpannya di cache. Dan jangan mengembalikan token sebagai nilai kembalian job. BullMQ menyimpan job yang sudah selesai secara default, jadi nilai kembalian itu mendarat di Redis dan tinggal di sana. Kembalikan hasil pengirimannya saja, karena itulah yang benar-benar ingin Anda lihat nanti.
Contoh lengkap yang berfungsi
Satu producer, satu worker, dan pemecahan CAPTCHA yang berada di processor yang sama dengan hal yang memakainya.
// npm install capskip
import { Queue, Worker } from "bullmq";
import { CapSkip, ValidationException } from "capskip";
const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("captcha", { connection });
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
});
new Worker("captcha", async (job) => {
const { sitekey, pageUrl, email } = job.data;
try {
// Solve and submit together. The token is short lived.
const result = await solver.recaptcha(sitekey, pageUrl);
const res = await postSignup(pageUrl, email, result.code);
return { status: res.status };
} catch (err) {
// A bad sitekey fails the same way on every attempt.
if (err instanceof ValidationException) {
await job.discard();
}
throw err;
}
}, { connection, concurrency: 20 });Pemanggilan itu adalah reCAPTCHA v2. Jenis lainnya berbentuk sama: oper invisible atau enterprise disetel ke 1, atau version disetel ke v3 dengan sebuah action, atau panggil turnstile atau geetest sebagai gantinya. Daftar lengkapnya ada di halaman pemecah CAPTCHA Node.js.
Kesalahan umum dan artinya
| Apa yang Anda lihat | Penyebab | Perbaiki |
|---|---|---|
| Queue terkuras jauh lebih lambat daripada kemampuan solver | Concurrency worker masih pada nilai defaultnya, yaitu satu | Naikkan. Pemecahan CAPTCHA adalah I/O yang di-await, bukan CPU |
| Dua pemecahan tercatat untuk satu job, berselang beberapa detik | Lock tidak diperbarui, jadi job dihitung stalled | Berhenti memblokir event loop di dalam processor |
| Job mendarat di failed set tanpa ada error yang dilempar | Job itu stalled lebih sering daripada batas maksimum yang diizinkan | Sama-sama pekerjaan yang memblokir. Perbaiki itu, bukan penghitungnya |
| Jalan di laptop Anda, NetworkException di mesin worker | 127.0.0.1 di mesin itu ya mesin itu sendiri | Pakai mode Server, dan setel CAPSKIP_HOST untuk worker |
| Token ditolak hanya pada percobaan kedua | Token dari percobaan pertama ikut terbawa | Lakukan pemecahan di dalam percobaan yang mengirim |
| ERROR_WRONG_USER_KEY di dalam ApiException | CAPSKIP_API_KEY belum disetel di environment worker | Setel variabel itu di tempat worker berjalan, lalu restart worker itu |
| CAPCHA_NOT_READY dari polling buatan sendiri | Hasilnya dibaca sebelum selesai | Biarkan client yang melakukan polling. Ia melambat dengan sendirinya |
Respons terakhir itu memang ditulis persis seperti kelihatannya, dan huruf yang hilang bukan salah ketik dari pihak kami, karena API memang benar-benar mengembalikannya seperti itu. Penjelasan lengkapnya ada di panduan CAPCHA_NOT_READY.
FAQ
Bisakah worker saya berjalan di mesin yang berbeda dari solver?
Bisa, dan itu justru penyiapan yang normal begitu Anda melewati satu proses. Ubah CapSkip ke mode Server di pengaturan koneksi supaya ia mendengarkan alamat jaringan Anda, bukan loopback, lalu setel CAPSKIP_HOST di environment setiap worker. Di jaringan Anda sendiri, alamat itu adalah alamat LAN dan tidak ada yang perlu menghadap ke internet. Jika sebuah worker berada di VPS, gunakan IP publik statis dengan aturan firewall yang hanya mengizinkan alamat yang Anda harapkan.
Berapa sebenarnya concurrency yang harus saya setel?
Mulai dari sepuluh lalu perhatikan dua hal: mesin solver, dan bagaimana situs yang Anda kirimi merespons. Karena satu pemecahan adalah I/O yang di-await, proses worker itu sendiri jarang menjadi batasnya. Situsnya yang biasanya jadi batas, dan ia akan memberi tahu Anda lewat rate limiting jauh sebelum Node kehabisan ruang. Tidak ada apa pun di sini yang mengantre di belakang saldo, jadi angka itu adalah keputusan kapasitas, bukan keputusan anggaran.
Kenapa CAPTCHA yang sama dipecahkan dua kali padahal saya tidak pernah menyetel attempts?
Karena pengulangan itu bukan retry. Job yang stalled dimasukkan kembali ke queue terlepas dari opsi attempts, dengan asumsi worker yang memegangnya sudah mati. Pemicunya adalah lock yang tidak diperbarui tepat waktu, yang terjadi ketika processor menahan CPU alih-alih menunggu lewat await. Temukan pekerjaan sinkron di dalam processor lalu pindahkan, dan duplikatnya akan hilang.
Haruskah pemecahan CAPTCHA punya queue sendiri yang dipanggil job lain?
Biasanya tidak. Memisahkannya berarti token melintasi batas queue dan mengendap di Redis sementara job induk berjalan lagi, dan itu cara tercepat untuk memakai token yang sudah kedaluwarsa. Simpan pemecahan dan apa pun yang memakainya di processor yang sama, lalu kembalikan hasilnya, bukan kredensialnya. Queue terpisah hanya masuk akal kalau yang ia serahkan balik sama sekali bukan token.
Versi singkatnya
Naikkan concurrency worker, karena default satu job dalam satu waktu mencekik queue berisi panggilan HTTP yang di-await tanpa alasan. Jauhkan processor dari CPU supaya lock terus diperbarui, karena job yang stalled akan dijalankan ulang oleh worker lain dan Anda membayarnya dengan throughput yang terbuang. Biarkan attempts tetap rendah dan jangan pernah membawa token melintasi satu percobaan pun. Simpan alamatnya di CAPSKIP_HOST dan ubah solver ke mode Server begitu ada worker yang tinggal di tempat lain.
- Endpoint mentah di balik klien didokumentasikan di dokumentasi API CapSkip.
- Tantangan kotak centangnya sendiri dijelaskan di halaman pemecah reCAPTCHA v2.
Perlu ditimbang sebelum Anda memilih angka concurrency itu: CapSkip adalah pemecah captcha tanpa batas yang berjalan di perangkat keras yang sudah Anda miliki, jadi menaikkannya hanya memakai kapasitas satu mesin dan tidak menambah biaya per pemecahan.
