Use Cases

Récupération de données immobilières avec gestion CAPTCHA

Un pipeline de collecte immobilière casse rarement sur l'analyse du HTML : il casse le matin où le portail glisse un défi CAPTCHA au milieu de la pagination. La réponse tient en quatre gestes : détectez le type de défi dans la réponse HTTP, envoyez ses paramètres à l'API CaptchaAI, réinjectez le token dans la même session, reprenez à la page interrompue.

Fil conducteur ici : une équipe data qui suit chaque matin les ventes d'un secteur — Grand Lyon, périphérie bruxelloise, couronne genevoise — pour alimenter un tableau de bord. Les portails visés déclenchent leurs défis au pire moment, après des dizaines de pages parcourues.

Quelles données une annonce immobilière apporte réellement

Fixez la liste des champs avant d'écrire une ligne de code : un collecteur qui ramène tout ramène surtout du bruit, et des données personnelles dont vous n'avez pas besoin.

Champ Où le lire Ce qu'il mesure
Prix affiché Page de l'annonce Écarts de prix entre quartiers
Adresse ou secteur Page de l'annonce Analyse géographique
Pièces, salles de bain, surface Caractéristiques Prix au mètre carré
Durée de mise en marché Métadonnées Écoulement du stock
Historique des prix Journal des baisses Tendances
Taxe foncière Registres publics Rendement net
Charges de copropriété Détail de l'annonce Coût de détention

Quelle protection attend derrière quel type de portail

Le type de défi dépend de la nature du site : un agrégateur MLS protège son inventaire bien plus agressivement qu'un annuaire d'agences.

Type de plateforme Protection Type de CAPTCHA
Agrégateurs MLS Cloudflare Challenge Défi complet, proxy requis
Portails grand public façon Zillow reCAPTCHA v3 Invisible, comportemental
Annuaires d'agences reCAPTCHA v2 Case à cocher ou invisible
Registres de taxe foncière CAPTCHA image Reconnaissance de texte
Enchères et ventes notariales Cloudflare Turnstile Défi du widget
Locaux commerciaux reCAPTCHA v2 Enterprise Vérification renforcée

CaptchaAI publie des plafonds par type : moins de 4 s pour reCAPTCHA v3, moins de 10 s pour Turnstile, moins de 15 s pour un Cloudflare Challenge, moins de 60 s pour reCAPTCHA v2, avec un taux de réussite élevé sur les types pris en charge.

Ce que le RGPD change dans une collecte immobilière

Prix, surfaces et dates de publication sont des données de marché. Le nom du vendeur, le téléphone de l'agent ou l'e-mail de contact sont des données personnelles : leur collecte relève du RGPD, même affichées publiquement. Le plus simple est de ne jamais extraire ces champs.

Deux réflexes pour les équipes en France, en Belgique ou en Suisse :

  • Documentez les champs extraits dans le dépôt du projet et respectez les conditions d'utilisation du portail.
  • Regardez d'abord les sources ouvertes. En France, le jeu de données DVF (demandes de valeurs foncières) publie les prix de transaction réels : inutile de scraper des annonces si vous cherchez des ventes conclues.

Rien de ceci n'est un avis juridique : faites valider le périmètre par votre DPO.

Le collecteur Python, du défi au token

Le script ci-dessous tient la boucle complète. solve_captcha() envoie la tâche à in.php, interroge res.php toutes les 5 secondes tant que la réponse vaut CAPCHA_NOT_READY, puis renvoie le token. fetch() distingue reCAPTCHA v2 de v3 via recaptcha/api.js?render=, renvoie le token dans g-recaptcha-response ou cf-turnstile-response, et rejoue la requête sur la même requests.Session().

import requests
import time
import re
import json
import csv
import os
from datetime import datetime

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


def solve_captcha(params):
    params["key"] = API_KEY
    resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
    if not resp.text.startswith("OK|"):
        raise Exception(f"Submit: {resp.text}")

    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 result.text.startswith("OK|"):
            return result.text.split("|", 1)[1]
        raise Exception(f"Solve: {result.text}")
    raise TimeoutError()


class PropertyCollector:
    def __init__(self):
        self.session = requests.Session()
        self.session.headers["User-Agent"] = (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 Chrome/120.0.0.0"
        )

    def fetch(self, url):
        """Fetch page with automatic CAPTCHA handling."""
        resp = self.session.get(url)

        # reCAPTCHA
        match = re.search(
            r'data-sitekey=["\']([A-Za-z0-9_-]+)["\']', resp.text
        )
        if match:
            # Detect v3
            is_v3 = "recaptcha/api.js?render=" in resp.text
            params = {
                "method": "userrecaptcha",
                "googlekey": match.group(1),
                "pageurl": url,
            }
            if is_v3:
                params["version"] = "v3"
                params["action"] = "search"

            token = solve_captcha(params)
            resp = self.session.post(url, data={
                "g-recaptcha-response": token,
            })

        # Turnstile
        if "cf-turnstile" in resp.text:
            match = re.search(r'data-sitekey=["\']([^"\']+)', resp.text)
            if match:
                token = solve_captcha({
                    "method": "turnstile",
                    "sitekey": match.group(1),
                    "pageurl": url,
                })
                resp = self.session.post(url, data={
                    "cf-turnstile-response": token,
                })

        return resp.text

    def collect_listings(self, urls):
        """Collect property listings from multiple pages."""
        listings = []
        for url in urls:
            try:
                html = self.fetch(url)
                page_listings = self._parse_listings(html)
                listings.extend(page_listings)
                print(f"  {len(page_listings)} listings from {url}")
                time.sleep(3)
            except Exception as e:
                print(f"  Error: {url} - {e}")
        return listings

    def _parse_listings(self, html):
        """Extract property data from HTML."""
        listings = []

        # Price extraction
        prices = re.findall(r'\$\s*([\d,]+)', html)
        # Address extraction
        addresses = re.findall(
            r'class="address"[^>]*>(.*?)</(?:div|span|p)', html
        )
        # Bed/Bath extraction
        beds = re.findall(r'(\d+)\s*(?:bed|br|bedroom)', html, re.I)
        baths = re.findall(r'(\d+)\s*(?:bath|ba|bathroom)', html, re.I)
        # Sqft extraction
        sqft = re.findall(r'([\d,]+)\s*(?:sq\s*ft|sqft)', html, re.I)

        # Combine available data
        count = max(len(prices), len(addresses), 1)
        for i in range(min(count, 50)):  # Cap at 50 per page
            listing = {
                "price": prices[i] if i < len(prices) else None,
                "address": (
                    addresses[i].strip() if i < len(addresses) else None
                ),
                "beds": beds[i] if i < len(beds) else None,
                "baths": baths[i] if i < len(baths) else None,
                "sqft": sqft[i] if i < len(sqft) else None,
                "collected_at": datetime.utcnow().isoformat(),
            }
            if listing["price"] or listing["address"]:
                listings.append(listing)

        return listings

    def export_csv(self, listings, filename):
        if not listings:
            print("No listings to export")
            return

        keys = ["price", "address", "beds", "baths", "sqft", "collected_at"]
        with open(filename, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=keys)
            writer.writeheader()
            writer.writerows(listings)
        print(f"Exported {len(listings)} listings to {filename}")


# Usage
collector = PropertyCollector()

search_urls = [
    "https://example-realty.com/search?city=austin&type=sale&page=1",
    "https://example-realty.com/search?city=austin&type=sale&page=2",
    "https://example-realty.com/search?city=austin&type=sale&page=3",
]

listings = collector.collect_listings(search_urls)
collector.export_csv(listings, "austin_listings.csv")

Adaptez _parse_listings() au balisage réel de votre cible : les expressions régulières de l'exemple visent un HTML anglophone. Sur un portail francophone, remplacez bed/bath/sqft par vos sélecteurs de pièces et de surface en mètres carrés.

Combien de threads prévoir pour une collecte quotidienne

CaptchaAI facture au thread concurrent, pas à la résolution : chaque formule inclut des résolutions illimitées par thread sur le mois. Le dimensionnement se lit en parallélisme, pas en volume.

Reprenez le scénario du Grand Lyon : 800 pages par jour, un défi toutes les vingt pages, soit une quarantaine de résolutions concentrées sur deux heures. BASIC ($15/mois, 5 threads) traite cinq défis en parallèle, ce qui suffit pour un marché unique. Une équipe qui suit six agglomérations passe à STANDARD ($30/mois, 15 threads) ; un collecteur national se dimensionne sur ADVANCE ($90/mois, 50 threads). La facturation reste en dollars US.

Deux limites : hCaptcha, FunCaptcha et GeeTest v4 ne sont pas pris en charge (GeeTest v4 est annoncé comme à venir), donc le collecteur doit s'arrêter proprement si un portail bascule dessus ; CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta) restent expérimentaux.

Rythme de collecte : quotidien, hebdomadaire, mensuel

Une collecte utile est régulière : ce sont les écarts entre deux passages qui portent l'information, pas le stock d'un jour donné.

Daily Collection
    → Property listings (500-1000 per market)
    → Price changes (delta from previous day)
    → New listings vs delisted

Weekly Analysis
    → Median price trends
    → Inventory levels
    → Days-on-market averages
    → Price-per-sqft by neighborhood

Monthly Report
    → Market heat map
    → Competitive pricing analysis
    → Investment opportunity scoring

Stockez chaque passage horodaté tel quel et calculez les deltas à l'analyse : vous rejouerez un calcul sans relancer de collecte, donc moins de pression sur les portails et moins de défis rencontrés.

Dépannage des collectes qui décrochent

Problème Cause probable Correctif
Le token passe, puis le défi réapparaît Cookie de session perdu Rejouez dans la même requests.Session(), même IP de sortie
CAPCHA_NOT_READY jusqu'au timeout Interrogation trop rapprochée, method inadapté 5 s entre deux appels à res.php ; vérifiez les paramètres de in.php
reCAPTCHA v3 se déclenche à chaque page Rythme mécanique, score trop bas Espacez les requêtes, évitez les rafales de pagination
Prix décalés d'une ligne Annonces sponsorisées insérées Parcourez le DOM bloc par bloc, pas en regex globale
403 dès la deuxième page (MLS) Plage d'IP datacenter identifiée Proxy résidentiel, paramètres transmis à la résolution

FAQ

Combien de threads faut-il pour collecter 5 000 annonces par jour ?

Comptez en simultanéité, pas en volume. Étalée sur la journée avec un défi toutes les vingt pages, cette collecte tient sur STANDARD ($30/mois, 15 threads) : c'est la compression de la fenêtre horaire qui impose de monter en gamme.

CaptchaAI prend-il en charge hCaptcha sur les portails immobiliers ?

Non — hCaptcha n'est pas pris en charge, FunCaptcha (Arkose Labs) non plus. Les types couverts ici sont reCAPTCHA v2 et v3 (Enterprise inclus), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3 et les CAPTCHA image ou grille.

Faut-il un proxy résidentiel pour les agrégateurs MLS ?

Souvent, oui : leur Cloudflare Challenge tient compte de la réputation de l'IP. Transmettez les paramètres du proxy à la résolution pour que défi et navigation partent de la même sortie.

À quelle fréquence relancer la collecte sans surcharger le portail ?

Un passage quotidien suffit sur un marché résidentiel : les prix bougent en jours, pas en minutes. Gardez la pause de 3 s entre pages.

Guides connexes

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