Portal gaji jarang memblokir Anda di query pertama. Mereka membiarkan beberapa pencarian lewat, lalu menahan sisanya di halaman verifikasi begitu pola aksesnya terlihat non-manusia — dan justru di situ benchmark kompensasi Anda berhenti separuh jadi. Penanganannya reaktif, bukan preventif: baca respons, kenali bahwa itu halaman tantangan, kirim sitekey ke API CaptchaAI, lalu ulangi request yang sama dengan token yang dikembalikan. Cloudflare Turnstile, Cloudflare Challenge, reCAPTCHA v2 dan v3, serta CAPTCHA gambar semuanya termasuk tipe yang didukung.
Konteksnya sangat terasa bagi tim data di Indonesia. Konsultan HR yang menyusun salary benchmark untuk klien, agensi rekrutmen yang memetakan rentang gaji per kota, dan tim people analytics startup sama-sama menarik angka dari puluhan sumber sekaligus — dan volume itulah yang memicu verifikasi. Satu catatan kepatuhan sebelum mulai: UU Pelindungan Data Pribadi (UU 27/2022) menempatkan data individu di kategori sensitif, jadi kumpulkan hanya angka agregat dan rentang publik, bukan catatan gaji per orang. Ini bukan nasihat hukum, hanya batas kerja yang wajar.
Kapan portal gaji memunculkan CAPTCHA
Setiap kategori sumber punya pemicu yang berbeda, dan mengenalinya lebih dulu menghemat banyak percobaan ulang.
| Jenis sumber | CAPTCHA | Pemicu |
|---|---|---|
| Situs perbandingan gaji | Cloudflare Turnstile | Request pencarian berulang |
| Filter gaji di job board | reCAPTCHA v2 | Beberapa pencarian gaji berturut-turut |
| Statistik ketenagakerjaan pemerintah | CAPTCHA gambar | Request unduh dataset |
| Halaman gaji korporat | Cloudflare Challenge | Tampilan halaman dalam jumlah besar |
| Platform survei SDM | reCAPTCHA v3 | Pengiriman formulir |
Polanya konsisten: satu atau dua query lolos tanpa hambatan, lalu tantangan muncul begitu portal melihat pola akses non-manusia. Karena itu penanganannya harus reaktif — jangan menyelesaikan CAPTCHA di setiap request, cukup saat respons benar-benar mengindikasikan tantangan.
Ritme request yang menekan frekuensi tantangan
| Pendekatan | Volume per hari | Frekuensi CAPTCHA | Cocok untuk |
|---|---|---|---|
| Berurutan dengan jeda | 100–500 query | Rendah | Survei kecil |
| Rotasi egress jaringan | 500–2.000 query | Sedang | Analisis lintas wilayah |
| Paralel multi-sesi | 2.000–10.000 query | Tinggi | Dataset komprehensif |
Jeda 2–5 detik antar-request terdengar lambat, tetapi untuk 500 kombinasi jabatan dan kota itu tetap selesai dalam satu malam, dan jumlah tantangan yang harus diselesaikan turun drastis. Jika Anda menjalankan collector dari AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta), perhitungkan juga latensi ke portal yang di-host di Amerika Utara — batas waktu polling sebaiknya diberi ruang, bukan dipangkas.
Model harga CaptchaAI membantu di sini karena berbasis thread, bukan per solve: BASIC ($15/bulan, 5 thread) sudah cukup untuk satu collector berurutan, sedangkan ADVANCE ($90/bulan, 50 thread) melayani beberapa job paralel tanpa biaya tambahan per CAPTCHA. Berapa pun banyaknya tantangan yang muncul malam itu, biayanya tetap.
Collector data gaji dengan Python
Kelas berikut menggabungkan tiga hal: pencarian, deteksi tantangan Turnstile, dan pengambilan record. Perhatikan bahwa penyelesaian hanya dipanggil ketika _is_turnstile_challenge bernilai benar.
import requests
import time
import re
from dataclasses import dataclass
@dataclass
class SalaryRecord:
title: str
location: str
min_salary: float
max_salary: float
median_salary: float
sample_size: int
source: str
class SalaryCollector:
def __init__(self, api_key):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
def collect_salary_data(self, portal_url, job_title, location):
"""Search for salary data, solving CAPTCHAs as needed."""
response = self.session.get(portal_url, params={
"title": job_title,
"location": location
})
if self._is_turnstile_challenge(response):
response = self._solve_turnstile_and_retry(response, portal_url)
return self._parse_salary_data(response.text, portal_url)
def collect_bulk(self, portal_url, job_titles, locations):
"""Collect salary data for multiple job title + location combos."""
results = []
for title in job_titles:
for location in locations:
try:
data = self.collect_salary_data(
portal_url, title, location
)
results.extend(data)
# Respectful delay between requests
time.sleep(2)
except Exception as e:
print(f"Failed for {title} in {location}: {e}")
return results
def _is_turnstile_challenge(self, response):
return (
response.status_code == 403 or
"cf-turnstile" in response.text or
"challenges.cloudflare.com" in response.text
)
def _solve_turnstile_and_retry(self, response, url):
match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
if not match:
raise ValueError("Turnstile sitekey not found")
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"method": "turnstile",
"sitekey": match.group(1),
"pageurl": url,
"json": 1
})
task_id = resp.json()["request"]
for _ in range(60):
time.sleep(3)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1
})
data = result.json()
if data["status"] == 1:
return self.session.post(url, data={
"cf-turnstile-response": data["request"]
})
raise TimeoutError("Turnstile solve timed out")
def _parse_salary_data(self, html, source):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
records = []
def text_or_empty(node):
return node.text.strip() if node and node.text else ""
for row in soup.select(".salary-row, .compensation-entry, tr[data-salary]"):
try:
records.append(SalaryRecord(
title=text_or_empty(row.select_one(".job-title, .title")),
location=text_or_empty(row.select_one(".location")),
min_salary=self._parse_amount(
text_or_empty(row.select_one(".min-salary, .low"))
),
max_salary=self._parse_amount(
text_or_empty(row.select_one(".max-salary, .high"))
),
median_salary=self._parse_amount(
text_or_empty(row.select_one(".median, .mid"))
),
sample_size=int(
text_or_empty(row.select_one(".count, .sample")).replace(",", "") or 0
),
source=source
))
except (AttributeError, ValueError):
continue
return records
def _parse_amount(self, text):
if not text:
return 0.0
cleaned = re.sub(r'[^\d.]', '', text)
return float(cleaned) if cleaned else 0.0
# Usage
collector = SalaryCollector("YOUR_API_KEY")
data = collector.collect_bulk(
"https://salary.example.com/search",
job_titles=["Software Engineer", "Data Analyst", "Product Manager"],
locations=["San Francisco", "New York", "Austin"]
)
for record in data:
print(f"{record.title} in {record.location}: "
f"${record.min_salary:,.0f}–${record.max_salary:,.0f} "
f"(median: ${record.median_salary:,.0f})")
Tiga detail yang menentukan keberhasilan: sitekey diambil dari HTML respons yang gagal (bukan di-hardcode), pageurl harus persis URL yang menampilkan tantangan, dan token dikirim balik pada field cf-turnstile-response. Alur kanoniknya selalu sama — kirim → simpan task ID → polling → pakai token. Jika portal target memakai kotak centang reCAPTCHA v2 alih-alih Turnstile, alurnya identik — yang berubah hanya method yang dikirim ke in.php dan nama field token yang dikembalikan.
Agregasi lintas sumber dengan Node.js
Satu sumber jarang cukup untuk benchmark yang bisa dipertanggungjawabkan. Agregator berikut menanyakan beberapa portal untuk kombinasi jabatan dan lokasi yang sama, lalu menghitung median dari sumber yang berhasil — sumber yang gagal dicatat sebagai error, bukan menghentikan seluruh proses.
class SalaryAggregator {
constructor(apiKey) {
this.apiKey = apiKey;
this.sources = [];
}
addSource(name, searchUrl) {
this.sources.push({ name, searchUrl });
}
async collectForRole(jobTitle, location) {
const results = [];
for (const source of this.sources) {
try {
const data = await this.querySource(source, jobTitle, location);
results.push({ source: source.name, ...data });
} catch (error) {
results.push({ source: source.name, error: error.message });
}
}
return this.aggregateResults(results, jobTitle, location);
}
async querySource(source, jobTitle, location) {
const url = `${source.searchUrl}?title=${encodeURIComponent(jobTitle)}&location=${encodeURIComponent(location)}`;
const response = await fetch(url);
const html = await response.text();
if (html.includes('cf-turnstile') || response.status === 403) {
return this.solveAndRetry(source.searchUrl, html, jobTitle, location);
}
return this.parseSalaryData(html);
}
async solveAndRetry(baseUrl, html, jobTitle, location) {
const match = html.match(/data-sitekey="(0x[^"]+)"/);
if (!match) throw new Error('Turnstile sitekey not found');
const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
body: new URLSearchParams({
key: this.apiKey,
method: 'turnstile',
sitekey: match[1],
pageurl: baseUrl,
json: '1'
})
});
const { request: taskId } = await submitResp.json();
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const result = await fetch(
`https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
);
const data = await result.json();
if (data.status === 1) {
const response = await fetch(baseUrl, {
method: 'POST',
body: new URLSearchParams({
'cf-turnstile-response': data.request,
title: jobTitle,
location: location
})
});
return this.parseSalaryData(await response.text());
}
}
throw new Error('Turnstile solve timed out');
}
aggregateResults(results, jobTitle, location) {
const valid = results.filter(r => !r.error && r.median);
if (valid.length === 0) return null;
const medians = valid.map(r => r.median);
return {
jobTitle,
location,
avgMedian: medians.reduce((a, b) => a + b, 0) / medians.length,
sources: valid.length,
range: { min: Math.min(...medians), max: Math.max(...medians) }
};
}
}
// Usage
const aggregator = new SalaryAggregator('YOUR_API_KEY');
aggregator.addSource('SalaryDB', 'https://salarydb.example.com/search');
aggregator.addSource('PayScale', 'https://payscale.example.com/lookup');
const result = await aggregator.collectForRole('Software Engineer', 'San Francisco');
console.log(`Median salary: $${result.avgMedian.toLocaleString()} (${result.sources} sources)`);
Pola "kumpulkan dulu, agregasi belakangan" ini penting untuk data kompensasi: kalau satu portal memberi angka menyimpang, Anda masih punya rentang dari sumber lain sebagai pembanding, bukan satu angka tunggal yang tidak bisa diverifikasi.
Masalah yang paling sering muncul
| Masalah | Penyebab | Solusi |
|---|---|---|
| Turnstile muncul di setiap pencarian | Sesi kedaluwarsa | Pertahankan cookie qa_validation_cookie di seluruh request |
| Data gaji berubah jadi "Login diperlukan" | Portal mewajibkan autentikasi | Autentikasi lebih dulu, baru lakukan pencarian |
| Hasil kosong setelah CAPTCHA selesai | Parameter POST tidak lengkap | Sertakan semua hidden field pada formulir |
| Angka berbeda antar proses | Portal menampilkan rentang berbeda | Kunci parameter query agar konsisten |
Dua kesalahan tambahan yang sering terlihat: menyelesaikan tantangan di request pertama padahal belum ada tantangan (membuang thread), dan memakai pageurl halaman pencarian generik ketika tantangan sebenarnya muncul di URL hasil. Keduanya menghasilkan token yang ditolak portal, bukan kegagalan di sisi solver.
Tipe yang didukung — dan yang belum
Portal gaji dan job board memakai beberapa keluarga CAPTCHA sekaligus, jadi periksa dulu apa yang terpasang di target Anda. Cloudflare Turnstile, Cloudflare Challenge, seluruh varian reCAPTCHA v2 dan v3, GeeTest v3, serta CAPTCHA gambar dan grid termasuk yang didukung penuh. CaptchaFox (beta), Friendly Captcha (beta), dan Lemin (beta) tersedia dalam status beta. Sebaliknya, hCaptcha dan FunCaptcha belum didukung, dan GeeTest v4 masih berstatus segera hadir — jika portal target Anda memakai salah satu dari tiga ini, rencanakan sumber alternatif sejak awal, jangan menunggu pipeline gagal di produksi. Untuk portal berbasis slider GeeTest v3, siapkan parameter challenge dan gt dari halaman target sebelum mengirim task.
Pertanyaan umum
Berapa banyak query gaji yang realistis per hari?
Batasnya ditentukan rate limit portal, bukan CaptchaAI. Dengan jeda 2–5 detik dan satu sesi berurutan, 300–500 query per hari adalah target yang wajar; untuk lebih dari itu, tambah thread dan jalankan beberapa sesi paralel.
Berapa thread yang saya butuhkan untuk proyek benchmark gaji?
Satu thread menangani satu CAPTCHA yang sedang berjalan. Untuk satu collector berurutan, BASIC ($15/bulan, 5 thread) sudah lapang. Untuk 5–10 job paralel lintas kota, ADVANCE ($90/bulan, 50 thread) memberi ruang tanpa antrean, dan solve-nya tidak dihitung per unit.
Apakah data gaji perlu dikumpulkan secara real-time?
Tidak. Sebagian besar portal memperbarui angka bulanan atau triwulanan, jadi jadwal mingguan atau bulanan sudah cukup dan jauh lebih hemat dibanding polling terus-menerus.
Apakah pengumpulan data gaji aman dari sisi UU PDP?
Selama Anda hanya mengambil rentang dan statistik agregat yang dipublikasikan secara terbuka, risikonya rendah. Yang harus dihindari adalah data gaji yang bisa dikaitkan ke individu tertentu dan data di balik login milik orang lain.
Bagaimana cara mulai paling cepat?
Ambil API key, jalankan satu penyelesaian percobaan terhadap halaman staging Anda sendiri, lalu tempelkan alurnya ke collector. Sekali alur kirim-polling-pakai token berjalan pada satu halaman, sisanya hanya soal menempelkannya ke loop pengumpulan.
Artikel Terkait
- Pengumpulan data untuk riset pasar
- Perbandingan GeeTest dan Cloudflare Turnstile
- Mengatasi error 403 Turnstile setelah token diperbaiki
Langkah Selanjutnya
Ambil API key CaptchaAI Anda dan jalankan pengumpulan data gaji malam ini tanpa terhenti di halaman verifikasi.