Comment scraper Zillow sans perdre de données

Découvrez comment scraper chaque annonce Zillow avec son historique de prix grâce à Python, vérifier l’exhaustivité des données, et évoluer avec Bright Data.
7 min de lecture
How to Scrape Zillow blog

Scraper Zillow consiste à collecter des annonces, prix et données immobilières depuis les pages publiques de Zillow pour des modèles de tarification, des outils d’analyse d’investissement et des tableaux de bord de marché. Chaque page intègre ses données principales en JSON.

Zillow bloque les requêtes Python par défaut, mais c’est le problème le plus facile. Le plus difficile, ce sont les données que votre scraper manque sans vous prévenir. La plupart des pages d’annonces n’incluent pas l’historique des prix dans leur HTML, et une recherche s’arrête à 984 annonces, soit seulement 6 % des maisons à vendre à Houston.

Ce guide vous montre comment scraper Zillow avec Python, obtenir chaque annonce avec son historique de prix, et décider quand un scraper géré coûte moins cher.

TL;DR

  • Utilisez l’empreinte TLS d’un navigateur. Zillow bloque l’empreinte TLS par défaut de Python, même avec des en-têtes Chrome. curl_cffi imitant Chrome a obtenu 429 pages sur 430.
  • Lisez le JSON intégré à la page, et récupérez l’historique des prix séparément. La plupart des pages d’annonces le chargent via une seconde requête que les scrapers HTML seuls ne font jamais.
  • Une recherche s’arrête à 984 annonces, donc divisez les grandes recherches. Ou utilisez l’API Scraper Zillow de Bright Data, qui a trouvé les 1 295 annonces de Fort Wayne depuis une seule URL.
  • Vérifiez l’exhaustivité, pas seulement les codes de statut. Comparez votre nombre de lignes avec le total rapporté par Zillow, relancez les lignes échouées, et signalez les maisons déjà vendues sans historique de prix.

Quelles données Zillow vous pouvez scraper et où les trouver

Votre scraper peut obtenir des données Zillow depuis 4 sources principales, et le fichier robots.txt de Zillow traite chacune différemment :

Données Source sur Zillow Ce que dit robots.txt
Champs d’annonce : prix, chambres, Zestimate, photos, agent immobilier JSON __NEXT_DATA__ dans le HTML de la page d’annonce Autorisé pour les pages /homedetails/
Historique des prix, historique fiscal, écoles Généralement une requête /graphql/ envoyée par la page après son chargement Interdit pour /graphql/
Résultats de recherche, 41 annonces par page Pages de villes comme /fort-wayne-in/2_p/, ou le point de terminaison interne async-create-search-page-state Autorisé pour les pages de villes, interdit pour le point de terminaison et les URL ?searchQueryState=
Annonces modifiées Sitemaps liés depuis robots.txt, comme latest.xml.gz Publié pour les robots d’indexation

Les pages de Zillow sont construites avec Next.js, et chaque page intègre ses données principales en JSON dans une balise script __NEXT_DATA__. Vous pouvez lire ce JSON sans rendre la page. Notre guide sur le scraping de sites Next.js explique comment fonctionnent ces données intégrées.

Vos requêtes pour une annonce et une recherche suivent ce chemin :

Diagramme du chemin de requête de Zillow en 3 lignes. La vérification des robots HUMAN à la périphérie CloudFront bloque une requête avec HTTP 403 ou sert la page avec HTTP 200. Le HTML de la page contient prix, chambres et photos, et l'historique des prix et fiscal provient d'une seconde requête /graphql/. Une recherche s'arrête à 24 pages, soit 984 annonces, et la page 25 renvoie HTTP 400.

La vérification des robots HUMAN s’exécute à la périphérie avant même qu’aucun HTML n’existe, et l’historique des prix se charge après la page. Si votre analyseur ne lit que le HTML, il peut confondre un blocage avec une page vide, et il manque l’historique des prix sur la plupart des annonces.

Pages d’annonces et historique des prix

Vous devez analyser le JSON de l’annonce deux fois, car il est doublement encodé. Le champ props.pageProps.componentProps.gdpClientCache avait une valeur de type chaîne, et cette chaîne était elle-même un objet JSON. Ses clés étaient les noms des requêtes GraphQL internes de Zillow.

Les annonces à vendre, hors marché et Zillow Showcase utilisent chacune leur propre nom d’opération GraphQL. ForSalePriorityQuery avait 101 champs, NotForSalePriorityQuery en avait 122, et ViewShowcasePriorityQuery en avait 78. Si votre analyseur attend un seul nom d’opération, il échoue sur les autres. Fusionnez plutôt chaque entrée du cache, et un seul analyseur gère les 3 types d’annonces.

L’historique des prix et fiscal était absent de ce JSON sur la plupart des annonces. Pour 20 annonces à vendre, l’API Scraper Zillow de Bright Data a renvoyé entre 1 et 18 événements d’historique de prix chacune. Le HTML de ces mêmes pages n’en contenait aucun. Si votre scraper ne lit que le HTML, il enregistre ces maisons comme n’ayant aucun historique de prix et ne lève aucune erreur.

Le navigateur demande l’historique après le chargement de la page. Pour une maison à vendre, cette requête (ForSaleNonPriorityQuery) renvoie 145 champs de propriété, dont 21 ans d’historique fiscal. Une page hors marché utilise une requête différente, une requête GraphQL persistante que la page appelle par son hachage.

Pages de recherche et la limite de 984 annonces

Une page complète de résultats de recherche contenait 41 annonces dans son bloc __NEXT_DATA__, mais seulement 9 d’entre elles étaient rendues comme cartes HTML. Si vous analysez les cartes avec des sélecteurs CSS, vous manquez les 32 autres. Les sélecteurs de cartes des anciens tutoriels Zillow, comme article.list-card, ne correspondaient à aucun élément.

Zillow limite également chaque recherche. Sa propre pagination s’arrête à la page 20, même pour une recherche annonçant plus de 1K résultats :

Résultats de recherche Fort Wayne de Zillow, avec le haut et le bas de la même liste. L'en-tête indique "Fort Wayne IN Real Estate & Homes For Sale" et "1,289 results," et la barre de pagination ci-dessous affiche les pages 1 à 5, des points de suspension, et 20.

Le haut et le bas de la liste de résultats de Fort Wayne sur Zillow. Le total (1 289 ici) change à mesure que des annonces sont ajoutées et supprimées, mais les numéros de page s’arrêtent tout de même à 20.

Vingt pages de 41 annonces font 820, la limite documentée par certains outils de scraping. Mais les pages 21 à 24 renvoyaient encore chacune 41 annonces supplémentaires lorsqu’elles étaient demandées par URL, et la page 25 renvoyait une erreur HTTP 400. Une recherche atteignait donc 984 annonces, soit 76 % des 1 295 annonces à vendre de Fort Wayne et 6 % des 15 410 de la ville de Houston. Chaque réponse rapportait toujours le total complet, alors comparez ce chiffre avec ce que vous collectez.

Analysez le JSON plutôt que le balisage, et vous obtenez les 41 annonces par page au lieu de 9.

Pourquoi Zillow bloque les requêtes Python par défaut

La protection anti-robots de Zillow provient de HUMAN Security, anciennement PerimeterX. Deux détails le révèlent : la page de blocage nomme l’ID d’application PerimeterX PXHYx10rg3, et robots.txt interdit /captchaPerimeterX/ et /HYx10rg3/.

Le refus provenait de CloudFront, le réseau de diffusion de contenu (CDN) devant Zillow, et non des serveurs propres de Zillow. Chaque réponse bloquée était un HTTP 403 avec x-cache: LambdaGeneratedResponse et x-px-blocked: 1. Elle manquait également l’en-tête x-envoy-upstream-service-time que les pages normales incluaient. Votre scraper peut vérifier cet en-tête x-px-blocked pour détecter un blocage sans analyser la page.

À quoi ressemble une requête bloquée

Une requête curl avec un User-Agent Chrome a été refusée :

curl -s -o /dev/null -D - \
  -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" \
  "https://www.zillow.com/homedetails/25875-Fox-Tail-Trl-South-Bend-IN-46628/77039629_zpid/"

La périphérie a répondu en 0,32 seconde avec la page “Press & Hold” de 5 952 octets :

HTTP/2 403
server: CloudFront
content-length: 5952
x-px-blocked: 1
x-cache: LambdaGeneratedResponse from cloudfront

Un Chromium headless lancé depuis Playwright a obtenu le même blocage, HTTP 403 avec x-px-blocked: 1, et a affiché le défi HUMAN :

Page Press & Hold de Zillow issue de la protection anti-robots HUMAN. Le logo Zillow est au-dessus du message "Press & Hold to confirm you are a human (and not a bot)" et d'un bouton Press & Hold.

Si votre scraper reçoit cette page, il a été refusé à la périphérie, et la page ne contient aucune donnée d’annonce.

Pour gérer cette page sans votre propre code de contournement, utilisez le solveur CAPTCHA Zillow de Bright Data.

Quels clients Python passent la vérification anti-robots de Zillow

Depuis la même IP, quelques secondes après que curl a été refusé, curl_cffi a récupéré la même annonce. Cette bibliothèque imite la négociation TLS et les en-têtes de Chrome :

python - <<'EOF'
from curl_cffi import requests

url = "https://www.zillow.com/homedetails/25875-Fox-Tail-Trl-South-Bend-IN-46628/77039629_zpid/"
r = requests.get(url, impersonate="chrome")
print(r.status_code, len(r.content))
EOF

Elle a affiché un HTTP 200 et 730 803 octets de HTML :

200 730803

Ces 2 requêtes différaient par leurs en-têtes et leur empreinte TLS. Une empreinte TLS est le motif de suites de chiffrement et d’extensions qu’un client propose lors de l’ouverture d’une connexion. Notre guide sur l’empreinte TLS explique comment les sites utilisent ces empreintes pour détecter les robots.

Depuis une IP résidentielle que Zillow n’avait pas bloquée, 6 clients ont récupéré chacun une page d’annonce et une page de recherche, avec le même résultat pour les deux :

Client Empreinte TLS Résultat
Python requests, en-têtes par défaut Python (OpenSSL) 403
Python requests, User-Agent Chrome et en-têtes de navigateur Python (OpenSSL) 403
httpx, en-têtes de navigateur Python (OpenSSL) 403
curl_cffi, impersonate="chrome" Chrome 200
curl_cffi, impersonate="firefox" Firefox 200
curl_cffi, impersonate="safari" Safari 403

Si votre client utilise l’empreinte TLS de Python, les en-têtes de navigateur n’aident pas. Le profil Safari a également été refusé sur les deux pages, donc testez chaque profil d’imitation contre Zillow avant de vous y fier. Le profil Chrome a aussi réussi lorsque HTTP/1.1 était forcé, donc les paramètres HTTP/2 n’étaient pas le facteur décisif.

Pourquoi les en-têtes et la réputation IP comptent encore

Zillow vérifie également si les en-têtes HTTP de votre requête ressemblent à ceux d’un vrai navigateur. Lorsqu’un proxy utilisait l’empreinte TLS de Chrome et une nouvelle IP américaine pour chaque requête, les en-têtes seuls déterminaient le résultat.

Le User-Agent par défaut de Python a été refusé 5 fois sur 5, et un User-Agent Chrome seul a réussi 5 fois sur 5. L’ensemble complet des en-têtes de Chrome sans ses indices client sec-ch-ua a été refusé 4 fois sur 10. Cela suggère que Zillow attribue un score de risque à chaque requête plutôt que d’appliquer une règle fixe.

La réputation de votre IP compte aussi. Environ 20 requêtes ont été envoyées depuis une IP résidentielle, et la plupart provenaient de clients que Zillow a refusés. Après cela, Zillow a refusé même le profil Chrome qui avait réussi quelques minutes plus tôt, et le blocage s’est terminé en moins de 6 minutes.

Les sessions avec l’empreinte TLS de Chrome et des cookies réutilisés se sont comportées différemment. Sur 5 sessions de ce type, 429 des 430 requêtes d’annonces ont renvoyé la page. Le seul échec était un HTTP 502, pas un blocage. HUMAN n’a refusé aucune des 430, y compris 250 requêtes depuis une IP en 12 minutes.

Traitez donc un refus comme un signal pour mettre cette IP en pause, pas pour réessayer immédiatement. Mesurez le taux de blocage par IP avant de décider du nombre d’IP proxy nécessaires. Pour la rotation d’IP et d’autres moyens de maintenir un faible taux de blocage, consultez notre guide sur le scraping web sans se faire bloquer.

Scraper Zillow avec Python et curl_cffi

Le scraper ci-dessous lit le même JSON que la page utilise. Il nécessite Python 3.10 ou plus récent et curl_cffi, que notre guide sur le scraping web avec curl_cffi présente. Installez la bibliothèque dans un environnement virtuel :

python3 -m venv .venv
source .venv/bin/activate  # sous Windows : .venv\Scripts\activate
pip install curl_cffi

Ce script, zillow_diy.py, récupère une page de recherche Fort Wayne, puis lit 3 annonces qui s’y trouvent :

"""Scraper Zillow avec l'empreinte TLS d'un navigateur et le JSON intégré de la page."""
import json
import re
import time

from curl_cffi import requests

NEXT_DATA = re.compile(
    r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S
)
# TLS Chrome + en-têtes correspondants ; gardez une session par IP
session = requests.Session(impersonate="chrome")


def next_data(url: str) -> dict:
    resp = session.get(url, timeout=30)
    match = NEXT_DATA.search(resp.text)
    # Un blocage peut être un 403 ou une page captcha en 200,
    # testez donc les données, pas le statut
    if resp.headers.get("x-px-blocked") or not match:
        raise RuntimeError(f"pas de données de page pour {url} (HTTP {resp.status_code})")
    resp.raise_for_status()  # la page 404 de Zillow contient aussi __NEXT_DATA__
    return json.loads(match.group(1))


def search_page(city: str, page: int) -> tuple[list[dict], int]:
    """Une page de résultats rendue côté serveur, plus le total rapporté par Zillow."""
    url = f"https://www.zillow.com/{city}/{page}_p/"
    cat1 = next_data(url)["props"]["pageProps"]["searchPageState"]["cat1"]
    return cat1["searchResults"]["listResults"], cat1["searchList"]["totalResultCount"]


def listing(url: str) -> dict:
    """La partie rendue côté serveur d'une annonce : gdpClientCache est un JSON doublement encodé."""
    props = next_data(url)["props"]["pageProps"]["componentProps"]
    cache = json.loads(props["gdpClientCache"])
    record = {}
    for query_result in cache.values():  # ex. ForSalePriorityQuery{...}
        record.update(query_result.get("property") or {})
    return record


if __name__ == "__main__":
    cards, total = search_page("fort-wayne-in", 1)
    print(f"page 1: {len(cards)} annonces sur {total} rapportées")
    for card in cards[:3]:
        time.sleep(2)
        home = listing(card["detailUrl"])
        history = home.get("priceHistory") or []
        print(home["zpid"], home["homeStatus"], home["price"], home["bedrooms"], "bd",
              home["livingArea"], "sqft | priceHistory:", len(history))

Exécuté avec python zillow_diy.py, il a trouvé 41 annonces sur la page 1, et 2 des 3 annonces ouvertes n’avaient aucun historique de prix :

page 1: 41 annonces sur 1295 rapportées
73145681 FOR_SALE 649900 4 bd 5124 sqft | priceHistory: 3
73159210 FOR_SALE 699900 4 bd 4132 sqft | priceHistory: 0
73110917 FOR_SALE 239900 3 bd 1468 sqft | priceHistory: 0

La première annonce était une annonce Showcase, qui incluait son historique de prix dans le HTML. Pour les 2 autres, l’API Scraper Zillow a renvoyé 2 et 3 événements d’historique de prix, et le HTML en avait 0. L’ensemble de l’exécution a pris environ 11 secondes, pauses de 2 secondes incluses.

Cette approche présentait 2 limites. D’abord, la page 24 des pages de ville de Fort Wayne renvoyait encore 41 annonces, mais la page 25 renvoyait une erreur HTTP 400. Si votre ville a plus d’annonces qu’une seule recherche ne peut en atteindre, vous avez besoin de recherches plus petites. Ensuite, l’historique des prix manquant ne provenait que de /graphql/.

Diviser une ville implique généralement des recherches par code postal ou filtrées, qui utilisent les URL /homes/ et ?searchQueryState=, et l’historique des prix manquant nécessite la requête /graphql/. Le fichier robots.txt de Zillow interdit les trois, donc les appeler est une décision de conformité pour votre équipe. Notre guide sur robots.txt pour le scraping web explique comment lire et respecter ces règles.

Pour exécuter ce scraper depuis de nombreuses IP, transmettez un proxy résidentiel Bright Data à la session. Pour des connexions chiffrées de bout en bout, chargez le certificat Bright Data pour le port 44445 avec verify="brightdata_root_ca_44445.crt".

Les IPs résidentielles proviennent de vraies personnes ayant donné leur accord, donc les proxys résidentiels Bright Data nécessitent d’abord une vérification KYC (connaissance du client) d’entreprise approuvée. La vérification approuve des cas d’usage spécifiques, donc décrivez votre pipeline Zillow lors de votre demande. Sans vérification KYC, l’API Web Unlocker gère le déblocage pour vous, et l’API Scraper Zillow de la section suivante renvoie des enregistrements d’annonces avec historique de prix.

Scraper Zillow avec l’API Scraper Zillow

L’API Scraper Zillow exécute la collecte sur l’infrastructure Bright Data, gère le déblocage et l’analyse pour vous, et renvoie des enregistrements JSON. Vous payez par enregistrement livré, et les nouveaux comptes reçoivent 5K crédits gratuits renouvelés chaque mois. Elle inclut ces scrapers Zillow, que les sections ci-dessous appellent les scrapers d’annonces, d’historique de prix, de page de recherche et Full Properties :

Scraper ID du jeu de données Entrée Sortie
Informations sur les annonces de propriétés Zillow gd_lfqkr8wm13ixtbd8f5 URL d’annonce, URL de recherche, ou filtres de localisation 1 enregistrement par annonce, jusqu’à 139 champs
Historique des prix Zillow gd_lxu1cz9r88uiqsosl URL d’annonce 1 ligne par événement d’historique de prix
Page de recherche de propriétés Zillow gd_m71oxpvxwr113ehqg URL de recherche avec searchQueryState 1 ligne par résultat de recherche, 26 champs
Informations complètes sur les propriétés Zillow gd_m794g571225l6vm7gh URL d’annonce ou de recherche 1 enregistrement par annonce, avec des noms de champs en snake_case comme price_history

Les 4 collectent par URL via la même requête API, vous changez donc seulement l’ID du jeu de données. Bright Data propose aussi une API Scraper d’Images Zillow et des scrapers pour d’autres sites immobiliers dans sa gamme API Scraper Immobilier. Dans le panneau de contrôle, le scraper d’annonces affiche ses 3 méthodes d’entrée et les modes synchrone et asynchrone :

Panneau de contrôle Bright Data sur la page de collecte par URL des informations d'annonces de propriétés Zillow. Le panneau de gauche liste les 4 scrapers Zillow et les 3 méthodes d'entrée du scraper d'annonces, et le centre affiche une URL d'annonce en entrée avec Synchrone (temps réel) sélectionné.

Le panneau de gauche liste les mêmes 4 scrapers que le tableau ci-dessus.

Collecter les annonces et l’historique des prix

Pour exécuter le script ci-dessous, zillow_api.py, vous avez besoin du paquet requests (pip install requests) et d’un jeton API depuis le panneau de contrôle. Sa fonction collect() appelle le point de terminaison synchrone et passe en interrogation lorsqu’une tâche dure plus d’1 minute :

"""Annonces, historique des prix et découverte avec l'API Scraper Zillow de Bright Data."""
import os
import time

import requests

API = "https://api.brightdata.com/datasets/v3"
HEADERS = {"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_TOKEN']}"}
LISTINGS = "gd_lfqkr8wm13ixtbd8f5"       # Informations sur les annonces de propriétés Zillow
PRICE_HISTORY = "gd_lxu1cz9r88uiqsosl"   # Historique des prix Zillow, une ligne par événement


def collect(dataset_id: str, urls: list[str]) -> list[dict]:
    """Jusqu'à 20 URLs par appel synchrone ; une tâche de plus d'1 minute renvoie 202."""
    params = {"dataset_id": dataset_id, "include_errors": "true", "format": "json"}
    resp = requests.post(f"{API}/scrape", headers=HEADERS, params=params, timeout=120,
                         json={"input": [{"url": u} for u in urls]})
    if resp.status_code == 202:
        return wait_and_download(resp.json()["snapshot_id"])
    resp.raise_for_status()
    return resp.json()


def discover(search_url: str) -> list[dict]:
    """Chaque annonce d'une recherche Zillow, collectée en enregistrements complets (async)."""
    resp = requests.post(f"{API}/trigger", headers=HEADERS, timeout=60,
                         params={"dataset_id": LISTINGS, "include_errors": "true",
                                 "type": "discover_new", "discover_by": "url"},
                         json={"input": [{"url": search_url}]})
    resp.raise_for_status()
    return wait_and_download(resp.json()["snapshot_id"])


def wait_and_download(snapshot_id: str, poll_s: int = 15) -> list[dict]:
    while True:
        resp = requests.get(f"{API}/progress/{snapshot_id}", headers=HEADERS,
                            timeout=60)
        resp.raise_for_status()
        status = resp.json()["status"]
        if status == "ready":
            break
        if status in ("failed", "canceled"):
            raise RuntimeError(f"snapshot {snapshot_id} terminé comme {status}")
        time.sleep(poll_s)
    while True:  # le téléchargement renvoie 202 tant que le fichier est en préparation
        resp = requests.get(f"{API}/snapshot/{snapshot_id}", headers=HEADERS,
                            params={"format": "json"}, timeout=300)
        if resp.status_code != 202:
            resp.raise_for_status()
            return resp.json()
        time.sleep(10)


if __name__ == "__main__":
    homes = collect(LISTINGS, [
        "https://www.zillow.com/homedetails/25875-Fox-Tail-Trl-South-Bend-IN-46628/77039629_zpid/",
    ])
    for h in homes:
        print(h["zpid"], h["homeStatus"], h["price"], "| zestimate", h.get("zestimate"),
              "| priceHistory", len(h.get("priceHistory") or []),
              "| taxHistory", len(h.get("taxHistory") or []),
              "| collecté", h.get("timestamp"))
    events = collect(PRICE_HISTORY, [
        "https://www.zillow.com/homedetails/2506-Gordon-Cir-South-Bend-IN-46635/77050198_zpid/",
    ])
    for e in events:
        print(e["zpid"], e["date"][:10], e["event"], e["price"], e["source"])

Avec BRIGHTDATA_API_TOKEN exporté, les deux appels ont pris 35 à 65 secondes ensemble :

77039629 FOR_SALE 359000 | zestimate 351600 | priceHistory 1 | taxHistory 21 | collecté 2026-09-27T11:30:53.846Z
77050198 2023-03-03 Sold 265000 IRMLS
77050198 2023-02-03 Pending sale 264900 IRMLS
77050198 2023-02-01 Listed for sale 264900 IRMLS

L’enregistrement d’annonce correspondait à ce que la propre page de Zillow chargeait, avec 1 événement d’historique de prix et 21 ans d’historique fiscal. Son timestamp montrait que Bright Data l’a collecté en direct, pendant la requête.

Les 3 événements pour la seconde maison, 2506 Gordon Cir, correspondent au tableau d’historique des prix sur sa page Zillow :

Tableau d'historique des prix de Zillow pour 2506 Gordon Cir à South Bend. Il liste Vendu le 3/3/2023 à 265 000 $, Vente en cours le 2/3/2023 à 264 900 $, et Mis en vente le 2/1/2023 à 264 900 $, chacun avec la source IRMLS.

Chaque ligne nomme sa source, un service de listing multiple (MLS) local, que la sortie de l’API conserve dans le champ source.

Le point de terminaison synchrone /scrape accepte jusqu’à 20 URL et renvoie les données directement si la tâche se termine en moins d’1 minute. Pour une tâche plus longue, il répond avec HTTP 202 et un ID de snapshot à la place. Un lot de 20 annonces de Fort Wayne a pris environ 4 minutes, donc attendez-vous à quelques minutes d’interrogation avant que votre script n’affiche quelque chose. Pour des lots plus importants, utilisez le point de terminaison asynchrone /trigger, que la section suivante utilise pour la découverte.

Découvrir chaque annonce dans une recherche

La découverte collecte des enregistrements d’annonces complets depuis une URL de recherche et peut dépasser la limite de 984 annonces qui a arrêté le scraper fait maison. La fonction discover() ci-dessus envoie l’URL de la ville de Fort Wayne, https://www.zillow.com/fort-wayne-in/, à la méthode d’entrée Découvrir par URL du scraper d’annonces dans une requête asynchrone /trigger. En 13 à 17 minutes, elle a renvoyé 1 295 lignes, une pour chaque annonce rapportée par Zillow. Pour un petit échantillon à la place, transmettez jusqu’à 20 URL d’annonces depuis search_page() dans zillow_diy.py à collect().

Pour limiter le nombre d’enregistrements que chaque URL de recherche renvoie, par exemple lors d’un test, envoyez {"input": [...], "limit_per_input": 50} comme corps JSON. Au lieu d’interroger, vous pouvez faire en sorte que /trigger livre les résultats vers un webhook ou S3, et chaque snapshot reste téléchargeable pendant 30 jours.

Lorsque vous avez seulement besoin des champs de résultats de recherche, comme le prix, les chambres et l’adresse, le scraper de page de recherche est plus rapide. Depuis une URL de recherche Fort Wayne avec searchQueryState, il a renvoyé 1 154 annonces uniques en 65 secondes. Utilisez la découverte lorsque vous avez besoin de chaque annonce.

Obtenir l’historique des prix pour les maisons hors marché

Pour les maisons à vendre, le champ priceHistory du scraper d’annonces correspondait à la propre page de Zillow sur 4 annonces sur 4. Pour les maisons hors marché, utilisez le scraper Full Properties. Sur 6 maisons hors marché sur 6, il a renvoyé l’historique des prix dans un tableau price_history en snake_case, 1 enregistrement par maison.

Pour les mêmes 6 maisons, le scraper d’historique des prix a renvoyé 1 ligne par événement : 28 lignes en 37 secondes. Les deux sorties correspondaient exactement.

La facturation est par enregistrement livré, donc une maison avec 5 événements vous coûte 5 enregistrements du scraper d’historique des prix et 1 de Full Properties. Les annonces de Fort Wayne avaient en moyenne 5 événements, avec un maximum de 29. Envoyez donc les URL hors marché au scraper Full Properties, et lisez price_history plutôt que priceHistory. Utilisez le scraper d’historique des prix lorsque vous voulez 1 ligne par événement.

Exécuter le même scraper depuis un terminal ou un agent IA

Le CLI Bright Data exécute le scraper d’annonces comme une commande de pipeline, adaptée aux tâches ponctuelles et aux agents de codage. Après l’avoir installé avec npm install -g @brightdata/cli et exécuté bdata login une fois, une commande renvoie une annonce :

bdata pipelines zillow_properties_listing \
  "https://www.zillow.com/homedetails/25875-Fox-Tail-Trl-South-Bend-IN-46628/77039629_zpid/"

En moins de 3 minutes, il a affiché le même enregistrement que l’API, sur une ligne de JSON qui commence par :

[{"zpid":77039629,"city":"South Bend","state":"IN","homeStatus":"FOR_SALE","address":{"city":"South Bend","streetAddress":"25875 Fox Tail Trl","zipcode":"46628","state":"IN"},"bedrooms":3,"bathrooms":2,"price":359000,"yearBuilt":1996,

Si votre assistant IA utilise le protocole de contexte de modèle (MCP), le serveur MCP Bright Data expose le même scraper comme l’outil web_data_zillow_properties_listing. Pour utiliser cet outil, activez le groupe d’outils business du serveur. Chaque enregistrement inclut un timestamp, afin que votre agent puisse confirmer l’actualité d’un prix avant de le citer.

Pour un agent complet construit sur le même serveur, consultez notre guide sur la création d’un agent IA immobilier avec CrewAI.

Garder les données Zillow complètes et à jour

Avant que les enregistrements n’atteignent votre entrepôt de données, vérifiez chaque exécution par rapport au total rapporté par Zillow. Les vérifications ci-dessous signalent aussi les lignes d’erreur, les annonces dupliquées, et l’historique de prix manquant. Un sitemap des annonces modifiées maintient ensuite vos exécutions répétées légères.

Valider chaque enregistrement

Une réponse 200 confirme que la requête a réussi, pas que les données sont complètes. Ce validateur, enregistré sous validate.py, sépare les lignes d’erreur, supprime les annonces dupliquées, et signale les maisons déjà vendues mais sans historique de prix. Il compte aussi les enregistrements collectés il y a plus de 24 heures comme obsolètes et compare votre nombre d’enregistrements avec le total rapporté par Zillow :

"""Vérifications à exécuter sur les enregistrements Zillow avant qu'ils n'atteignent l'entrepôt."""
from datetime import datetime, timedelta, timezone


def validate(rows: list[dict], reported_total: int | None = None,
             max_age_h: int = 24) -> dict:
    errors = [r for r in rows if r.get("error_code") or r.get("error")]
    records, seen = [], set()
    for r in rows:
        if r in errors or r.get("zpid") in seen:
            continue
        seen.add(r.get("zpid"))
        records.append(r)
    # Une maison déjà vendue a un historique de prix, donc l'absence d'historique est une lacune, pas un fait.
    history_gaps = [r["url"] for r in records
                    if (r.get("lastSoldPrice") or r.get("dateSoldString"))
                    and not r.get("priceHistory")]
    cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_h)
    stale = [r["url"] for r in records
             if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) < cutoff]
    report = {"records": len(records), "error_rows": len(errors),
              "duplicates": len(rows) - len(errors) - len(records),
              "history_gaps": history_gaps, "stale": len(stale),
              "retry": [e.get("input", {}).get("url") for e in errors]}
    if reported_total:
        report["coverage"] = round(len(records) / reported_total, 3)
    return report

Le script run_pipeline.py exécute la découverte pour Fort Wayne puis valide les lignes :

"""Découvrir chaque annonce d'une recherche Zillow, puis valider le résultat."""
import json
import time

from validate import validate
from zillow_api import discover

start = time.time()
rows = discover("https://www.zillow.com/fort-wayne-in/")
report = validate(rows, reported_total=1295)  # total de Zillow, ex. depuis search_page()
print(f"{len(rows)} lignes en {round((time.time() - start) / 60, 1)} min")
counts = {k: (len(v) if isinstance(v, list) else v) for k, v in report.items()}
print(json.dumps(counts))
json.dump(rows, open("fort_wayne.json", "w"))

La découverte a renvoyé 1 295 lignes, et le validateur en a conservé 1 270 comme enregistrements :

1295 lignes en 12.9 min
{"records": 1270, "error_rows": 25, "duplicates": 0, "history_gaps": 0, "stale": 0, "retry": 25, "coverage": 0.981}

La couverture était de 98,1 % des 1 295 annonces rapportées par Zillow. L’exécution n’avait aucun doublon, et aucun enregistrement n’a échoué à la vérification de l’historique des prix. La vérification signale seulement les maisons déjà vendues, donc elle ignore les maisons qui n’ont jamais eu d’historique de prix, comme les nouveaux projets de construction.

Un prix de Vente manquant dans vos données n’est pas toujours une erreur de scraping. Dans 8 annonces de Houston, aucun des 2 événements de Vente n’avait de prix, comme attendu au Texas, où les prix de vente ne sont pas des informations publiques.

Envoyez toutes les URL que la vérification d’historique de prix signale au scraper Full Properties, et resoumettez les URL de retry. Pour toute annonce qui manque encore d’un enregistrement complet, les résultats du scraper de page de recherche incluent le prix, les chambres et l’adresse.

Rafraîchir seulement les annonces modifiées

Le fichier robots.txt de Zillow listait un index de sitemap pour chaque statut d’annonce. L’index des ventes par agent incluait un sitemap latest.xml.gz. Ce script, changed_listings.py, le lit via l’API Web Unlocker et conserve les annonces de l’Indiana. Avant de l’exécuter, créez une zone Web Unlocker dans le panneau de contrôle et mettez son nom dans le script :

"""Lister les annonces Zillow récemment modifiées dans un état, depuis un sitemap lié depuis robots.txt."""
import gzip
import os
import re

import requests

SITEMAP = "https://www.zillow.com/xml/sitemaps/us/hdp/for-sale-by-agent/latest.xml.gz"
HEADERS = {"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_TOKEN']}"}


def unlock(url: str, attempts: int = 2) -> bytes:
    # remplacez "web_unlocker" par le nom de votre zone Web Unlocker
    payload = {"zone": "web_unlocker", "url": url, "format": "raw", "country": "us"}
    for _ in range(attempts):  # réessayer une fois en cas d'erreur transitoire
        resp = requests.post("https://api.brightdata.com/request", headers=HEADERS,
                             json=payload, timeout=180)
        # Web Unlocker renvoie le code de statut propre de la cible dans cet en-tête
        if resp.headers.get("x-brd-status-code") == "200":
            return resp.content
    raise RuntimeError(f"{url}: {resp.headers.get('x-brd-status-code')} "
                       f"{resp.headers.get('x-brd-error')}")


body = unlock(SITEMAP)
# le fichier peut arriver déjà décodé
xml = (gzip.decompress(body) if body[:2] == b"\x1f\x8b" else body).decode()
entries = re.findall(r"<loc>([^<]+)</loc>\s*<lastmod>([^<]+)</lastmod>", xml)
indiana = [(url, mod) for url, mod in entries if re.search(r"-IN-\d{5}/", url)]
dates = [mod for _, mod in entries]
print(f"{len(entries)} annonces modifiées à l'échelle nationale, {len(indiana)} dans l'Indiana")
print("changement le plus ancien :", min(dates), "| le plus récent :", max(dates))
print(indiana[0][0])

Le fichier listait 50K annonces modifiées à l’échelle nationale, dont 1 014 dans l’Indiana :

50000 annonces modifiées à l'échelle nationale, 1014 dans l'Indiana
changement le plus ancien : 2026-09-24T14:08:00Z | le plus récent : 2026-09-27T07:51:00Z
https://www.zillow.com/homedetails/600-W-Cyprus-Cir-Bloomington-IN-47403/94517948_zpid/

Un fichier sitemap peut lister au maximum 50K URL, et ces 50K changements couvraient environ 3 jours. Une exécution quotidienne peut donc recollecter seulement les annonces de votre zone modifiées depuis la dernière exécution, ce qui maintient son coût bas. Envoyez-les à collect() 20 URL à la fois, ou en une seule tâche /trigger. Le fichier liste ce qui a changé, pas chaque maison sur Zillow.

Les sitemaps des 4 index de vente listaient 749 des 1 154 annonces de Fort Wayne trouvées par le scraper de page de recherche, soit 65 %. Aucune des 217 annonces de nouvelles constructions n’apparaissait. Utilisez les sitemaps pour décider quoi recollecter, et utilisez la découverte pour trouver ce qui existe.

Ce que chaque approche coûte à grande échelle

La façon la moins chère de récupérer une page n’est souvent pas la façon la moins chère d’obtenir un jeu de données complet. Chaque approche ci-dessous a renvoyé des données Zillow, mais elles diffèrent par leur exhaustivité et le travail qu’il vous reste à faire. Les prix sont les tarifs catalogue au moment de la publication, et notre page de tarifs indique les tarifs actuels :

Approche Ce que vous payez Résultat Travail restant pour vous
curl_cffi sur vos propres IP Vos serveurs et trafic proxy 429 pages sur 430 servies ; recherches limitées à 984 annonces ; généralement pas d’historique de prix dans le HTML Mises à jour d’empreintes, réputation IP, division des recherches, historique des prix
API Web Unlocker 1,50 $ par 1K requêtes réussies 10 pages d’annonces sur 10, médiane 4,5 s Analyse, division des recherches, historique des prix
API Navigateur 8 $ par Go 3,97 Mo par annonce, environ 0,03 $ ; historique des prix rendu Scripts de navigateur, analyse
API Scraper Zillow 1,50 $ par 1K enregistrements Les 1 295 annonces de Fort Wayne trouvées depuis une seule URL ; historique des prix pour les maisons à vendre et hors marché Validation
Jeux de données Zillow Jusqu’à 2,50 $ par 1K enregistrements, commande minimale de 250 $ 137,42 M d’enregistrements de propriétés sur la marketplace, principalement des maisons hors marché Choix d’un calendrier de mise à jour

Web Unlocker facture seulement les requêtes réussies et renvoie le code de statut du site cible dans l’en-tête x-brd-status-code, vérifiez donc cet en-tête dans votre code. Une requête pour une annonce manquante, par exemple, a renvoyé x-brd-status-code: 404. L’API Navigateur a rendu le tableau d’historique des prix sans code supplémentaire, ce qui convient aux pages nécessitant une session de navigateur complète.

L’approche maison a également un coût de maintenance qu’aucun prix du tableau ne montre. Un développeur qui exécute un scraper Zillow quotidien pour des clients a rapporté qu’il casse chaque trimestre lorsque Zillow déploie des mises à jour. Les pages de Zillow expliquent pourquoi. Chaque type d’annonce utilisait son propre nom d’opération GraphQL dans le JSON, et les sélecteurs de cartes des anciens tutoriels, comme article.list-card, ne correspondaient à rien.

Les jeux de données ont un objectif différent : le jeu de données d’annonces Zillow de Bright Data contient plus de 137 M d’enregistrements de propriétés. La plupart de ces enregistrements concernent des maisons hors marché, ce qui convient aux modèles d’évaluation et à la recherche nécessitant chaque propriété, pas seulement les maisons à vendre. Les acheteurs peuvent obtenir des mises à jour quotidiennes, hebdomadaires, mensuelles, ou selon un calendrier personnalisé, et l’API Scraper collecte les annonces actuelles à la demande.

Comparez le coût de l’ensemble du pipeline, incluant la découverte, les détails d’annonces, l’historique des prix et les tentatives, plutôt que le coût d’une seule récupération de page. Notre guide sur le scraping web géré vs fait maison estime le coût complet de l’exploitation de scrapers en interne.

Réflexions finales

Pour récupérer une page Zillow, vous avez besoin d’un client avec l’empreinte TLS d’un navigateur. Obtenir des données complètes est plus difficile, car chaque recherche s’arrête à 984 annonces et la plupart des pages d’annonces chargent l’historique des prix dans une seconde requête. Pour une tâche ponctuelle sur une petite zone sans historique de prix, le scraper curl_cffi suffit. Pour chaque annonce avec son historique de prix selon un calendrier, l’API Scraper Zillow de Bright Data gère les deux pour vous.

Créez un compte Bright Data gratuit, exécutez la découverte sur une URL de recherche de votre marché, et comparez le nombre de lignes avec le total rapporté par Zillow.

FAQ

Peut-on scraper des données depuis Zillow ?

Oui, si votre client utilise l’empreinte TLS d’un navigateur. La bibliothèque Python requests obtient un HTTP 403, tandis que curl_cffi imitant Chrome obtient la page. Les champs d’annonces sont en JSON intégré dans chaque page, donc un analyseur peut les lire sans dépendre de la mise en page HTML.

Pourquoi Zillow n’affiche-t-il pas toutes les annonces ?

Zillow limite chaque recherche. Sa pagination affiche 20 pages, mais le site sert 24 pages de 41 annonces chacune, donc une recherche atteint au maximum 984 annonces. Divisez les grandes zones en recherches plus petites, ou utilisez l’API Scraper Zillow de Bright Data, qui a trouvé les 1 295 annonces de Fort Wayne depuis une seule URL.

Comment obtenir les données d’historique des prix Zillow ?

Utilisez l’API Scraper Zillow de Bright Data. Ses enregistrements d’annonces à vendre incluent l’historique des prix, et son scraper Full Properties le renvoie pour les maisons hors marché. La plupart des pages d’annonces chargent l’historique des prix dans une seconde requête, donc les scrapers HTML seuls le manquent. Pour les tendances de marché par code postal, Zillow Research publie des CSV gratuits.

Zillow dispose-t-il d’une API ouverte ?

Non. Les API de Zillow Group pour les annonces MLS, les registres publics et les Zestimates nécessitent une approbation, limitent les appels quotidiens, et restreignent le stockage des données. Zillow Research propose des CSV gratuits de données au niveau du marché uniquement. Pour les données au niveau des annonces, l’API Scraper Zillow de Bright Data renvoie les annonces et l’historique des prix en JSON structuré.

Comment scraper Zillow gratuitement ?

Utilisez curl_cffi, un client HTTP Python gratuit qui imite Chrome, pour récupérer lentement les pages d’annonces, et analysez le JSON intégré. Cette méthode s’arrête à 984 annonces par recherche et manque la plupart de l’historique des prix. Les nouveaux comptes Bright Data reçoivent 5K crédits gratuits par mois sans carte de crédit, suffisant pour 5K enregistrements de l’API Scraper Zillow.

Comment scraper Zillow sans coder ?

Utilisez le tableau de bord sans code d’un scraper géré. Dans le panneau de contrôle Bright Data, ouvrez l’API Scraper Zillow, collez des URL d’annonces ou de recherche, et lancez l’exécution. Téléchargez ensuite les enregistrements en JSON ou CSV. Pour un marché entier, vous pouvez acheter un jeu de données Zillow prêt à l’emploi au lieu de scraper.

ChatGPT peut-il faire du web scraping ?

ChatGPT peut écrire du code de scraper, mais il n’est pas conçu pour collecter des données Zillow à grande échelle sans autres outils. Un agent IA obtient des annonces structurées en appelant l’outil d’annonces Zillow dans le serveur MCP Bright Data, puis travaille avec le JSON renvoyé. Vérifiez l’horodatage de chaque enregistrement avant que votre agent ne cite un prix.