Use Cases

Scraper des sites protégés par CAPTCHA

Un crawler qui tombe sur un CAPTCHA ne renvoie pas une erreur : il renvoie une page HTML valide, mais vide des données attendues. C'est pour cette raison qu'un pipeline de collecte se dégrade en silence pendant des jours. La réponse tient en trois gestes : reconnaître le défi, envoyer ses paramètres à l'API CaptchaAI, réinjecter le token dans la requête suivante.

Trois montages couvrent la quasi-totalité des cas de production : résolution à la demande, résolution en amont, et Cloudflare Challenge avec cookie cf_clearance.

Reconnaître le défi CAPTCHA avant de le résoudre

Votre scraper doit d'abord savoir dire « cette réponse n'est pas la page attendue ». Trois signaux suffisent :

  • un code HTTP 403 ou 503 avec un corps HTML anormalement court ;
  • un conteneur g-recaptcha ou cf-turnstile, ou un script servi depuis challenges.cloudflare.com ;
  • l'absence du sélecteur métier attendu par votre parseur (le div.item de votre extraction).

Le troisième est le plus robuste : il survit aux changements de marqueurs HTML. Traitez-le comme une exception explicite, pas comme une liste vide — une page sans résultat et une page bloquée méritent deux entrées de logs distinctes. Pour les défis affichés en fenêtre modale, voir la détection des CAPTCHA injectés en modale.

Les types de CAPTCHA que vous croiserez vraiment

Type de CAPTCHA Où il apparaît Méthode CaptchaAI
reCAPTCHA v2 Connexion, pages de recherche method=userrecaptcha
reCAPTCHA v3 Score en arrière-plan method=userrecaptcha&version=v3
Cloudflare Turnstile Sites derrière Cloudflare method=turnstile
Cloudflare Challenge Blocage plein écran method=cloudflare_challenge
CAPTCHA image / OCR Portails anciens method=base64
GeeTest v3 Inscription, recherche method=geetest

CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta) existent aussi, mais leur statut bêta les écarte du chemin critique d'un crawl. Trois familles restent hors périmètre :

Type Statut côté CaptchaAI Conséquence pour la collecte
hCaptcha ❌ pas pris en charge Prévoyez une source alternative
FunCaptcha (Arkose Labs) ❌ pas pris en charge Site hors périmètre automatisable
GeeTest v4 ❌ à venir Vérifiez la version du widget

Approche 1 : détecter puis résoudre à la demande

Montage par défaut, et le moins coûteux : vous scrapez normalement et n'occupez un thread que lorsqu'un défi apparaît. Notez la même requests.Session entre la page de défi et la soumission du token : c'est le cookie de session qui rend la réponse acceptable côté serveur.

import requests
import time
from bs4 import BeautifulSoup

API_KEY = "YOUR_API_KEY"

class ProtectedScraper:
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        })

    def scrape(self, url):
        resp = self.session.get(url)

        # Check for CAPTCHA
        if self._has_captcha(resp.text):
            resp = self._handle_captcha(resp.text, url)

        return resp.text

    def _has_captcha(self, html):
        indicators = ["g-recaptcha", "cf-turnstile", "h-captcha", "captcha"]
        return any(ind in html.lower() for ind in indicators)

    def _handle_captcha(self, html, url):
        soup = BeautifulSoup(html, "html.parser")

        # reCAPTCHA v2
        rc = soup.find("div", class_="g-recaptcha")
        if rc:
            token = self._solve_recaptcha(rc["data-sitekey"], url)
            return self.session.post(url, data={"g-recaptcha-response": token})

        # Cloudflare Turnstile
        ts = soup.find("div", class_="cf-turnstile")
        if ts:
            token = self._solve_turnstile(ts["data-sitekey"], url)
            return self.session.post(url, data={"cf-turnstile-response": token})

        raise Exception("Unknown CAPTCHA type")

    def _solve_recaptcha(self, site_key, page_url):
        resp = requests.get("https://ocr.captchaai.com/in.php", params={
            "key": API_KEY, "method": "userrecaptcha",
            "googlekey": site_key, "pageurl": page_url
        })
        return self._poll(resp.text.split("|")[1])

    def _solve_turnstile(self, site_key, page_url):
        resp = requests.get("https://ocr.captchaai.com/in.php", params={
            "key": API_KEY, "method": "turnstile",
            "sitekey": site_key, "pageurl": page_url
        })
        return self._poll(resp.text.split("|")[1])

    def _poll(self, task_id):
        for _ in range(60):
            time.sleep(5)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": API_KEY, "action": "get", "id": task_id
            })
            if result.text == "CAPCHA_NOT_READY": continue
            if result.text.startswith("OK|"): return result.text.split("|")[1]
            raise Exception(result.text)
        raise TimeoutError()

# Usage
scraper = ProtectedScraper()
html = scraper.scrape("https://example.com/data")

L'interrogation du résultat se fait toutes les 5 secondes, avec un plafond de 60 tentatives. Turnstile se termine généralement en moins de 10 secondes ; reCAPTCHA v2 demande davantage. Journalisez le temps de résolution réel : c'est votre indicateur avancé quand un site change de configuration.

Approche 2 : résoudre en amont sur les pages à défi connu

Quand une page affiche systématiquement un défi — recherche, connexion —, inutile de la charger une première fois pour le découvrir. Récupérez le sitekey une fois, mettez-le en cache, puis envoyez directement la requête accompagnée du token.

def scrape_known_captcha_page(url, site_key):
    # Solve before even loading the page
    token = solve_recaptcha(site_key, url)

    # Submit directly with token
    resp = requests.post(url, data={
        "g-recaptcha-response": token,
        "query": "search term"
    })
    return resp.text

Le gain est d'un aller-retour HTTP par page. La contrepartie : un sitekey mis en cache trop longtemps finit invalidé lors d'une refonte du site. Rafraîchissez-le dès qu'une soumission échoue deux fois d'affilée.

Approche 3 : franchir un Cloudflare Challenge et conserver cf_clearance

Un Cloudflare Challenge ne produit pas un token de formulaire mais un cookie cf_clearance, lié à un couple précis d'adresse IP et de User-Agent. D'où le fameux « pourtant le défi a bien été résolu » : cookie obtenu via le proxy A, rejoué via le proxy B.

def get_cloudflare_clearance(url, proxy):
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "cloudflare_challenge",
        "pageurl": url,
        "proxy": proxy,
        "proxytype": "HTTP"
    })
    task_id = resp.text.split("|")[1]

    for _ in range(60):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": task_id
        })
        if result.text == "CAPCHA_NOT_READY": continue
        if "cf_clearance" in result.text:
            # Parse cf_clearance and user_agent from response
            return result.text
    raise TimeoutError()

Transmettez le même proxy à l'API et à votre client HTTP, et reprenez le User-Agent renvoyé avec le cookie. Un worker, un proxy, un User-Agent, un cookie : cette règle élimine l'essentiel des blocages résiduels.

Passer à l'échelle : boucle multipage et budget de threads

def scrape_multiple_pages(base_url, site_key, pages):
    scraper = ProtectedScraper()
    results = []

    for page in pages:
        url = f"{base_url}?page={page}"
        try:
            html = scraper.scrape(url)
            soup = BeautifulSoup(html, "html.parser")
            items = soup.find_all("div", class_="item")
            results.extend([item.text.strip() for item in items])
            print(f"Page {page}: {len(items)} items")
        except Exception as e:
            print(f"Page {page} failed: {e}")

        time.sleep(random.uniform(2, 5))

    return results

Deux détails comptent en production : l'échec d'une page n'interrompt jamais la boucle, et la pause aléatoire évite un rythme de requêtes trop régulier, signal en soi pour les défenses anti-automatisation.

Côté coût, CaptchaAI facture des threads simultanés, pas des résolutions : un thread correspond à un défi en cours, et chaque plan inclut un nombre illimité de résolutions par thread.

  • crawl séquentiel nocturne : BASIC ($15/mois, 5 threads) ;
  • flotte de workers sur plusieurs domaines : ADVANCE ($90/mois, 50 threads).

Dimensionnez d'après les défis simultanés, jamais d'après le volume mensuel de pages (facturation en dollars US).

Scénario : veille tarifaire depuis un worker européen

Cas courant dans les équipes data francophones : relever chaque nuit les prix publics d'une centaine de références sur dix sites marchands, depuis des workers OVHcloud, Scaleway ou AWS eu-west-3 (Paris). Sur ces dix domaines, deux servent du Turnstile, trois un reCAPTCHA v2 sur la recherche, les autres rien.

Le dimensionnement se fait donc sur cinq domaines, pas sur dix : cinq défis simultanés au pic, absorbés par un plan d'entrée. Côté conformité, trois règles :

  • données publiques de catalogue uniquement ;
  • robots.txt et conditions d'utilisation respectés ;
  • aucune donnée personnelle conservée au passage — la minimisation RGPD allège aussi votre rétention de logs.

Dépannage d'un scraping sous CAPTCHA

Symptôme Cause probable Correctif
Un défi sur chaque page Rythme trop soutenu, IP marquée Répartissez sur plusieurs proxys, espacez
Token refusé après résolution Token expiré, session différente Utilisez-le dans les 120 s, même session
Blocage Cloudflare malgré le cookie Proxy ou User-Agent différents Reprenez le couple proxy + User-Agent
Page différente après résolution Redirection, cookie supplémentaire Suivez les redirections, rejouez avec les cookies
CAPCHA_NOT_READY jusqu'au timeout Paramètres ou pageurl erronés Vérifiez le sitekey et l'URL transmise

Questions fréquentes

CaptchaAI résout-il hCaptcha pour mes crawls ?

Non, hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs). Si un site cible s'appuie sur l'un des deux, cherchez une source de données alternative.

Combien de threads faut-il pour 50 000 pages par nuit ?

Comptez les défis simultanés, pas les pages. Avec 10 % de pages protégées et huit workers en parallèle, huit threads suffisent : chaque thread enchaîne les résolutions sans limite de volume.

Combien de temps un token reste-t-il valide ?

Environ 120 secondes pour reCAPTCHA et Turnstile. Envoyez-le juste après réception ; si votre file d'attente introduit un délai, résolvez au moment de la soumission.

Faut-il un navigateur headless pour chaque page ?

Rarement. Une session HTTP suffit tant que les paramètres du défi sont lisibles dans le HTML servi. Réservez Selenium, Puppeteer ou Playwright aux pages rendues en JavaScript, et consultez la gestion des CAPTCHA en navigateur headless pour lire les paramètres dans le DOM.

Pour aller plus loin

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