Anwendungsfälle

CAPTCHA-Verarbeitung für die Erfassung von Gehalts- und Vergütungsdaten

Der Engpass bei der Erfassung von Gehalts- und Vergütungsdaten ist selten das Parsen der Tabellen – es ist die CAPTCHA-Abfrage, die nach wenigen Suchen erscheint. Sobald Sie Gehaltsspannen nach Rolle, Standort oder Branche im großen Stil abfragen, blenden Gehaltsvergleichsportale, Jobbörsen und staatliche Arbeitsmarktstatistiken Cloudflare Turnstile oder reCAPTCHA ein. CaptchaAI löst diese Abfrage in derselben Sitzung und übergibt das Token an Ihren Collector, sodass die Erfassung ohne manuelles Eingreifen weiterläuft. Dieser Leitfaden zeigt die typischen CAPTCHA-Muster, lauffähigen Collector-Code in Python und JavaScript, eine Durchsatzstrategie und einen Blick auf die DSGVO-Seite.

Wo CAPTCHAs auf Gehaltsportalen auftreten

Nicht jedes Portal schützt sich gleich. Die folgende Übersicht zeigt, welcher CAPTCHA-Typ Ihnen bei welcher Quelle typischerweise begegnet und was ihn auslöst:

Quelltyp CAPTCHA Auslöser
Gehaltsvergleichsseiten Cloudflare Turnstile Wiederholte Suchanfragen
Gehaltsfilter der Jobbörse reCAPTCHA v2 Mehrere Gehaltssuchen
Staatliche Arbeitsstatistik Bild-CAPTCHA Anfragen zum Herunterladen von Daten
Gehaltsseiten für Unternehmen Cloudflare Challenge Massenseitenaufrufe
HR-Umfrageplattformen reCAPTCHA v3 Formulareinreichungen

CaptchaAI deckt genau diese Familie ab – Turnstile und Cloudflare Challenge, reCAPTCHA v2 und v3 sowie Bild- und Grid-CAPTCHAs. hCaptcha und FunCaptcha gehören nicht dazu; auf diese Typen setzen Gehaltsportale in der Praxis aber ohnehin selten.

Gehaltsdaten-Collector in Python

Der folgende Collector fragt ein Portal ab, erkennt eine Turnstile-Abfrage und löst sie über CaptchaAI, bevor er die Ergebnisseite parst. Die Methode collect_bulk iteriert über Kombinationen aus Berufsbezeichnung und Standort und hält zwischen den Anfragen eine bewusste Verzögerung ein.

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

Der Ablauf ist bei jedem Turnstile-Portal identisch: den sitekey aus dem HTML lesen, die Aufgabe an in.php übermitteln, das Ergebnis über res.php abfragen (Polling) und das zurückgegebene cf-turnstile-response-Token im selben Session-Kontext absenden. Erst danach liefert das Portal die eigentlichen Gehaltsdaten. Preise stehen im Code bewusst in US-Dollar, weil viele Quellen international sind – für die Ausgabe können Sie die Formatierung an Ihren Markt anpassen.

Aggregation über mehrere Quellen (JavaScript)

Verlässliche Vergütungsbenchmarks entstehen selten aus einer einzigen Quelle. Der folgende Aggregator fragt mehrere Portale nacheinander ab, löst pro Quelle die Turnstile-Abfrage und bildet aus den Medianwerten einen Durchschnitt samt Spannbreite.

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

Das Muster ist dasselbe wie in Python: pro Quelle Turnstile lösen, das Token einfügen, die Antwort parsen. Weichen die Mediane mehrerer Quellen stark voneinander ab, ist die Spannbreite oft aussagekräftiger als der Mittelwert – gerade bei kleinen Stichproben.

Durchsatz und Erfassungsstrategie

Wie viele Abfragen pro Tag realistisch sind, hängt weniger von CaptchaAI als von den Rate-Limits der Quelle ab. Als Orientierung:

Ansatz Volumen pro Tag CAPTCHA-Häufigkeit Am besten für
Sequentiell mit Verzögerungen 100–500 Abfragen Niedrig Kleine Umfragen
Proxy-Rotation 500–2.000 Abfragen Mäßig Regionale Analyse
Mehrere Sitzungen parallel 2.000–10.000 Abfragen Hoch Umfangreiche Datensätze

Für regionale Auswertungen mit rotierenden Residential-Proxys eignet sich ein einfaches Worker-Setup bei Anbietern wie Hetzner oder netcup gut. Entscheidend ist ein ruhiges Anfrageprofil: konstante Sessions, moderate Intervalle und saubere Proxys senken die CAPTCHA-Häufigkeit spürbar und halten damit auch die Kosten niedrig.

DSGVO und rechtssichere Erfassung

Gehalts- und Vergütungsdaten berühren in der DACH-Region schnell den Datenschutz. Beziehen sich die erfassten Werte auf identifizierbare Personen – etwa individuelle Gehälter statt aggregierter Spannen –, gelten sie nach DSGVO als personenbezogene Daten, und auch die IP-Adressen aus Ihrem Erfassungs-Setup fallen darunter. Prüfen Sie vor einem größeren Lauf die Nutzungsbedingungen der Quelle, Ihre Rechtsgrundlage und ob aggregierte Marktdaten für Ihre Fragestellung ausreichen. CaptchaAI verarbeitet ausschließlich die CAPTCHA-Abfrage; die Verantwortung für die erhobenen Inhalte und deren Rechtsgrundlage bleibt bei Ihnen.

Fehlerbehebung

Problem Ursache Lösung
Zu viele CAPTCHAs in kurzer Zeit Abrufrate oder Parallelität ist für die Quelle zu aggressiv Drosseln Sie die Intervalle, halten Sie Sessions stabil und prüfen Sie die Qualität Ihrer Proxys
Daten fehlen trotz gelöster CAPTCHA Der Parser liest eine alte oder unvollständige Ansicht aus Extrahieren Sie Daten erst nach erfolgreicher Token-Anwendung in derselben Sitzung
Kosten steigen stärker als erwartet Zu viele Wiederholungen oder unnötige Seitenaufrufe lösen zusätzliche Challenges aus Lösen Sie nur kritische Schritte und protokollieren Sie Wiederholungen pro Quelle

Häufige Fragen

Welche CAPTCHA-Typen löst CaptchaAI auf Gehaltsportalen?

Cloudflare Turnstile und Cloudflare Challenge, reCAPTCHA v2 und v3 sowie Bild- und Grid-CAPTCHAs – also genau die Typen, die auf Gehalts- und Jobportalen üblich sind. hCaptcha und FunCaptcha unterstützt CaptchaAI nicht; trifft eine Quelle ausschließlich darauf ab, brauchen Sie eine alternative Datenquelle.

Ist das automatisierte Erfassen von Gehaltsdaten DSGVO-konform?

Das hängt von den Daten und der Quelle ab. Aggregierte Gehaltsspannen sind unkritischer als personenbezogene Einzelwerte. Prüfen Sie die Nutzungsbedingungen des Portals und Ihre Rechtsgrundlage; CaptchaAI löst nur die CAPTCHA-Abfrage und trifft keine Aussage über die Zulässigkeit Ihrer Erhebung.

Was kostet die CAPTCHA-Verarbeitung bei hohem Abfragevolumen?

CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung – jeder Tarif enthält unbegrenzte Lösungen pro Thread. Für einzelne Erfassungsläufe reicht BASIC (15 $/Monat, 5 Threads); für parallele Multi-Session-Erfassung über viele Berufsbilder skalieren ADVANCE (90 $/Monat, 50 Threads) oder höher. Die Kosten steigen also mit der Parallelität, nicht mit der Zahl der Suchen.

Warum erscheint Turnstile nach einigen Suchanfragen erneut?

Portale werten Anfragerate, Session-Alter und IP-Reputation aus. Bricht die Session ab oder feuern zu viele Anfragen von derselben IP, wird die Abfrage erneut ausgelöst. Halten Sie Cookies über die Session hinweg stabil, verteilen Sie Anfragen über Residential-Proxys und lassen Sie zwischen den Suchen einige Sekunden Abstand.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.