Tempat yang tepat untuk menangani CAPTCHA di Scrapy adalah downloader middleware, bukan di dalam parse(). Satu kelas middleware mencegat setiap respons, mendeteksi sitekey, mengirim task ke CaptchaAI, lalu menembak ulang request yang sama dengan token — dan seluruh spider Anda tetap bersih dari logika CAPTCHA.
Artikel ini memberi empat potong kode yang bisa langsung dipakai: middleware pengunduh lengkap dengan statistik dan batas coba ulang, settings.py yang sudah disesuaikan, spider contoh yang tidak tahu-menahu soal CAPTCHA, serta spider middleware untuk meneruskan token ke callback berikutnya. Di sela-selanya ada catatan operasional: konkurensi versus jumlah thread paket, batas waktu, dan pola kesalahan yang paling sering muncul.
Kenapa logika CAPTCHA sebaiknya keluar dari spider
Banyak project scraping memulai dengan cara termudah: satu blok pemeriksaan HTML di dalam callback. Cara itu jalan untuk satu spider, lalu berantakan begitu ada lima spider yang dikerjakan tiga orang berbeda.
| Pendekatan | Lokasi kode CAPTCHA | Yang terjadi saat spider bertambah |
|---|---|---|
| Cek manual di tiap callback | Tersebar di semua parse() |
Logika coba ulang diduplikasi, statistik tidak konsisten |
| Downloader middleware | Satu kelas terpusat | Spider baru ikut terlindungi tanpa perubahan kode |
Middleware juga menjaga pemisahan tanggung jawab antara "mengambil halaman" dan "mengurai halaman" tetap utuh.
Downloader middleware CaptchaAI untuk Scrapy
Kelas berikut memindai setiap HtmlResponse dengan dua pola regex — satu untuk reCAPTCHA, satu untuk Turnstile — lalu menjalankan alur baku CaptchaAI: kirim ke in.php, simpan task ID, polling res.php, pakai token.
# middlewares.py
import re
import time
import logging
import requests as http_requests
from scrapy import signals
from scrapy.http import HtmlResponse
logger = logging.getLogger(__name__)
class CaptchaAIMiddleware:
"""Scrapy downloader middleware for automatic CAPTCHA solving."""
CAPTCHA_PATTERNS = [
(r'data-sitekey="([^"]+)"', "recaptcha"),
(r"cf-turnstile.*?data-sitekey=\"([^\"]+)\"", "turnstile"),
]
def __init__(self, api_key, max_retries=2):
self.api_key = api_key
self.max_retries = max_retries
self.stats = {"detected": 0, "solved": 0, "failed": 0}
@classmethod
def from_crawler(cls, crawler):
api_key = crawler.settings.get("CAPTCHAAI_API_KEY")
if not api_key:
raise ValueError("CAPTCHAAI_API_KEY setting is required")
middleware = cls(
api_key=api_key,
max_retries=crawler.settings.getint("CAPTCHAAI_MAX_RETRIES", 2),
)
crawler.signals.connect(middleware.spider_closed, signal=signals.spider_closed)
return middleware
def process_response(self, request, response, spider):
"""Check response for CAPTCHA and solve if found."""
if not isinstance(response, HtmlResponse):
return response
body = response.text
for pattern, captcha_type in self.CAPTCHA_PATTERNS:
match = re.search(pattern, body)
if match:
sitekey = match.group(1)
self.stats["detected"] += 1
logger.info(
f"CAPTCHA ({captcha_type}) on {response.url}, solving..."
)
retries = request.meta.get("captcha_retries", 0)
if retries >= self.max_retries:
self.stats["failed"] += 1
logger.error(f"Max CAPTCHA retries on {response.url}")
return response
token = self._solve(captcha_type, sitekey, response.url)
if token:
self.stats["solved"] += 1
# Re-request with token
new_request = request.copy()
new_request.meta["captcha_retries"] = retries + 1
new_request.meta["captcha_token"] = token
new_request.method = "POST"
new_request.body = f"g-recaptcha-response={token}"
new_request.headers[b"Content-Type"] = b"application/x-www-form-urlencoded"
new_request.dont_filter = True
return new_request
else:
self.stats["failed"] += 1
return response
def _solve(self, captcha_type, sitekey, pageurl):
"""Solve CAPTCHA via CaptchaAI."""
method_map = {
"recaptcha": {"method": "userrecaptcha", "googlekey": sitekey},
"turnstile": {"method": "turnstile", "sitekey": sitekey},
}
params = method_map.get(captcha_type)
if not params:
return None
try:
resp = http_requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"pageurl": pageurl,
"json": 1,
**params,
}, timeout=30)
result = resp.json()
if result.get("status") != 1:
logger.error(f"Submit error: {result.get('request')}")
return None
task_id = result["request"]
time.sleep(10)
for _ in range(24):
resp = http_requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key, "action": "get",
"id": task_id, "json": 1,
}, timeout=15)
data = resp.json()
if data.get("status") == 1:
return data["request"]
if data["request"] != "CAPCHA_NOT_READY":
return None
time.sleep(5)
except Exception as e:
logger.error(f"Solve exception: {e}")
return None
def spider_closed(self, spider):
"""Log CAPTCHA statistics on spider close."""
logger.info(
f"CAPTCHA Stats — Detected: {self.stats['detected']}, "
f"Solved: {self.stats['solved']}, "
f"Failed: {self.stats['failed']}"
)
Beberapa detail yang layak diperhatikan sebelum kode ini masuk ke project Anda:
- Batas coba ulang disimpan di
request.meta, bukan di atribut kelas. Dengan begitu setiap request punya penghitungnya sendiri dan crawl tidak pernah terjebak dalam loop solve tanpa akhir. dont_filter = Truewajib ada. Tanpa itu, request ulang yang membawa token akan dibuang filter duplikat Scrapy karena URL-nya persis sama.- Jeda awal 10 detik lalu 24 kali polling tiap 5 detik memberi plafon sekitar dua menit per task. Angka ini konservatif dan aman untuk reCAPTCHA v2; untuk tipe yang lebih cepat, jeda awalnya bisa Anda perpendek.
- Statistik
detected/solved/faileddicetak lewat sinyalspider_closed. Ini bahan diagnosis termurah yang bisa Anda punya — rasiofailedyang naik mendadak biasanya menandakan sitekey berubah, bukan layanan solver bermasalah.
Konfigurasi settings.py: batas waktu, konkurensi, dan thread
Middleware saja belum cukup. Nilai bawaan Scrapy dirancang untuk halaman yang selesai dalam hitungan detik, sementara satu penyelesaian CAPTCHA bisa memakan puluhan detik.
# settings.py
# CaptchaAI configuration
CAPTCHAAI_API_KEY = "YOUR_API_KEY" # Better: use env variable
CAPTCHAAI_MAX_RETRIES = 2
# Enable the middleware
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.CaptchaAIMiddleware": 600,
}
# Increase timeouts for CAPTCHA solving
DOWNLOAD_TIMEOUT = 180
# Rate limiting
DOWNLOAD_DELAY = 3
RANDOMIZE_DOWNLOAD_DELAY = True
CONCURRENT_REQUESTS = 4
CONCURRENT_REQUESTS_PER_DOMAIN = 2
DOWNLOAD_TIMEOUT = 180 adalah baris terpenting di blok ini: dengan nilai bawaan, request Anda justru mati saat token hampir siap. Prioritas 600 menempatkan middleware sesudah penanganan redirect dan cookie, sehingga yang diperiksa sudah berupa HTML final.
Nilai CONCURRENT_REQUESTS sebaiknya diselaraskan dengan kapasitas thread paket CaptchaAI, karena penagihan CaptchaAI berbasis thread — bukan per solve — dengan jumlah penyelesaian tak terbatas per thread selama bulan berjalan:
- BASIC ($15/bulan, 5 thread) — cukup untuk satu spider dengan konkurensi kecil.
- STANDARD ($30/bulan, 15 thread) — beberapa spider berjalan paralel.
- ADVANCE ($90/bulan, 50 thread) — crawl terjadwal berskala agensi.
Konteks lokalnya begini: banyak tim data dan pekerja lepas di Indonesia menjalankan spider terjadwal dari AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta) dengan anggaran bulanan yang harus bisa dipatok di awal. Model thread membuat biaya tidak ikut melonjak ketika sebuah situs tiba-tiba menampilkan CAPTCHA di setiap halaman daftar produk — yang berubah hanya durasi crawl, bukan tagihan.
Spider yang tetap bersih: hanya parsing
Inilah imbalan dari pola tadi. Spider di bawah tidak menyebut CAPTCHA sama sekali; ia hanya mengurai kartu produk dan mengikuti pagination.
# spiders/product_spider.py
import scrapy
class ProductSpider(scrapy.Spider):
name = "products"
start_urls = ["https://example.com/products"]
def parse(self, response):
"""Parse product listing page."""
# The middleware handles CAPTCHAs automatically
# This method only deals with parsing
for product in response.css("div.product-card"):
yield {
"name": product.css(".name::text").get("").strip(),
"price": product.css(".price::text").get("").strip(),
"url": response.urljoin(product.css("a::attr(href)").get("")),
}
# Follow pagination
next_page = response.css("a.next-page::attr(href)").get()
if next_page:
yield scrapy.Request(response.urljoin(next_page))
def parse_product(self, response):
"""Parse individual product page."""
# Access CAPTCHA token if middleware solved one
token = response.meta.get("captcha_token")
if token:
self.logger.info(f"Page accessed after CAPTCHA solve: {response.url}")
yield {
"title": response.css("h1::text").get("").strip(),
"description": response.css(".description::text").get("").strip(),
"price": response.css(".price::text").get("").strip(),
}
Perhatikan parse_product: ia hanya membaca captcha_token dari response.meta untuk keperluan log. Kalau Anda mendapati diri Anda menulis logika penyelesaian CAPTCHA di dalam callback seperti ini, itu tanda ada pola yang seharusnya naik ke middleware.
Meneruskan token ke callback berikutnya
Sebagian alur membutuhkan token yang sama pada request turunan — misalnya halaman detail yang lahir dari halaman daftar yang sudah melewati verifikasi. Untuk kebutuhan itu, pakai spider middleware terpisah.
class CaptchaTokenSpiderMiddleware:
"""Pass CAPTCHA tokens to spider callbacks."""
def process_spider_input(self, response, spider):
"""Add CAPTCHA token to response meta if available."""
token = response.meta.get("captcha_token")
if token:
spider.logger.debug(f"CAPTCHA token available for {response.url}")
return None
def process_spider_output(self, response, result, spider):
"""Forward token to new requests from this response."""
token = response.meta.get("captcha_token")
for item_or_request in result:
if isinstance(item_or_request, scrapy.Request) and token:
item_or_request.meta.setdefault("parent_captcha_token", token)
yield item_or_request
process_spider_output menyalin token ke parent_captcha_token pada setiap scrapy.Request baru, jadi callback berikutnya tahu bahwa halaman induknya sempat menghadapi tantangan CAPTCHA. Daftarkan kelas ini di SPIDER_MIDDLEWARES, bukan di DOWNLOADER_MIDDLEWARES — dua registry yang berbeda, dan tertukar adalah kekeliruan klasik.
Catatan operasional saat middleware berjalan di crawl besar
- Panggilan HTTP sinkron menahan slot downloader selama polling berlangsung. Untuk konkurensi tinggi, pindahkan penyelesaian CAPTCHA ke jalur asinkron (misalnya lewat
scrapy-playwright) atau tambah thread, jangan sekadar menaikkanCONCURRENT_REQUESTS. - Kirim token segera setelah diterima. Masa berlaku token reCAPTCHA hanya sekitar dua menit; antrean panjang di sisi Scrapy membuat token basi sebelum sempat dipakai.
- Pisahkan log CAPTCHA dari log crawl supaya rasio detected/solved/failed mudah dibaca per hari.
- Uji regex sitekey Anda pada halaman nyata. Pola
data-sitekeyjuga cocok dengan widget lain di halaman yang sama, dan deteksi palsu memboroskan thread. - Jaga cakupan data. UU Pelindungan Data Pribadi (UU 27/2022) membuat prinsip "ambil hanya data yang memang berwenang Anda proses, hindari data pribadi" bukan sekadar etika, melainkan pertimbangan kepatuhan yang nyata bagi tim di Indonesia.
Kesalahan yang paling sering muncul di middleware CAPTCHA
- Lupa
dont_filter— request ulang hilang tanpa jejak dan spider berhenti seolah tidak menemukan apa pun. DOWNLOAD_TIMEOUTdibiarkan bawaan — polling belum selesai, koneksi sudah ditutup.- Batas coba ulang terlalu longgar — satu halaman yang memang selalu menantang bisa menghabiskan thread berjam-jam.
- Prioritas middleware terlalu awal — yang diperiksa adalah respons yang belum melewati redirect atau dekompresi.
- API key ditulis langsung di
settings.py— pakai environment variable, apalagi kalau repo project dibagikan ke klien.
Pertanyaan umum
Berapa nilai CONCURRENT_REQUESTS yang aman untuk paket thread saya?
Samakan dengan jumlah thread, lalu turunkan sedikit. Pada BASIC ($15/bulan, 5 thread), nilai CONCURRENT_REQUESTS = 4 menyisakan ruang bagi request ulang yang membawa token tanpa membuat task menunggu di antrean.
Apakah middleware ini bisa dipakai untuk hCaptcha?
Tidak. CaptchaAI belum mendukung hCaptcha maupun FunCaptcha (Arkose Labs), dan GeeTest v4 masih berstatus segera hadir. Yang bisa ditangani middleware ini adalah reCAPTCHA v2/v3, Cloudflare Turnstile dan Cloudflare Challenge, GeeTest v3, serta CAPTCHA gambar dan grid.
Kenapa token saya sudah tidak valid saat request ulang dikirim?
Umumnya karena jarak waktu. Kalau DOWNLOAD_DELAY besar dan antrean padat, token menunggu terlalu lama sebelum dikirim. Perpendek antrean, atau selesaikan CAPTCHA tepat sebelum request dikirim, bukan jauh sebelumnya.
Pada prioritas berapa middleware sebaiknya didaftarkan?
Angka 600 adalah titik awal yang aman: sesudah redirect, cookie, dan dekompresi, tetapi sebelum middleware cache buatan Anda sendiri. Naikkan angkanya bila ada middleware lain yang perlu memodifikasi HTML lebih dulu.
Bagaimana memperkirakan biaya kalau spider berjalan setiap hari?
Hitung dari thread, bukan dari jumlah CAPTCHA. Karena setiap thread memberi penyelesaian tak terbatas selama bulan berjalan, biaya bulanan Anda tetap sama entah spider menemukan 500 atau 50000 tantangan — yang berubah hanya lama crawl.
Panduan terkait
- Integrasi Crawlee dengan CaptchaAI untuk scraping modern
- Membangun framework scraping kustom dari nol
Pasang middleware ini hari ini — ambil API key CaptchaAI Anda.