Use Cases

Gestion des CAPTCHA pour la collecte de données sur les salaires et les rémunérations

La première requête sur une grille salariale passe. La dixième déclenche un défi Cloudflare Turnstile, et le lot s'interrompt au milieu d'un balayage de postes. La réponse tient en deux gestes : détecter le défi au lieu de le subir, puis le résoudre via l'API CaptchaAI avant de rejouer la même requête — le reste de votre pipeline de collecte ne bouge pas.

Comparateurs, filtres salariaux des sites d'emploi, statistiques du travail et plateformes d'enquêtes RH activent tous une protection dès que le rythme des recherches ressemble à celui d'un script. Ce guide couvre les déclencheurs, le code de collecte, le dimensionnement des threads et les erreurs récurrentes.

Où les défis se déclenchent sur les portails de salaires

Cartographiez vos sources avant d'écrire du code : le type de défi conditionne la méthode d'API et le temps de résolution à prévoir.

Type de source Défi rencontré Ce qui le déclenche
Comparateurs de salaires Cloudflare Turnstile Recherches répétées depuis la même session
Filtres salariaux des sites d'emploi reCAPTCHA v2 Consultations successives de fourchettes
Statistiques publiques du travail CAPTCHA image Demandes de téléchargement de jeux de données
Pages « rémunérations » d'entreprise Cloudflare Challenge Consultation massive de pages
Plateformes d'enquêtes RH reCAPTCHA v3 Soumissions de formulaires

Ces cinq familles sont prises en charge par CaptchaAI. hCaptcha et FunCaptcha (Arkose Labs) ne le sont pas : sur ces portails, passez par un accès négocié ou un export officiel.

Cadrer la collecte avant de l'industrialiser

Point souvent négligé côté francophone : dès que des montants sont rattachés à des personnes identifiables, vous entrez dans le champ du RGPD et la CNIL attend une minimisation stricte. N'agrégez que des fourchettes par intitulé de poste et par zone géographique, ne stockez aucun identifiant individuel, et documentez la finalité avant le premier lot.

Une équipe RH à Lyon ou à Bruxelles qui construit une grille interne croise typiquement deux comparateurs, un site d'emploi et un jeu de données statistiques — et rencontre un défi sur au moins deux d'entre eux. Respectez les conditions d'utilisation de chaque source et privilégiez les API officielles quand elles existent.

Collecteur Python : détecter Turnstile puis rejouer la requête

Le collecteur ci-dessous détecte un défi Turnstile à trois signaux (statut 403, marqueur cf-turnstile, domaine challenges.cloudflare.com), extrait le sitekey, envoie la tâche à in.php, interroge res.php, puis renvoie le token dans le champ cf-turnstile-response.

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_of(node):
          return node.get_text(strip=True) if node else ""

        for row in soup.select(".salary-row, .compensation-entry, tr[data-salary]"):
            try:
                records.append(SalaryRecord(
              title=text_of(row.select_one(".job-title, .title")),
              location=text_of(row.select_one(".location")),
                    min_salary=self._parse_amount(
                text_of(row.select_one(".min-salary, .low"))
                    ),
                    max_salary=self._parse_amount(
                text_of(row.select_one(".max-salary, .high"))
                    ),
                    median_salary=self._parse_amount(
                text_of(row.select_one(".median, .mid"))
                    ),
              sample_size=int(text_of(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})")

Le délai de deux secondes entre requêtes n'imite pas un visiteur : il évite de saturer le portail et de multiplier les défis. Et comme les sélecteurs CSS des comparateurs changent souvent, isolez _parse_salary_data et couvrez-la par des tests sur pages enregistrées.

Agréger plusieurs sources en Node.js

Une seule source donne une médiane fragile. L'agrégateur suivant interroge plusieurs portails pour un même couple poste/localisation, résout le défi source par source, puis consolide la médiane et l'amplitude — l'écart entre comparateurs est en soi une information.

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

Conservez le nombre de sources valides à côté de la moyenne : une médiane issue d'une seule source ne se compare pas à une médiane issue de quatre.

Choisir un rythme de collecte

Les chiffres ci-dessous reposent sur des mesures observées et des retours d'utilisateurs. Les résultats varient selon le portail, le volume et le moment de la journée.

Approche Volume par jour Fréquence des défis Adapté à
Séquentiel avec délais 100 à 500 requêtes Faible Enquêtes ponctuelles
Rotation de proxys résidentiels 500 à 2 000 requêtes Modérée Analyse régionale
Sessions parallèles 2 000 à 10 000 requêtes Élevée Référentiels complets

Dimensionner vos threads CaptchaAI

La facturation CaptchaAI repose sur les threads : un thread correspond à un défi en cours, et chaque plan inclut un nombre illimité de résolutions par thread. Partez de votre parallélisme réel, pas de votre volume mensuel.

  • BASIC ($15/mois, 5 threads) : collecte séquentielle sur deux ou trois comparateurs, typiquement une enquête interne ponctuelle.
  • STANDARD ($30/mois, 15 threads) : plusieurs intitulés de poste balayés en parallèle sur un jeu de sources stable.
  • ADVANCE ($90/mois, 50 threads) : référentiel multi-pays rafraîchi chaque semaine, avec rotation de proxys.

Si vos workers attendent pendant que les résolutions s'accumulent, augmentez le nombre de threads, pas la cadence vers le portail. La facturation se fait en dollars US.

Dépannage

Problème Cause probable Correctif
Turnstile réapparaît à chaque recherche Session non conservée Persistez le cookie cf_clearance entre les requêtes
Le portail renvoie « Connexion requise » Accès réservé aux comptes authentifiés Authentifiez-vous avant la première recherche
Résultats vides après une résolution réussie Champs de formulaire manquants dans le POST Rejouez tous les champs cachés, pas seulement le token
Fourchettes incohérentes d'une exécution à l'autre Paramètres de requête variables Figez la devise, la période et le niveau d'expérience
Erreur 403 malgré un token valide Token envoyé trop tard Réduisez le délai entre la résolution et le POST

FAQ

CaptchaAI prend-il en charge hCaptcha sur les portails de salaires ?

Non — hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs). Les types couverts sont Cloudflare Turnstile, Cloudflare Challenge, reCAPTCHA v2 et v3, GeeTest v3, les CAPTCHA image et les grilles d'images, plus CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta).

Quel plan choisir pour un référentiel salarial multi-pays ?

Comptez un thread par requête simultanée. Un balayage hebdomadaire de quelques centaines de couples poste/ville tient dans STANDARD ($30/mois, 15 threads) ; passez à ADVANCE ($90/mois, 50 threads) si vous interrogez plusieurs pays en parallèle.

Comment rester conforme au RGPD pendant la collecte ?

Ne conservez que des agrégats : intitulé, zone, fourchette, taille d'échantillon. Écartez tout élément permettant de remonter à une personne et fixez une durée de conservation. En cas de doute sur une source nominative, faites valider le périmètre avant de lancer le lot.

Faut-il des proxys pour une collecte à fort volume ?

Oui, au-delà de quelques centaines de requêtes par jour. Les proxys résidentiels réduisent nettement la fréquence des défis face aux IP de datacenter, et une rotation par source évite de concentrer tout le trafic sur une seule adresse.

À quelle fréquence rafraîchir un jeu de données salariales ?

La plupart des portails actualisent leurs grilles chaque mois ou chaque trimestre. Une collecte hebdomadaire suffit ; au-delà, vous multipliez les défis sans gagner en fraîcheur.

Articles connexes

Prochaines étapes

Fiabilisez votre collecte de rémunérations : récupérez votre clé API CaptchaAI et laissez les défis des portails salariaux se résoudre pendant que vos workers continuent d'avancer.

Les commentaires sont désactivés pour cet article.