Jawaban singkatnya: Anda tidak perlu menjalankan browser sama sekali. Turnstile diselesaikan di luar skrip Anda — kirim sitekey dan URL halaman ke API CaptchaAI, tunggu token-nya, lalu POST token itu sebagai field cf-turnstile-response bersama data form biasa. Seluruh alurnya muat di satu file Python dengan pustaka requests.
Ini penting karena banyak pipeline scraping di Indonesia berjalan di worker kecil: VPS 1 GB, container di AWS ap-southeast-1 (Singapura), atau runner CI. Browser headless menambah ratusan MB pada ukuran image sekaligus waktu boot di setiap job; jalur requests + API menjaga worker tetap satu proses Python.
Alurnya sama dari awal sampai akhir: ambil sitekey → kirim task → polling → pakai token.
Yang perlu disiapkan sebelum mulai
pip install requests
- API key CaptchaAI dari akun Anda di captchaai.com, beserta saldo aktif
- URL halaman yang dilindungi Turnstile
- Sitekey Turnstile — langkah 1 mengambilnya otomatis
Langkah 1: ambil sitekey Turnstile dari HTML halaman
Sitekey adalah identitas publik widget Turnstile: tertanam di HTML sebagai atribut data-sitekey dan hampir selalu diawali 0x. Fungsi berikut mengunduh halaman dengan header browser yang wajar, lalu mencocokkan tiga pola penulisan yang umum.
import re
import requests
def extract_turnstile_sitekey(url):
"""Extract Cloudflare Turnstile sitekey from page HTML."""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0",
"Accept": "text/html,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
response = requests.get(url, headers=headers, timeout=15)
patterns = [
r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']',
r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
r"siteKey\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
]
for pattern in patterns:
match = re.search(pattern, response.text)
if match:
return match.group(1)
return None
sitekey = extract_turnstile_sitekey("https://example.com/signup")
print(f"Sitekey: {sitekey}")
Kalau hasilnya None, penyebabnya hampir selalu sama: widget dirender oleh JavaScript setelah halaman dimuat, jadi belum ada di HTML awal. Untuk pengujian pertama, salin sitekey manual dari inspect element.
Langkah 2: kirim task Turnstile ke API CaptchaAI
Satu POST ke in.php dengan method=turnstile. Responsnya bukan token, melainkan ID task — simpan nilainya, karena langkah berikutnya memerlukannya.
import requests
API_KEY = "YOUR_API_KEY"
def submit_turnstile(sitekey, page_url):
"""Submit Turnstile solving task to CaptchaAI."""
response = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": page_url,
"json": 1,
})
data = response.json()
if data.get("status") != 1:
raise Exception(f"Submit failed: {data.get('request')}")
return data["request"]
task_id = submit_turnstile("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")
print(f"Task ID: {task_id}")
Parameter json=1 membuat respons berbentuk JSON, jauh lebih rapi daripada format teks lama. status yang bukan 1 berarti permintaan ditolak, dan request memuat kode error-nya: ERROR_WRONG_USER_KEY (API key salah) atau ERROR_ZERO_BALANCE (saldo habis).
Langkah 3: polling res.php sampai token siap
Penyelesaian berjalan asinkron: tanyakan ke res.php secara berkala sampai status bernilai 1. Turnstile termasuk tipe cepat — target penyelesaiannya di bawah 10 detik — jadi jeda 5 detik sudah proporsional. Polling lebih rapat hanya menambah request, bukan kecepatan.
import time
def poll_result(task_id, timeout=120):
"""Poll CaptchaAI for the solved Turnstile token."""
start = time.time()
while time.time() - start < timeout:
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": 1,
}).json()
if result.get("status") == 1:
return result["request"]
if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
raise Exception("Turnstile could not be solved")
raise TimeoutError("Solve timed out")
token = poll_result(task_id)
print(f"Token: {token[:50]}...")
Dua detail penting di sini:
time.sleep(5)dipanggil sebelum polling pertama — bertanya pada detik nol pasti menghasilkan status belum siap.ERROR_CAPTCHA_UNSOLVABLEadalah kondisi akhir, bukan kondisi tunggu. Hentikan loop dan tangani sebagai kegagalan, bukan diputar sampai batas waktu.
Skrip Python lengkap: dari sitekey sampai form terkirim
Ketiga langkah digabung jadi satu skrip yang bisa langsung dijalankan. Perhatikan requests.Session(): sesi yang sama mengambil halaman dan mengirim form, sehingga cookie kunjungan pertama tetap terbawa saat POST.
import re
import time
import requests
API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://example.com/signup"
def solve_turnstile(sitekey, page_url):
"""Full Turnstile solve: submit + poll."""
# Submit
submit = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": page_url,
"json": 1,
})
data = submit.json()
if data.get("status") != 1:
raise Exception(f"Submit error: {data.get('request')}")
task_id = data["request"]
print(f"Task submitted: {task_id}")
# Poll
for _ in range(30):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": 1,
}).json()
if result.get("status") == 1:
return result["request"]
raise TimeoutError("Solve timed out")
# --- Main flow ---
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0",
"Accept": "text/html,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
})
# 1. Get page and extract sitekey
response = session.get(TARGET_URL, timeout=15)
match = re.search(r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text)
if not match:
raise ValueError("Turnstile sitekey not found")
sitekey = match.group(1)
print(f"Sitekey: {sitekey}")
# 2. Solve Turnstile
token = solve_turnstile(sitekey, TARGET_URL)
print(f"Token: {token[:50]}...")
# 3. Submit form with token
form_response = session.post(TARGET_URL, data={
"cf-turnstile-response": token,
"email": "user@example.com",
"password": "SecurePass123",
})
print(f"Form status: {form_response.status_code}")
Baris terakhir adalah kuncinya: token dikirim sebagai cf-turnstile-response. Nama field itulah yang divalidasi server — bukan header, bukan nama lain.
Kalau widget Turnstile memakai parameter action
Sebagian situs menyetel atribut data-action pada widget dan memvalidasinya di sisi server. Bila action tidak ikut dikirim, token yang Anda terima terlihat sah tetapi tetap ditolak form. Periksa HTML widget, lalu teruskan nilainya apa adanya.
def solve_turnstile_with_action(sitekey, page_url, action):
"""Solve Turnstile that requires an action parameter."""
submit = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": page_url,
"action": action, # Include the action from data-action attribute
"json": 1,
})
data = submit.json()
if data.get("status") != 1:
raise Exception(f"Submit error: {data.get('request')}")
task_id = data["request"]
for _ in range(30):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": 1,
}).json()
if result.get("status") == 1:
return result["request"]
raise TimeoutError("Solve timed out")
Tiga pola pengiriman token ke server
Setelah token di tangan, cara mengirimnya bergantung pada bentuk form target. Buka tab Network, kirim form itu sekali secara manual, lalu tiru persis struktur payload yang terlihat.
Pola 1: POST form dengan cf-turnstile-response
Bentuk paling umum, dipakai form HTML klasik.
# Most common — Turnstile uses cf-turnstile-response field
response = session.post(form_url, data={
"cf-turnstile-response": token,
"email": "user@example.com",
})
Pola 2: API JSON
Aplikasi berbasis SPA biasanya mengirim JSON dan memberi nama field-nya sendiri.
response = session.post(api_url, json={
"turnstileToken": token,
"email": "user@example.com",
})
Pola 3: nama field kustom
Beberapa framework menyalin token ke field tambahan. Kalau ragu, kirim keduanya.
# Some sites rename the field — check the form HTML
response = session.post(form_url, data={
"cf-turnstile-response": token,
"captcha_token": token, # Custom duplicate field
"action": "signup",
})
Kelas solver Turnstile siap produksi dengan percobaan ulang
Untuk job terjadwal, worker antrean, atau cron malam hari, logika di atas perlu percobaan ulang dan pemisahan error. Kelas berikut mengulang error sementara, tetapi berhenti pada error yang tidak akan membaik: API key salah dan saldo habis.
import re
import time
import requests
class TurnstileSolver:
"""Production-ready Turnstile solver with retry logic."""
API_URL = "https://ocr.captchaai.com"
def __init__(self, api_key, max_retries=3):
self.api_key = api_key
self.max_retries = max_retries
def extract_sitekey(self, session, url):
"""Extract Turnstile sitekey from page."""
response = session.get(url, timeout=15)
match = re.search(
r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text
)
return match.group(1) if match else None
def solve(self, sitekey, page_url, action=None):
"""Solve Turnstile with retry logic. Returns token string."""
for attempt in range(1, self.max_retries + 1):
try:
token = self._solve_once(sitekey, page_url, action)
return token
except TimeoutError:
print(f"Attempt {attempt} timed out")
except Exception as e:
error_str = str(e)
if "ERROR_ZERO_BALANCE" in error_str:
raise # Don't retry billing errors
if "ERROR_WRONG_USER_KEY" in error_str:
raise
print(f"Attempt {attempt} failed: {e}")
raise Exception(f"Failed after {self.max_retries} attempts")
def _solve_once(self, sitekey, page_url, action=None):
"""Single solve attempt."""
params = {
"key": self.api_key,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": page_url,
"json": 1,
}
if action:
params["action"] = action
submit = requests.post(f"{self.API_URL}/in.php", data=params, timeout=30)
submit.raise_for_status()
data = submit.json()
if data.get("status") != 1:
raise Exception(f"Submit error: {data.get('request')}")
task_id = data["request"]
for _ in range(30):
time.sleep(5)
result = requests.get(f"{self.API_URL}/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1,
}, timeout=30).json()
if result.get("status") == 1:
return result["request"]
if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
raise Exception("CAPTCHA unsolvable")
raise TimeoutError("Poll timed out")
# Usage
solver = TurnstileSolver("YOUR_API_KEY")
token = solver.solve("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")
Pemisahan itu bukan kosmetik: mengulang ERROR_ZERO_BALANCE hanya menghabiskan kuota request dan menunda kegagalan yang sudah pasti. Pada worker yang memproses ribuan URL, perbaikan yang benar adalah berhenti dan mengisi saldo.
Menghitung thread dan biaya untuk pipeline harian
Harga CaptchaAI dihitung per thread — jumlah penyelesaian yang berjalan bersamaan — bukan per CAPTCHA yang selesai. Contoh yang lazim di sini: freelancer monitoring harga untuk beberapa klien, dengan sekitar 8 worker paralel.
BASIC($15/bulan, 5 thread) — belum cukup, worker akan saling mengantreSTANDARD($30/bulan, 15 thread) — ruang memadai, termasuk saat lonjakanADVANCE($90/bulan, 50 thread) — lompatan berikutnya saat pipeline tumbuh ke puluhan worker
Biaya bulanannya sama entah Anda menyelesaikan 5.000 atau 50.000 Turnstile, jadi model thread cocok untuk beban yang tidak rata. Sesuaikan max_retries dan jumlah worker paralel dengan thread paket Anda: 40 worker di paket 15 thread tidak error, tetapi request mengantre dan waktu penyelesaian naik.
Catatan kepatuhan yang relevan di Indonesia: UU Pelindungan Data Pribadi (UU 27/2022) menjadikan "proses hanya data yang memang Anda berhak proses" sebagai aturan praktis. Otomatisasi form dan QA pada properti milik sendiri aman; mengumpulkan data pribadi milik orang lain tidak.
Diagnosis cepat saat token ditolak
| Gejala | Penyebab | Solusi |
|---|---|---|
| Token diterima tetapi form menolak | Sitekey keliru atau action tidak disertakan |
Ambil ulang sitekey, sertakan action bila widget punya data-action |
| Sitekey tidak ditemukan di HTML | Widget dirender oleh JavaScript | Ambil sitekey manual, atau pakai Selenium/Playwright untuk halaman dinamis |
| HTTP 403 sebelum halaman terbaca | Request dikirim tanpa header browser yang wajar | Tambahkan User-Agent, Accept, dan Accept-Language |
| Penyelesaian melewati 60 detik | Antrean sedang padat | Perbesar batas waktu, jangan perkecil jeda polling |
| Token berhasil sekali lalu gagal | Situs meminta token baru per pengiriman | Selesaikan token baru untuk setiap submit |
ERROR_ZERO_BALANCE |
Saldo akun habis | Isi ulang saldo; jangan coba ulang error ini |
Pertanyaan yang sering diajukan
Apakah saya benar-benar tidak perlu browser untuk Turnstile?
Tidak perlu. Alurnya murni HTTP: requests mengambil HTML, API menyelesaikan tantangan, token dikirim balik lewat POST biasa. Browser hanya perlu bila sitekey dirender oleh JavaScript dan Anda ingin mengambilnya otomatis.
Berapa lama satu penyelesaian Turnstile biasanya berlangsung?
Targetnya di bawah 10 detik. Timeout 120 detik di kode adalah jaring pengaman untuk jam sibuk, bukan angka yang diharapkan tercapai; kalau sering menyentuhnya, periksa jumlah thread paket Anda.
Berapa thread yang saya butuhkan untuk 20 worker paralel?
Minimal 20, karena satu thread menampung satu penyelesaian yang sedang berjalan. Paket ADVANCE ($90/bulan, 50 thread) memberi ruang aman berikut lonjakannya, dan jumlah solve-nya tidak dibatasi.
Apakah token Turnstile bisa dipakai ulang untuk beberapa pengiriman?
Tidak. Token sekali pakai dan masa berlakunya pendek. Selesaikan token baru untuk setiap submit — menyimpan token lama adalah penyebab paling umum form ditolak padahal kodenya benar.
Bagaimana kalau situs target pindah dari Turnstile ke hCaptcha?
Untuk kasus itu Anda memerlukan layanan lain: hCaptcha dan FunCaptcha (Arkose Labs) tidak didukung. Yang didukung: Turnstile, Cloudflare Challenge, keluarga reCAPTCHA v2/v3, GeeTest v3, serta CAPTCHA gambar dan grid; GeeTest v4 berstatus segera hadir.
Ringkasan
Empat langkah, tanpa browser: ambil sitekey dari HTML, kirim ke in.php dengan method=turnstile, polling res.php, kirim token sebagai cf-turnstile-response. Untuk produksi, bungkus dalam kelas dengan percobaan ulang, dan jangan ulangi error API key maupun saldo. Pola yang sama berlaku untuk reCAPTCHA v2/v3 dan GeeTest v3 — yang berbeda hanya nilai method dan nama field token.