Wie man Zillow scrapt, ohne Daten zu verpassen

Erfahren Sie, wie Sie jedes Zillow-Listing mit Preishistorie mit Python scrapen, die Datenvollständigkeit prüfen und mit Bright Data skalieren.
18 min lesen
How to Scrape Zillow blog

Das Scrapen von Zillow bedeutet, Listing-, Preis- und Immobiliendaten von den öffentlichen Seiten von Zillow für Preismodelle, Investmentanalyse-Tools und Marktdashboards zu sammeln. Jede Seite bettet ihre Hauptdaten als JSON ein.

Zillow blockiert Standard-Python-Requests, aber das ist das leichtere Problem. Das schwierigere sind Daten, die Ihr Scraper verpasst, ohne es zu melden. Die meisten Listing-Seiten enthalten keine Preishistorie in ihrem HTML, und eine Suche endet bei 984 Listings, nur 6 % der zum Verkauf stehenden Häuser in Houston.

Dieser Leitfaden zeigt Ihnen, wie Sie Zillow mit Python scrapen, jedes Listing mit seiner Preishistorie erhalten und entscheiden, wann ein verwalteter Scraper weniger kostet.

Kurzfassung

  • Nutzen Sie den TLS-Fingerabdruck eines Browsers. Zillow blockiert Pythons Standard-TLS-Fingerabdruck, selbst mit Chrome-Headern. curl_cffi, das Chrome imitiert, erreichte 429 von 430 Seiten.
  • Lesen Sie das eingebettete JSON der Seite und rufen Sie die Preishistorie separat ab. Die meisten Listing-Seiten laden sie mit einer zweiten Anfrage, die reine HTML-Scraper nie ausführen.
  • Eine Suche endet bei 984 Listings, teilen Sie also große Suchen auf. Oder nutzen Sie die Bright Data Zillow Scraper API, die alle 1.295 Fort-Wayne-Listings über eine URL fand.
  • Prüfen Sie die Vollständigkeit, nicht nur die Statuscodes. Vergleichen Sie Ihre Zeilenanzahl mit der von Zillow gemeldeten Gesamtzahl, wiederholen Sie fehlgeschlagene Zeilen und kennzeichnen Sie zuvor verkaufte Häuser ohne Preishistorie.

Welche Zillow-Daten Sie scrapen können und wo Sie sie finden

Ihr Scraper kann Zillow-Daten aus 4 Hauptquellen abrufen, und Zillows robots.txt behandelte jede von ihnen unterschiedlich:

Daten Quelle bei Zillow Was robots.txt vorgab
Listing-Felder: Preis, Schlafzimmer, Zestimate, Fotos, Listing-Agent __NEXT_DATA__-JSON innerhalb des Listing-Seiten-HTML Erlaubt für /homedetails/-Seiten
Preishistorie, Steuerhistorie, Schulen Meist eine /graphql/-Anfrage, die die Seite nach dem Laden sendete Verboten für /graphql/
Suchergebnisse, 41 Listings pro Seite Stadtseiten wie /fort-wayne-in/2_p/, oder der interne async-create-search-page-state-Endpunkt Erlaubt für Stadtseiten, verboten für den Endpunkt und ?searchQueryState=-URLs
Geänderte Listings Sitemaps, verlinkt aus robots.txt, wie latest.xml.gz Für Crawler veröffentlicht

Zillows Seiten wurden mit Next.js erstellt, und jede Seite bettete ihre Hauptdaten als JSON in einem __NEXT_DATA__-Script-Tag ein. Sie können dieses JSON lesen, ohne die Seite zu rendern. Unser Leitfaden zum Scraping von Next.js-Websites erklärt, wie diese eingebetteten Daten funktionieren.

Ihre Anfragen für ein Listing und eine Suche folgen diesem Pfad:

Diagramm von Zillows Anfragepfad in 3 Zeilen. HUMANs Bot-Prüfung am CloudFront-Edge blockiert eine Anfrage mit HTTP 403 oder liefert die Seite mit HTTP 200. Das Seiten-HTML enthält Preis, Schlafzimmer und Fotos, und Preis- sowie Steuerhistorie kommen von einer zweiten /graphql/-Anfrage. Eine Suche endet bei 24 Seiten bzw. 984 Listings, und Seite 25 liefert HTTP 400.

HUMANs Bot-Prüfung läuft am Edge, bevor überhaupt HTML existiert, und die Preishistorie lädt, nachdem die Seite geladen wurde. Wenn Ihr Parser nur das HTML liest, kann er eine Blockierung mit einer leeren Seite verwechseln, und er verpasst die Preishistorie bei den meisten Listings.

Listing-Seiten und Preishistorie

Sie müssen das Listing-JSON zweimal parsen, da es doppelt kodiert war. Das Feld props.pageProps.componentProps.gdpClientCache hatte einen String-Wert, und dieser String war selbst ein JSON-Objekt. Seine Schlüssel waren die Namen von Zillows internen GraphQL-Abfragen.

Listings zum Verkauf, vom Markt genommene und Zillow-Showcase-Listings verwendeten jeweils ihren eigenen GraphQL-Operationsnamen. ForSalePriorityQuery hatte 101 Felder, NotForSalePriorityQuery hatte 122, und ViewShowcasePriorityQuery hatte 78. Wenn Ihr Parser einen Operationsnamen erwartet, scheitert er bei den anderen. Führen Sie stattdessen jeden Eintrag im Cache zusammen, dann kann ein Parser alle 3 Listing-Typen verarbeiten.

Preis- und Steuerhistorie fehlten bei den meisten Listings in diesem JSON. Bei 20 zum Verkauf stehenden Listings lieferte die Bright Data Zillow Scraper API jeweils 1 bis 18 Preishistorie-Ereignisse. Das HTML derselben Seiten enthielt keines dieser Ereignisse. Wenn Ihr Scraper nur das HTML liest, verzeichnet er diese Häuser als ohne Preishistorie und gibt keinen Fehler aus.

Der Browser forderte die Historie an, nachdem die Seite geladen hatte. Bei einem zum Verkauf stehenden Haus lieferte diese Anfrage (ForSaleNonPriorityQuery) 145 Immobilienfelder, einschließlich 21 Jahre Steuerhistorie. Eine vom Markt genommene Seite nutzte eine andere Anfrage, eine persistierte GraphQL-Abfrage, die die Seite über ihren Hash aufrief.

Suchseiten und das 984-Listing-Limit

Eine vollständige Seite mit Suchergebnissen enthielt 41 Listings in ihrem __NEXT_DATA__-Block, aber nur 9 davon wurden als HTML-Karten gerendert. Wenn Sie die Karten mit CSS-Selektoren parsen, verpassen Sie die anderen 32. Die Karten-Selektoren aus älteren Zillow-Tutorials, wie article.list-card, fanden keine Elemente.

Zillow begrenzte außerdem jede Suche. Die eigene Paginierung stoppte bei Seite 20, selbst bei einer Suche, die mehr als 1.000 Ergebnisse meldete:

Zillows Suchergebnisse für Fort Wayne, oberer und unterer Bereich derselben Liste. Die Überschrift lautet 'Fort Wayne IN Real Estate & Homes For Sale' und '1.289 Ergebnisse', und die Paginierungsleiste darunter zeigt die Seiten 1 bis 5, eine Ellipse und 20.

Oberer und unterer Bereich von Zillows Ergebnisliste für Fort Wayne. Die Gesamtzahl (hier 1.289) ändert sich, wenn Listings hinzugefügt oder entfernt werden, aber die Seitenzahlen stoppten dennoch bei 20.

Zwanzig Seiten mit je 41 Listings ergeben 820, das Limit, das einige Scraping-Tools dokumentieren. Aber die Seiten 21 bis 24 lieferten bei Anfrage über URL jeweils noch 41 weitere Listings, und Seite 25 lieferte einen HTTP-400-Fehler. Eine Suche erreichte somit 984 Listings, was 76 % der 1.295 zum Verkauf stehenden Listings in Fort Wayne und 6 % der 15.410 in der Stadt Houston entsprach. Jede Antwort meldete weiterhin die vollständige Gesamtzahl, vergleichen Sie diese also mit dem, was Sie sammeln.

Parsen Sie das JSON statt des Markups, und Sie erhalten alle 41 Listings pro Seite statt 9.

Warum Zillow Standard-Python-Requests blockiert

Zillows Bot-Schutz stammte von HUMAN Security, früher PerimeterX. Zwei Details zeigten dies: Die Blockierungsseite nannte die PerimeterX-App-ID PXHYx10rg3, und robots.txt verbot /captchaPerimeterX/ und /HYx10rg3/.

Die Ablehnung kam von CloudFront, dem Content Delivery Network (CDN) vor Zillow, nicht von Zillows eigenen Servern. Jede blockierte Antwort war ein HTTP 403 mit x-cache: LambdaGeneratedResponse und x-px-blocked: 1. Außerdem fehlte der x-envoy-upstream-service-time-Header, den normale Seiten enthielten. Ihr Scraper kann diesen x-px-blocked-Header prüfen, um eine Blockierung zu erkennen, ohne die Seite zu parsen.

Wie eine blockierte Anfrage aussieht

Eine curl-Anfrage mit einem Chrome-User-Agent wurde abgelehnt:

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/"

Der Edge antwortete in 0,32 Sekunden mit der 5.952-Byte-“Press & Hold”-Seite:

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

Ein von Playwright gestartetes headless Chromium erhielt dieselbe Blockierung, HTTP 403 mit x-px-blocked: 1, und rendertte HUMANs Challenge:

Zillows Press & Hold-Seite von HUMANs Bot-Schutz. Das Zillow-Logo steht über der Nachricht 'Press & Hold to confirm you are a human (and not a bot)' und einer Press & Hold-Schaltfläche.

Wenn Ihr Scraper diese Seite erhält, wurde er am Edge abgelehnt, und die Seite enthält keine Listing-Daten.

Um diese Seite ohne eigenen Umgehungscode zu handhaben, nutzen Sie den Bright Data Zillow-CAPTCHA-Löser.

Welche Python-Clients Zillows Bot-Prüfung bestehen

Von derselben IP aus, Sekunden nachdem curl abgelehnt wurde, rief curl_cffi dasselbe Listing ab. Diese Bibliothek imitiert Chromes TLS-Handshake und Header:

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

Es gab ein HTTP 200 und 730.803 Bytes HTML aus:

200 730803

Diese 2 Anfragen unterschieden sich in ihren Headern und in ihrem TLS-Fingerabdruck. Ein TLS-Fingerabdruck ist das Muster der Cipher-Suiten und Erweiterungen, die ein Client beim Öffnen einer Verbindung anbietet. Unser Leitfaden zum TLS-Fingerprinting erklärt, wie Websites diese Fingerabdrücke zur Bot-Erkennung nutzen.

Von einer residenziellen IP aus, die Zillow nicht blockiert hatte, riefen 6 Clients jeweils eine Listing-Seite und eine Suchseite ab, mit demselben Ergebnis bei beiden:

Client TLS-Fingerabdruck Ergebnis
Python requests, Standard-Header Python (OpenSSL) 403
Python requests, Chrome-User-Agent und Browser-Header Python (OpenSSL) 403
httpx, Browser-Header Python (OpenSSL) 403
curl_cffi, impersonate="chrome" Chrome 200
curl_cffi, impersonate="firefox" Firefox 200
curl_cffi, impersonate="safari" Safari 403

Wenn Ihr Client Pythons TLS-Fingerabdruck verwendet, helfen Browser-Header nicht. Das Safari-Profil wurde ebenfalls bei beiden Seiten abgelehnt, testen Sie also jedes Imitationsprofil gegen Zillow, bevor Sie sich darauf verlassen. Das Chrome-Profil bestand auch, als HTTP/1.1 erzwungen wurde, die HTTP/2-Einstellungen waren also nicht der entscheidende Faktor.

Warum Header und IP-Reputation weiterhin wichtig sind

Zillow prüft auch, ob die HTTP-Header Ihrer Anfrage wie die eines echten Browsers aussehen. Als ein Proxy Chromes TLS-Fingerabdruck und eine neue US-IP für jede Anfrage nutzte, entschieden allein die Header über das Ergebnis.

Pythons Standard-User-Agent wurde 5 von 5 Mal abgelehnt, und ein alleiniger Chrome-User-Agent bestand 5 von 5 Mal. Chromes vollständiger Header-Satz ohne seine sec-ch-ua-Client-Hints wurde 4 von 10 Mal abgelehnt. Das deutet darauf hin, dass Zillow jeder Anfrage einen Risikowert zuweist, statt eine feste Regel anzuwenden.

Die Reputation Ihrer IP spielt ebenfalls eine Rolle. Etwa 20 Anfragen wurden von einer residenziellen IP gesendet, und die meisten kamen von Clients, die Zillow ablehnte. Danach lehnte Zillow sogar das Chrome-Profil ab, das Minuten zuvor bestanden hatte, und die Blockierung endete innerhalb von 6 Minuten.

Sitzungen mit Chromes TLS-Fingerabdruck und wiederverwendeten Cookies verhielten sich anders. Über 5 solcher Sitzungen hinweg lieferten 429 von 430 Listing-Anfragen die Seite. Der eine Fehlschlag war ein HTTP 502, keine Blockierung. HUMAN lehnte keine der 430 ab, einschließlich 250 Anfragen von einer IP in 12 Minuten.

Behandeln Sie eine Ablehnung also als Signal, diese IP zu pausieren, nicht als sofortigen Wiederholungsversuch. Messen Sie die Blockierungsrate pro IP, bevor Sie entscheiden, wie viele Proxy-IPs Sie benötigen. Für IP-Rotation und andere Möglichkeiten, die Blockierungsrate niedrig zu halten, siehe unseren Leitfaden zum Web-Scraping, ohne blockiert zu werden.

Zillow mit Python und curl_cffi scrapen

Der untenstehende Scraper liest dasselbe JSON, das die Seite verwendet. Er benötigt Python 3.10 oder neuer und curl_cffi, das unser Leitfaden zum Web-Scraping mit curl_cffi vorstellt. Installieren Sie die Bibliothek in einer virtuellen Umgebung:

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

Dieses Skript, zillow_diy.py, ruft eine Suchseite für Fort Wayne ab und liest dann 3 Listings daraus:

"""Zillow mit dem TLS-Fingerabdruck eines Browsers und dem eingebetteten JSON der Seite scrapen."""
import json
import re
import time

from curl_cffi import requests

NEXT_DATA = re.compile(
    r'', re.S
)
# Chrome-TLS + passende Header; eine Session pro IP beibehalten
session = requests.Session(impersonate="chrome")


def next_data(url: str) -> dict:
    resp = session.get(url, timeout=30)
    match = NEXT_DATA.search(resp.text)
    # Eine Blockierung kann ein 403 oder eine 200-Captcha-Seite sein,
    # also auf die Daten prüfen, nicht auf den Status
    if resp.headers.get("x-px-blocked") or not match:
        raise RuntimeError(f"keine Seitendaten für {url} (HTTP {resp.status_code})")
    resp.raise_for_status()  # Zillows 404-Seite enthält auch __NEXT_DATA__
    return json.loads(match.group(1))


def search_page(city: str, page: int) -> tuple[list[dict], int]:
    """Eine server-gerenderte Ergebnisseite, plus die von Zillow gemeldete Gesamtzahl."""
    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:
    """Der server-gerenderte Teil eines Listings: gdpClientCache ist doppelt kodiertes JSON."""
    props = next_data(url)["props"]["pageProps"]["componentProps"]
    cache = json.loads(props["gdpClientCache"])
    record = {}
    for query_result in cache.values():  # z. B. ForSalePriorityQuery{...}
        record.update(query_result.get("property") or {})
    return record


if __name__ == "__main__":
    cards, total = search_page("fort-wayne-in", 1)
    print(f"Seite 1: {len(cards)} Listings von {total} gemeldet")
    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))

Bei Ausführung mit python zillow_diy.py fand es 41 Listings auf Seite 1, und 2 der 3 geöffneten Listings hatten keine Preishistorie:

Seite 1: 41 Listings von 1295 gemeldet
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

Das erste Listing war ein Showcase-Listing, das seine Preishistorie im HTML enthielt. Bei den anderen 2 lieferte die Zillow Scraper API 2 bzw. 3 Preishistorie-Ereignisse, und das HTML hatte 0. Der gesamte Durchlauf dauerte etwa 11 Sekunden, einschließlich der 2-Sekunden-Pausen.

Dieser Ansatz hatte 2 Grenzen. Erstens lieferte Seite 24 der Fort-Wayne-Stadtseiten immer noch 41 Listings, aber Seite 25 lieferte einen HTTP-400-Fehler. Wenn Ihre Stadt mehr Listings hat, als eine Suche erreichen kann, benötigen Sie kleinere Suchen. Zweitens stammte die fehlende Preishistorie nur von /graphql/.

Das Aufteilen einer Stadt bedeutet meist Postleitzahl- oder gefilterte Suchen, die /homes/– und ?searchQueryState=-URLs verwenden, und die fehlende Preishistorie benötigt die /graphql/-Anfrage. Zillows robots.txt verbot alle drei, der Aufruf ist also eine Compliance-Entscheidung für Ihr Team. Unser Leitfaden zu robots.txt für Web-Scraping erklärt, wie man diese Regeln liest und respektiert.

Um diesen Scraper von vielen IPs aus auszuführen, übergeben Sie einen Bright Data Residential-Proxy an die Session. Für Ende-zu-Ende-verschlüsselte Verbindungen laden Sie das Bright-Data-Zertifikat für Port 44445 mit verify="brightdata_root_ca_44445.crt".

Residential-IPs stammen von echten Personen, die zugestimmt haben, daher benötigen Bright Data Residential-Proxys zunächst eine genehmigte Unternehmens-KYC-Überprüfung. Die Überprüfung genehmigt bestimmte Anwendungsfälle, beschreiben Sie also Ihre Zillow-Pipeline bei der Antragstellung. Ohne KYC übernimmt die Web Unlocker API das Entsperren für Sie, und die Zillow Scraper API im nächsten Abschnitt liefert Listing-Datensätze mit Preishistorie.

Zillow mit der Zillow Scraper API scrapen

Die Zillow Scraper API führt die Sammlung auf der Bright-Data-Infrastruktur aus, übernimmt das Entsperren und Parsen für Sie und liefert JSON-Datensätze zurück. Sie zahlen pro geliefertem Datensatz, und neue Konten erhalten 5.000 kostenlose Credits, die sich monatlich erneuern. Sie umfasst diese Zillow-Scraper, die in den folgenden Abschnitten als Listing-, Preishistorie-, Suchseiten- und Full-Properties-Scraper bezeichnet werden:

Scraper Dataset-ID Eingabe Ausgabe
Zillow Properties Listing Information gd_lfqkr8wm13ixtbd8f5 Listing-URL, Such-URL oder Standortfilter 1 Datensatz pro Listing, bis zu 139 Felder
Zillow Price History gd_lxu1cz9r88uiqsosl Listing-URL 1 Zeile pro Preishistorie-Ereignis
Zillow Properties Search Page gd_m71oxpvxwr113ehqg Such-URL mit searchQueryState 1 Zeile pro Suchergebnis, 26 Felder
Zillow Full Properties Information gd_m794g571225l6vm7gh Listing-URL oder Such-URL 1 Datensatz pro Listing, mit snake_case-Feldnamen wie price_history

Alle 4 sammeln per URL über dieselbe API-Anfrage, Sie ändern also nur die Dataset-ID. Bright Data bietet außerdem eine Zillow Image Scraper API und Scraper für andere Immobilienseiten im Bereich der Real Estate Scraper API an. Im Control Panel zeigt der Listing-Scraper seine 3 Eingabemethoden sowie die synchronen und asynchronen Modi:

Bright Data Control Panel auf der Collect-by-URL-Seite für Zillow Properties Listing Information. Das linke Panel listet die 4 Zillow-Scraper und die 3 Eingabemethoden des Listing-Scrapers auf, und die Mitte zeigt eine Listing-URL als Eingabe mit ausgewähltem Synchronous (Real-time).

Das linke Panel listet dieselben 4 Scraper wie die Tabelle oben auf.

Listings und Preishistorie sammeln

Um das untenstehende Skript, zillow_api.py, auszuführen, benötigen Sie das requests-Paket (pip install requests) und ein API-Token aus dem Control Panel. Die Funktion collect() ruft den synchronen Endpunkt auf und wechselt zum Polling, wenn ein Job länger als 1 Minute dauert:

"""Listings, Preishistorie und Discovery mit Bright Datas Zillow Scraper API."""
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"       # Zillow Properties Listing Information
PRICE_HISTORY = "gd_lxu1cz9r88uiqsosl"   # Zillow Price History, eine Zeile pro Ereignis


def collect(dataset_id: str, urls: list[str]) -> list[dict]:
    """Bis zu 20 URLs pro synchronem Aufruf; ein Job über 1 Minute liefert 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]:
    """Jedes Listing in einer Zillow-Suche, als vollständige Datensätze gesammelt (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} endete als {status}")
        time.sleep(poll_s)
    while True:  # der Download liefert 202, während die Datei noch vorbereitet wird
        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 []),
              "| gesammelt", 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"])

Mit exportiertem BRIGHTDATA_API_TOKEN dauerten die beiden Aufrufe zusammen 35 bis 65 Sekunden:

77039629 FOR_SALE 359000 | zestimate 351600 | priceHistory 1 | taxHistory 21 | gesammelt 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

Der Listing-Datensatz stimmte mit dem überein, was Zillows eigene Seite lud, mit 1 Preishistorie-Ereignis und 21 Jahren Steuerhistorie. Sein timestamp zeigte, dass Bright Data ihn live während der Anfrage sammelte.

Die 3 Ereignisse für das zweite Haus, 2506 Gordon Cir, stimmen mit der Preishistorietabelle auf seiner Zillow-Seite überein:

Zillows Preishistorietabelle für 2506 Gordon Cir in South Bend. Sie listet 'Sold' am 3.3.2023 für 265.000 $, 'Pending sale' am 3.2.2023 für 264.900 $ und 'Listed for sale' am 1.2.2023 für 264.900 $, jeweils mit der Quelle IRMLS.

Jede Zeile nennt ihre Quelle, einen lokalen Multiple Listing Service (MLS), den die API-Ausgabe im Feld source beibehält.

Der synchrone /scrape-Endpunkt nimmt bis zu 20 URLs entgegen und liefert die Daten direkt zurück, wenn der Job innerhalb von 1 Minute abgeschlossen ist. Bei einem längeren Job antwortet er stattdessen mit HTTP 202 und einer Snapshot-ID. Eine Charge von 20 Fort-Wayne-Listings dauerte etwa 4 Minuten, erwarten Sie also ein paar Minuten Polling, bevor Ihr Skript etwas ausgibt. Für größere Chargen nutzen Sie den asynchronen /trigger-Endpunkt, den der nächste Abschnitt für die Discovery verwendet.

Jedes Listing in einer Suche entdecken

Discovery sammelt vollständige Listing-Datensätze aus einer Such-URL und kann das 984-Listing-Limit überschreiten, das den selbstgebauten (DIY) Scraper stoppte. Die obige discover()-Funktion sendet die Fort-Wayne-Stadt-URL, https://www.zillow.com/fort-wayne-in/, an die Discover-by-URL-Eingabemethode des Listing-Scrapers in einer asynchronen /trigger-Anfrage. In 13 bis 17 Minuten lieferte sie 1.295 Zeilen, eine für jedes von Zillow gemeldete Listing. Für eine kleine Stichprobe übergeben Sie stattdessen bis zu 20 Listing-URLs aus search_page() in zillow_diy.py an collect().

Um zu begrenzen, wie viele Datensätze jede Such-URL liefert, zum Beispiel bei einem Testlauf, senden Sie {"input": [...], "limit_per_input": 50} als JSON-Body. Statt zu pollen, können Sie /trigger die Ergebnisse an einen Webhook oder S3 liefern lassen, und jeder Snapshot bleibt 30 Tage lang herunterladbar.

Wenn Sie nur Suchergebnisfelder wie Preis, Schlafzimmer und Adresse benötigen, ist der Suchseiten-Scraper schneller. Von einer Fort-Wayne-Such-URL mit searchQueryState lieferte er in 65 Sekunden 1.154 eindeutige Listings. Nutzen Sie Discovery, wenn Sie jedes Listing benötigen.

Preishistorie für vom Markt genommene Häuser abrufen

Bei zum Verkauf stehenden Häusern stimmte die priceHistory des Listing-Scrapers bei 4 von 4 Listings mit Zillows eigener Seite überein. Für vom Markt genommene Häuser nutzen Sie den Full-Properties-Scraper. Bei 6 von 6 vom Markt genommenen Häusern lieferte er die Preishistorie in einem snake_case-price_history-Array, 1 Datensatz pro Haus.

Für dieselben 6 Häuser lieferte der Preishistorie-Scraper 1 Zeile pro Ereignis: 28 Zeilen in 37 Sekunden. Die beiden Ausgaben stimmten exakt überein.

Die Abrechnung erfolgt pro geliefertem Datensatz, ein Haus mit 5 Ereignissen kostet Sie also 5 Datensätze beim Preishistorie-Scraper und 1 bei Full Properties. Fort-Wayne-Listings hatten im Durchschnitt 5 Ereignisse, maximal 29. Senden Sie also vom Markt genommene URLs an den Full-Properties-Scraper und lesen Sie price_history statt priceHistory. Nutzen Sie den Preishistorie-Scraper, wenn Sie 1 Zeile pro Ereignis möchten.

Denselben Scraper über ein Terminal oder einen KI-Agenten ausführen

Die Bright Data CLI führt den Listing-Scraper als Pipeline-Befehl aus, was sich für einmalige Aufgaben und Coding-Agenten eignet. Nachdem Sie sie mit npm install -g @brightdata/cli installiert und einmal bdata login ausgeführt haben, liefert ein Befehl ein Listing zurück:

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

Innerhalb von 3 Minuten gab sie denselben Datensatz wie die API aus, in einer JSON-Zeile, die so beginnt:

[{"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,

Wenn Ihr KI-Assistent das Model Context Protocol (MCP) nutzt, stellt der Bright Data MCP Server denselben Scraper als Tool web_data_zillow_properties_listing bereit. Um das Tool zu nutzen, aktivieren Sie die business-Tool-Gruppe des Servers. Jeder Datensatz enthält einen timestamp, sodass Ihr Agent bestätigen kann, wie aktuell ein Preis ist, bevor er einen zitiert.

Für einen vollständigen Agenten, der auf demselben Server aufbaut, siehe unseren Leitfaden zum Erstellen eines Immobilien-KI-Agenten mit CrewAI.

Zillow-Daten vollständig und aktuell halten

Bevor die Datensätze Ihr Data Warehouse erreichen, prüfen Sie jeden Durchlauf gegen Zillows gemeldete Gesamtzahl. Die untenstehenden Prüfungen kennzeichnen außerdem Fehlerzeilen, doppelte Listings und fehlende Preishistorie. Eine Sitemap geänderter Listings hält dann Ihre wiederholten Durchläufe klein.

Jeden Datensatz validieren

Eine 200-Antwort bestätigt, dass die Anfrage erfolgreich war, nicht dass die Daten vollständig sind. Dieser Validator, gespeichert als validate.py, trennt Fehlerzeilen, entfernt doppelte Listings und kennzeichnet Häuser, die zuvor verkauft wurden, aber keine Preishistorie haben. Er zählt auch Datensätze, die vor mehr als 24 Stunden gesammelt wurden, als veraltet und vergleicht Ihre Datensatzanzahl mit Zillows gemeldeter Gesamtzahl:

"""Prüfungen, die auf Zillow-Datensätze angewendet werden sollten, bevor sie das Warehouse erreichen."""
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)
    # Ein zuvor verkauftes Haus hat Preishistorie, fehlende Historie ist also eine Lücke, keine Tatsache.
    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

Das Skript run_pipeline.py führt die Discovery für Fort Wayne aus und validiert dann die Zeilen:

"""Jedes Listing in einer Zillow-Suche entdecken, dann das Ergebnis validieren."""
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)  # Zillows Gesamtzahl, z. B. von search_page()
print(f"{len(rows)} Zeilen in {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"))

Die Discovery lieferte 1.295 Zeilen, und der Validator behielt 1.270 davon als Datensätze:

1295 Zeilen in 12.9 Min.
{"records": 1270, "error_rows": 25, "duplicates": 0, "history_gaps": 0, "stale": 0, "retry": 25, "coverage": 0.981}

Die Abdeckung betrug 98,1 % der 1.295 von Zillow gemeldeten Listings. Der Durchlauf hatte keine Duplikate, und kein Datensatz scheiterte an der Preishistorie-Prüfung. Die Prüfung kennzeichnet nur zuvor verkaufte Häuser, sie ignoriert also Häuser, die nie eine Preishistorie hatten, wie Neubauprojekte.

Ein fehlender Verkaufspreis in Ihren Daten ist nicht immer ein Scraping-Fehler. Bei 8 Houston-Listings hatte keines der 2 Sold-Ereignisse einen Preis, wie in Texas zu erwarten, wo Verkaufspreise nicht öffentlich zugänglich sind.

Senden Sie alle URLs, die die Preishistorie-Prüfung kennzeichnet, an den Full-Properties-Scraper und reichen Sie die retry-URLs erneut ein. Für jedes Listing, dem weiterhin ein vollständiger Datensatz fehlt, enthalten die Ergebnisse des Suchseiten-Scrapers Preis, Schlafzimmer und Adresse.

Nur die geänderten Listings aktualisieren

Zillows robots.txt listete einen Sitemap-Index für jeden Listing-Status. Der Index für vom Agenten zum Verkauf stehende Listings enthielt eine latest.xml.gz-Sitemap. Dieses Skript, changed_listings.py, liest sie über die Web Unlocker API und behält die Indiana-Listings. Bevor Sie es ausführen, erstellen Sie eine Web-Unlocker-Zone im Control Panel und tragen Sie ihren Namen in das Skript ein:

"""Kürzlich geänderte Zillow-Listings in einem Bundesstaat auflisten, aus einer in robots.txt verlinkten Sitemap."""
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:
    # "web_unlocker" durch den Namen Ihrer Web-Unlocker-Zone ersetzen
    payload = {"zone": "web_unlocker", "url": url, "format": "raw", "country": "us"}
    for _ in range(attempts):  # bei einem vorübergehenden Fehler einmal erneut versuchen
        resp = requests.post("https://api.brightdata.com/request", headers=HEADERS,
                             json=payload, timeout=180)
        # Web Unlocker gibt den eigenen Statuscode des Ziels in diesem Header zurück
        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)
# die Datei kann bereits dekodiert ankommen
xml = (gzip.decompress(body) if body[:2] == b"\x1f\x8b" else body).decode()
entries = re.findall(r"([^<]+)\s*([^<]+)", 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)} geänderte Listings landesweit, {len(indiana)} in Indiana")
print("älteste Änderung:", min(dates), "| neueste:", max(dates))
print(indiana[0][0])

Die Datei listete 50.000 geänderte Listings landesweit, 1.014 davon in Indiana:

50000 geänderte Listings landesweit, 1014 in Indiana
älteste Änderung: 2026-09-24T14:08:00Z | neueste: 2026-09-27T07:51:00Z
https://www.zillow.com/homedetails/600-W-Cyprus-Cir-Bloomington-IN-47403/94517948_zpid/

Eine Sitemap-Datei kann höchstens 50.000 URLs auflisten, und diese 50.000 Änderungen erstreckten sich über etwa 3 Tage. Ein täglicher Durchlauf kann also nur die Listings in Ihrem Gebiet erneut sammeln, die seit dem letzten Durchlauf geändert wurden, was die Kosten niedrig hält. Senden Sie sie in 20er-Paketen an collect() oder in einem einzigen /trigger-Job. Die Datei listet, was sich geändert hat, nicht jedes Haus auf Zillow.

Die Sitemaps in den 4 Zum-Verkauf-Indizes listeten 749 der 1.154 Fort-Wayne-Listings auf, die der Suchseiten-Scraper fand, also 65 %. Keines der 217 Neubau-Listings erschien. Nutzen Sie Sitemaps, um zu entscheiden, was erneut gesammelt werden soll, und nutzen Sie Discovery, um herauszufinden, was existiert.

Was jeder Ansatz im großen Maßstab kostet

Der günstigste Weg, eine Seite abzurufen, ist oft nicht der günstigste Weg, einen vollständigen Datensatz zu erhalten. Jeder untenstehende Ansatz lieferte Zillow-Daten, aber sie unterscheiden sich darin, wie vollständig diese sind und wie viel Arbeit Ihnen noch bleibt. Die Preise sind Listenpreise zum Veröffentlichungszeitpunkt, und unsere Preisseite enthält aktuelle Tarife:

Ansatz Was Sie zahlen Ergebnis Verbleibende Arbeit für Sie
curl_cffi auf eigenen IPs Ihre Server und Proxy-Traffic 429 von 430 Seiten geliefert; Suchen auf 984 Listings begrenzt; meist keine Preishistorie im HTML Fingerabdruck-Updates, IP-Reputation, Suchaufteilung, Preishistorie
Web Unlocker API 1,50 $ pro 1.000 erfolgreiche Anfragen 10 von 10 Listing-Seiten, Median 4,5 Sek. Parsing, Suchaufteilung, Preishistorie
Browser API 8 $ pro GB 3,97 MB pro Listing, etwa 0,03 $; Preishistorie gerendert Browser-Skripte, Parsing
Zillow Scraper API 1,50 $ pro 1.000 Datensätze Alle 1.295 Fort-Wayne-Listings über eine URL gefunden; Preishistorie für Häuser zum Verkauf und vom Markt genommen Validierung
Zillow-Datensätze Bis zu 2,50 $ pro 1.000 Datensätze, Mindestbestellung 250 $ 137,42 Mio. Immobiliendatensätze im Marktplatz, meist vom Markt genommene Häuser Auswahl eines Aktualisierungsplans

Web Unlocker berechnet nur für erfolgreiche Anfragen und gibt den Statuscode der Zielseite im x-brd-status-code-Header zurück, prüfen Sie diesen Header also in Ihrem Code. Eine Anfrage für ein fehlendes Listing lieferte beispielsweise x-brd-status-code: 404. Die Browser API renderte die Preishistorietabelle ohne zusätzlichen Code, was sich für Seiten eignet, die eine vollständige Browser-Sitzung benötigen.

Der DIY-Ansatz hat außerdem Wartungskosten, die keine Angabe in der Tabelle zeigt. Ein Entwickler, der für Kunden einen täglichen Zillow-Scraper betreibt, berichtete, dass dieser jedes Quartal ausfällt, wenn Zillow Updates einspielt. Zillows Seiten zeigen warum. Jeder Listing-Typ verwendete seinen eigenen GraphQL-Operationsnamen im JSON, und Karten-Selektoren aus alten Tutorials, wie article.list-card, fanden nichts.

Die Datensätze haben einen anderen Zweck: Der Bright Data Zillow Listing Datensatz enthält mehr als 137 Mio. Immobiliendatensätze. Die meisten dieser Datensätze sind vom Markt genommene Häuser, was sich für Bewertungsmodelle und Forschung eignet, die jede Immobilie benötigt, nicht nur zum Verkauf stehende Häuser. Käufer können tägliche, wöchentliche, monatliche oder individuell geplante Updates erhalten, und die Scraper API sammelt aktuelle Listings auf Anfrage.

Vergleichen Sie die Kosten der gesamten Pipeline, einschließlich Discovery, Listing-Details, Preishistorie und Wiederholungsversuchen, nicht die Kosten eines einzelnen Seitenabrufs. Unser Leitfaden zu verwaltetem vs. DIY-Web-Scraping schätzt die vollen Kosten des internen Betriebs von Scrapern.

Fazit

Um eine Zillow-Seite abzurufen, benötigen Sie einen Client mit dem TLS-Fingerabdruck eines Browsers. Vollständige Daten zu erhalten ist schwieriger, da jede Suche bei 984 Listings stoppt und die meisten Listing-Seiten die Preishistorie in einer zweiten Anfrage laden. Für eine einmalige Aufgabe in einem kleinen Gebiet ohne Preishistorie reicht der curl_cffi-Scraper aus. Für jedes Listing mit seiner Preishistorie nach Zeitplan übernimmt die Bright Data Zillow Scraper API beides für Sie.

Erstellen Sie ein kostenloses Bright Data Konto, führen Sie Discovery für eine Such-URL aus Ihrem Markt aus und vergleichen Sie die Zeilenanzahl mit der von Zillow gemeldeten Gesamtzahl.

FAQ

Kann man Daten von Zillow scrapen?

Ja, wenn Ihr Client den TLS-Fingerabdruck eines Browsers nutzt. Die Python-Bibliothek requests erhält ein HTTP 403, während curl_cffi, das Chrome imitiert, die Seite erhält. Listing-Felder befinden sich in JSON, das in jede Seite eingebettet ist, sodass ein Parser sie lesen kann, ohne vom HTML-Layout abhängig zu sein.

Warum zeigt Zillow nicht alle Listings?

Zillow begrenzt jede Suche. Die Paginierung zeigt 20 Seiten, aber die Seite liefert 24 Seiten mit je 41 Listings, sodass eine Suche höchstens 984 Listings erreicht. Teilen Sie große Gebiete in kleinere Suchen auf, oder nutzen Sie die Bright Data Zillow Scraper API, die alle 1.295 Fort-Wayne-Listings über eine URL fand.

Wie erhalte ich Zillow-Preishistoriedaten?

Nutzen Sie die Bright Data Zillow Scraper API. Ihre Datensätze für zum Verkauf stehende Listings enthalten Preishistorie, und ihr Full-Properties-Scraper liefert diese für vom Markt genommene Häuser. Die meisten Listing-Seiten laden die Preishistorie in einer zweiten Anfrage, reine HTML-Scraper verpassen sie also. Für Markttrends nach Postleitzahl veröffentlicht Zillow Research kostenlose CSVs.

Hat Zillow eine offene API?

Nein. Die APIs der Zillow Group für MLS-Listings, öffentliche Unterlagen und Zestimates erfordern eine Genehmigung, begrenzen tägliche Aufrufe und schränken die Datenspeicherung ein. Zillow Research bietet nur kostenlose CSVs mit Daten auf Marktebene an. Für Daten auf Listing-Ebene liefert die Bright Data Zillow Scraper API Listings und Preishistorie als strukturiertes JSON.

Wie kann man Zillow kostenlos scrapen?

Nutzen Sie curl_cffi, einen kostenlosen Python-HTTP-Client, der Chrome imitiert, um Listing-Seiten langsam abzurufen, und parsen Sie das eingebettete JSON. Diese Methode stoppt bei 984 Listings pro Suche und verpasst die meiste Preishistorie. Neue Bright-Data-Konten erhalten monatlich 5.000 kostenlose Credits ohne Kreditkarte, genug für 5.000 Zillow-Scraper-API-Datensätze.

Wie kann man Zillow ohne Programmieren scrapen?

Nutzen Sie das No-Code-Dashboard eines verwalteten Scrapers. Öffnen Sie im Bright Data Control Panel die Zillow Scraper API, fügen Sie Listing- oder Such-URLs ein und starten Sie den Durchlauf. Laden Sie die Datensätze anschließend als JSON oder CSV herunter. Für einen gesamten Markt können Sie statt des Scrapens einen fertigen Zillow-Datensatz kaufen.

Kann ChatGPT Web-Scraping durchführen?

ChatGPT kann Scraper-Code schreiben, ist aber nicht darauf ausgelegt, Zillow-Daten ohne andere Tools im großen Maßstab zu sammeln. Ein KI-Agent erhält strukturierte Listings, indem er das Zillow-Listing-Tool im Bright Data MCP Server aufruft und dann mit dem zurückgegebenen JSON arbeitet. Prüfen Sie den Zeitstempel jedes Datensatzes, bevor Ihr Agent einen Preis nennt.