Use Cases

Maaş ve Tazminat Verilerinin Toplanması için CAPTCHA Kullanımı

Ücret araştırması yapan bir veri ekibinin en sık takıldığı yer parser değil, üçüncü sayfada beliren doğrulama ekranıdır. Maaş karşılaştırma siteleri, iş panoları ve resmî işgücü istatistik portalları ücret verisini Cloudflare Turnstile ve reCAPTCHA arkasına koyar; unvan ve şehir kombinasyonlarını taradıkça bu doğrulamalar kaçınılmaz biçimde tetiklenir.

Kısa cevap: doğrulamayı ayrı bir kriz değil, toplayıcınızın olağan bir adımı olarak modelleyin.

  1. Yanıtta doğrulama işareti görürseniz sayfadan sitekey değerini çıkarın.
  2. Çözüm görevini in.php uç noktasına gönderin ve res.php üzerinden sonucu sorgulayın.
  3. Dönen token'ı formun kendi alanıyla birlikte POST edin ve isteği tekrarlayın.

Aşağıda bu döngünün Python ve Node.js karşılıkları, hangi kaynağın hangi doğrulamayı çıkardığını gösteren bir tablo, plan/thread hesabı ve üretimde en sık karşılaşılan hataların düzeltmeleri var.

Ücret verisi toplarken CAPTCHA neden çıkar?

Maaş portalları tek tek aramaları değil, deseni cezalandırır. Tek bir "Yazılım Mühendisi / İstanbul" sorgusu genelde sorunsuz döner; aynı oturumda 40 unvanı sırayla sorguladığınızda oturum bir eşiği aşar ve portal doğrulama akışına geçer. Türkiye'deki ekipler için bu tipik olarak iki senaryoda ortaya çıkar: bir İK danışmanlığının yıllık ücret kıyaslama raporu için çok sayıda rolü taraması ve bir e-ticaret şirketinin teknik ekip bütçesini planlarken bölge bazlı aralıkları derlemesi.

Burada bir uyarı gerekiyor: topladığınız veri tekil bir kişiye indirgenebilir hale geldiği anda (isim, ilan sahibi, tekil profil) KVKK kapsamına girer. Pratikte üç kural işi güvende tutar:

  • Ücret çalışmalarını rol + konum + aralık düzeyinde agregat tutun, tekil kayıt saklamayın.
  • Oturum açma duvarının arkasındaki, erişim yetkiniz olmayan profillere hiç girmeyin.
  • Ham HTML'i uzun süre saklamak yerine yalnızca türetilmiş aralıkları arşivleyin.

Bu makaledeki yaklaşım, erişime açık sayfalarda yalnızca doğrulama adımını otomatikleştirmeye yöneliktir.

Maaş portallarındaki CAPTCHA tetikleyicileri

Kaynak türü CAPTCHA Tetikleyici
Maaş karşılaştırma siteleri Cloudflare Turnstile Tekrarlanan arama sorguları
İş panosu maaş filtreleri reCAPTCHA v2 Çoklu maaş aramaları
Devlet işgücü istatistikleri Resim CAPTCHA'sı Veri indirme istekleri
Kurumsal maaş sayfaları Cloudflare doğrulama akışı Toplu sayfa görüntülemeleri
İK anket platformları reCAPTCHA v3 Form gönderimleri

Bu beş satır tek bir entegrasyonla karşılanmaz: Turnstile ve reCAPTCHA farklı method değerlerini ve farklı token alanlarını kullanır — Turnstile cf-turnstile-response, reCAPTCHA v2 g-recaptcha-response bekler. Toplayıcınızı yazmadan önce hedef kaynakların hangi satıra düştüğünü not edin; kod tarafında ayrışan tek şey bu iki alandır.

Python: tek kaynaktan toplu ücret toplama

Aşağıdaki toplayıcı aramayı yapar; yanıt 403 dönerse ya da gövdede cf-turnstile görürse sitekey değerini çıkarıp in.php uç noktasına gönderir, res.php üzerinden periyodik sorgulama yapar ve dönen cf-turnstile-response değeriyle isteği tekrarlar. time.sleep(2) satırını kozmetik saymayın — portalın istek sınırına saygı gösteren tek yer orasıdır.

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})")

Kodu kendi ortamınıza taşırken iki noktaya dikkat edin: YOUR_API_KEY yerine panelinizdeki gerçek API anahtarını koyun ve CSS seçicilerini hedef portalın gerçek işaretlemesine göre güncelleyin — .salary-row burada yalnızca bir yer tutucudur.

Hacme göre toplama stratejisi

Yaklaşım Günlük hacim CAPTCHA sıklığı Uygun olduğu iş
Gecikmeli sıralı 100–500 sorgu Düşük Küçük ücret anketleri
Proxy yapılandırmasıyla dağıtım 500–2.000 sorgu Orta Bölgesel analiz
Çoklu oturum paralel 2.000–10.000 sorgu Yüksek Kapsamlı veri kümeleri

Thread ihtiyacınızı şuradan hesaplayın: bir Turnstile çözümü birkaç saniye sürer ve o süre boyunca tek bir thread meşguldür. Küçük bir kıyaslama çalışması için BASIC ($15/ay, 5 thread) çoğu zaman yeterlidir; gecelik toplu çalıştırmalarda paralel oturum sayısını artıran ekipler genellikle ADVANCE ($90/ay, 50 thread) seviyesine çıkar. Planlar thread başına ücretlendirilir ve thread başına çözüm sayısı sınırsızdır — TL kuru dalgalanırken aylık USD maliyetinin sabit kalması, Türkiye'deki ekipler için tek başına bir tercih sebebidir.

Node.js: birden fazla kaynağı birleştirme

Tek bir portal nadiren yeterli olur; ücret aralıkları kaynaktan kaynağa ciddi biçimde değişir. Aşağıdaki toplayıcı aynı rolü birkaç kaynağa sorar, her biri için doğrulama döngüsünü ayrı yürütür ve medyanların ortalamasıyla tek bir kayıt üretir. Bir kaynak hata verdiğinde çalışma durmaz; hata o kaynağın satırında taşınır ve rapora yansır.

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)`);

Sorun giderme

Sorun Sebep Düzeltme
Her aramada yeniden Turnstile Oturum taşınmıyor qa_session_cookie çerezini oturum nesnesinde kalıcı tutun
"Giriş yapmanız gerekiyor" yanıtı Portal kimlik doğrulaması istiyor Aramadan önce yetkili hesapla oturum açın
Çözümden sonra boş sonuç POST gövdesinde eksik alan var Formdaki tüm gizli alanları isteğe ekleyin
Token reddedildi Token'ın süresi doldu Token'ı alır almaz, 60 saniye içinde gönderin
Çalıştırmalar arasında tutarsız veri Portal farklı aralık gösteriyor Sorgu parametrelerini ve para birimini sabitleyin

Bu tablodaki ilk satır en sinsi olanıdır: oturum nesnesini her sorguda yeniden kurarsanız portal sizi her seferinde yeni ziyaretçi sayar ve doğrulama sıklığı katlanır. Tek bir requests.Session (ya da tek bir fetch bağlamı) üzerinden ilerleyin.

Sık sorulan sorular

Bu iş için hangi plan yeterli?

Sorgu sayısı değil, eşzamanlılık belirler. Tek bir sıralı toplayıcı çalıştırıyorsanız BASIC ($15/ay, 5 thread) yeterlidir; 10–20 paralel oturumla gecelik tarama yapıyorsanız ADVANCE ($90/ay, 50 thread) daha uygundur. Çözüm sayısı için ayrıca ücret alınmaz.

Maaş portalı hCaptcha kullanıyorsa ne olur?

CaptchaAI hCaptcha'yı desteklemiyor; FunCaptcha (Arkose Labs) da desteklenmiyor. GeeTest v4 için yalnızca "çok yakında" bilgisi var. Bu tipleri çıkaran kaynakları otomatik toplama listenizden çıkarın veya o kaynak için ayrı bir manuel süreç planlayın. CaptchaFox (beta), Friendly Captcha (beta) ve Lemin (beta) ise beta kapsamındadır.

Topladığım ücret verisi KVKK kapsamına girer mi?

Veri tekil bir kişiye bağlanabiliyorsa evet. Rol, konum ve aralık düzeyinde agregat tutulan ücret verisi genelde kişisel veri sayılmaz. Erişim yetkiniz olmayan, oturum açma duvarının arkasındaki profilleri toplamayın.

Fiyatları TL olarak görebilir miyim?

Hayır, planlar USD üzerinden faturalandırılır. Bunu bir dezavantaj değil, bütçe tarafında öngörülebilirlik olarak değerlendirin: aylık maliyetiniz thread sayınıza bağlıdır, çözdüğünüz CAPTCHA hacmine değil.

Gerçek zamanlı ücret verisi toplamalı mıyım?

Genellikle hayır. Çoğu maaş portalı verisini aylık ya da üç aylık döngülerle günceller, dolayısıyla saatlik tarama boşuna istek üretir. Haftalık veya aylık bir çalıştırma takvimi hem daha az doğrulama tetikler hem de aynı sonucu verir.

İlgili Makaleler

Sonraki Adımlar

Ücret araştırmanızı doğrulama ekranlarında kesintiye uğratmayın — CaptchaAI API anahtarınızı alın ve ilk maaş portalı sorgunuzu bugün otomatikleştirin.

Bu makale için yorumlar devre dışı bırakılmıştır.