๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

How to Scrape Real Estate Listings with Proxies

Learn how to scrape real estate listings with proxies: extract price, beds, sqft and agent fields, handle pagination and geo-targeting, and beat anti-bot.

S SparkProxy 1 22 min read
Share
How to Scrape Real Estate Listings with Proxies

If you want to scrape real estate listings at any real coverage, the wall you hit first is not parsing HTML, it's staying unblocked long enough to finish the run. Property portals rate-limit hard, render prices in JavaScript, and change prices by region. This is a hands-on guide: which fields to pull, how to page through thousands of results, how to geo-target regional portals, how to get past anti-bot defenses, and working code against SparkProxy's Scraping API. It's the build-it companion to our strategy piece on datacenter proxies for real estate data aggregation, so here we stay on the keyboard.

What you'll build

  • A field map that reliably extracts price, address, beds, baths, sqft, days on market, and agent
  • A paginator that walks search results without tripping rate limits
  • Geo-targeted requests for US, UK, EU, and AU portals
  • An anti-bot escalation ladder (datacenter to residential, plus stealth) so a 403 doesn't end the job
  • A full production loop that writes clean rows to CSV

Scrape responsibly: public data and ToS

Before any code, set the boundary. Scraping publicly displayed listing data that any anonymous visitor can see sits on defensible ground, and US courts have repeatedly declined to treat access to public pages as unauthorized access. That's not a license to do whatever you want. A few practical rules keep you on the right side:

  • Public pages only. Don't scrape anything behind a login, a paywall, or an agent-only MLS portal. Member agreements bind those, and bypassing authentication is a different legal category.
  • Respect robots.txt and ToS as signals. Most portals prohibit automated access in their terms. That rarely creates criminal exposure for public data, but it does mean the site will fight you technically, and it shapes how you should behave: slow, polite, low-footprint.
  • Don't rebuild their product. Using collected data internally for valuation models, market research, or lead scoring is a different risk profile from republishing scraped listings as a competing portal, which invites licensing claims on MLS-sourced data.
  • Rate-limit yourself. Hammering a server is both a detection signal and a bad-neighbor move. A polite crawl that spreads load is more sustainable than a fast one that gets you banned in an hour.

For the full legal framework (MLS licensing, RESO agreements, Fair Housing implications when data feeds automated decisions), read the compliance section of the real estate data aggregation guide. The rest of this article assumes you're collecting public listing data for internal analysis.


What fields to extract from a listing

A listing detail page carries far more than a price. Decide your schema up front, because retrofitting a field after you've scraped 200,000 pages means re-scraping. Here's the field reference most property data pipelines converge on, with the data type and the gotcha that bites people.

FieldData typeExample valueExtraction notes
`price`integer`459000`Strip `$`, commas, and `+`. Distinguish list price from an estimated value ("Zestimate"-style) shown nearby.
`address`string`123 Oak St, Austin, TX 78704`Split into street / city / state / zip on write so you can geocode and dedupe.
`beds`float`3`Studios show as `0` or "Studio". Some rentals list a range ("1-2").
`baths`float`2.5`Half baths matter. Keep the decimal, don't round to int.
`sqft`integer`1840`Interior area. Do not confuse with `lot_size`. May be missing on land/condo listings.
`lot_size`integer`6534`Usually square feet, sometimes acres. Capture the unit.
`days_on_market`integer`12`Resets on relist. A jump from a low DOM to high often signals a price cut.
`status`string`active``active`, `pending`, `contingent`, `sold`, `off-market`. Drives whether the row is current.
`property_type`string`Single Family`Single Family, Condo, Townhouse, Multi-Family, Land. Normalize the vocabulary.
`year_built`integer`1998`Missing on new construction and some land.
`listing_agent`string`Jamie Rivera`Often paired with brokerage. Watch for "Listed by" prefixes.
`brokerage`string`Lone Star Realty`Useful for agent-level market share analysis.
`mls_id`string`AUS-4451203`The stable join key across sources when present.
`lat` / `lng`float`30.242, -97.769`Frequently embedded in a JSON blob or a static map URL, not visible text.
`price_history`array`[{date, price, event}]`Lives in an expandable section; usually needs `render_js` to appear.

Two of these are worth more than the rest for analysis. Days on market plus price history together tell you demand and seller motivation, which raw price alone never will. A 45-day listing with two price cuts is a different investment than a 4-day listing at asking, even at the same number.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Quickstart: one listing with the Scraping API

The reason to route through a managed Scraping API rather than raw proxies is that real estate portals combine three problems at once: aggressive IP rate limits, JavaScript-rendered prices, and behavioral fingerprinting. The API handles proxy rotation, headless Chromium rendering, and anti-bot measures behind a single call, so you send a URL and get back rendered HTML or structured JSON. If you want the trade-off analysis, see web scraping API vs self-managed proxies.

The base endpoint is https://scrape.sparkproxy.io/api/v1 and it authenticates with an X-API-Key header. The simplest possible call, rendering a listing page through a US IP:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.example-portal.com/homedetails/123-oak-st" \
  --data-urlencode "render_js=true" \
  --data-urlencode "country_code=US"

The same thing in Python, which is where the rest of this guide lives:

import requests

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"          # from your SparkProxy dashboard

resp = requests.get(
    API,
    headers={"X-API-Key": API_KEY},
    params={
        "url": "https://www.example-portal.com/homedetails/123-oak-st",
        "render_js": "true",       # portals render price/beds via JS
        "country_code": "US",      # match the portal's country
        "wait_for": "[data-testid='price']",  # don't capture until price exists
    },
    timeout=90,
)
resp.raise_for_status()
html = resp.text

The one parameter people forget is wait_for. Modern portals build the page shell first and hydrate the price a beat later. Without wait_for, you capture the shell and your price field comes back empty on a random fraction of pages. Give it the selector for the element you most care about and the API waits for it before capturing.


Extracting structured fields with extract_rules

You can pull the full HTML and parse it yourself, but for scalar fields it's cleaner to let the API return JSON directly. Pass extract_rules as a JSON object mapping each output field to a CSS selector, and set format=json. You get back a dict keyed by your field names.

import requests, json

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"

# Map your schema field -> CSS selector on the target page.
# Selectors are portal-specific; inspect the page to confirm them.
extract_rules = {
    "price":          "[data-testid='price']",
    "address":        "h1[data-testid='address']",
    "beds":           "[data-testid='bed-bath-item']:nth-of-type(1)",
    "baths":          "[data-testid='bed-bath-item']:nth-of-type(2)",
    "sqft":           "[data-testid='bed-bath-item']:nth-of-type(3)",
    "days_on_market": "[data-testid='dom-value']",
    "listing_agent":  "[data-testid='listing-attribution'] .agent-name",
    "status":         "[data-testid='status-pill']",
}

resp = requests.get(
    API,
    headers={"X-API-Key": API_KEY},
    params={
        "url": "https://www.example-portal.com/homedetails/123-oak-st",
        "render_js": "true",
        "country_code": "US",
        "format": "json",
        "extract_rules": json.dumps(extract_rules),
        "wait_for": "[data-testid='price']",
    },
    timeout=90,
)
listing = resp.json()
print(listing)
# {"price": "$459,000", "address": "123 Oak St, Austin, TX 78704",
#  "beds": "3 bd", "baths": "2.5 ba", "sqft": "1,840 sqft", ...}

The values come back as the raw on-page text, so add a cleaning layer. Never store "$459,000" when you'll want to do arithmetic on it later.

import re

def to_int(text):
    """'$459,000' or '1,840 sqft' -> 459000 / 1840. None if no digits."""
    if not text:
        return None
    digits = re.sub(r"[^\d]", "", text)
    return int(digits) if digits else None

def to_float(text):
    """'2.5 ba' -> 2.5 . Handles 'Studio' as 0."""
    if not text:
        return None
    if "studio" in text.lower():
        return 0.0
    m = re.search(r"\d+(\.\d+)?", text)
    return float(m.group()) if m else None

clean = {
    "price":          to_int(listing.get("price")),
    "beds":           to_float(listing.get("beds")),
    "baths":          to_float(listing.get("baths")),
    "sqft":           to_int(listing.get("sqft")),
    "days_on_market": to_int(listing.get("days_on_market")),
    "address":        (listing.get("address") or "").strip(),
    "agent":          (listing.get("listing_agent") or "").strip(),
    "status":         (listing.get("status") or "").strip().lower(),
}

Keep the raw response too, at least during development. When a selector silently starts returning None after a portal redesign, having the raw HTML archived is the difference between a five-minute fix and a re-scrape.


Handling pagination across search results

Extracting one page is the easy 5%. The job is walking a search result set that runs to hundreds of pages and collecting every listing URL, then visiting each. Portals paginate three ways, and you handle each differently:

  • Numbered pages (?page=2, /homes/2_p/): the friendliest case. Increment until you get an empty result set.
  • Infinite scroll: no page param. You need the API to scroll the page so more cards load, which you drive with js_scenario.
  • A capped result window: many portals hard-cap search results at roughly 20 pages regardless of how many matches exist. The fix is not more pages, it's narrower queries. Split a metro into price bands, zip codes, or bedroom counts so each query returns under the cap.

That last point is the one that trips up most people. If a metro has 8,000 listings but the portal only paginates 800 of them, you don't scrape harder, you slice the search. Query by zip, then by price band inside dense zips, and union the deduplicated results.

A numbered paginator that harvests listing URLs from each search page:

from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time, random

def collect_listing_urls(search_url, session, max_pages=20):
    """Walk numbered search pages, return a deduped list of detail URLs."""
    found = []
    for page in range(1, max_pages + 1):
        page_url = f"{search_url}?page={page}"
        html = fetch_html(page_url, session)     # defined in the full script below
        if not html:
            break
        soup = BeautifulSoup(html, "html.parser")
        cards = soup.select("a[data-testid='property-card-link']")
        links = [urljoin(page_url, a["href"]) for a in cards if a.get("href")]
        if not links:
            break                                 # ran past the last page, stop
        found.extend(links)
        # polite, jittered delay between pages
        time.sleep(random.uniform(1.5, 4.0))
    # dedupe while preserving order
    return list(dict.fromkeys(found))

For infinite-scroll portals, drive the scroll with js_scenario so lazy-loaded cards render before capture:

scenario = {
    "instructions": [
        {"scroll_y": 4000}, {"wait": 1200},
        {"scroll_y": 8000}, {"wait": 1200},
        {"scroll_y": 12000}, {"wait": 1200},
    ]
}
params = {
    "url": search_url,
    "render_js": "true",
    "js_scenario": json.dumps(scenario),
    "country_code": "US",
}

Each scroll-and-wait pair triggers another batch of listing cards. Three or four cycles usually loads a full "page" worth of results on typical portals.


Geo-targeting regional and international portals

Two different geo problems live under one word. The first: some county assessor and regional MLS-adjacent portals only serve, or only serve correct pricing to, in-country IPs. The second: prices, currency, and availability on international portals depend on where the request appears to come from. Both are solved by the country_code parameter, an ISO 3166-1 alpha-2 code that pins the exit IP to that country.

Match the code to the portal's home market:

RegionExample portals`country_code`Notes
United StatesZillow, Redfin, Realtor.com`US`County assessor sites often need a same-country or same-state IP.
United KingdomRightmove, Zoopla`GB`Prices in GBP; a non-GB IP can trigger interstitials.
SpainIdealista`ES`Strong bot mitigation; pair with `premium_proxy`.
GermanyImmoScout24`DE`Cookie/consent wall renders via JS; use `wait_for`.
FranceSeLoger`FR`Regional filtering; keep the IP in-country.
Australiarealestate.com.au, Domain`AU`Aggressive rate limits; go slow.
CanadaRealtor.ca`CA`Serves localized MLS data by region.

In code, geo-targeting is one parameter, so a multi-country crawl is just a loop over a config:

PORTALS = [
    {"name": "us_metro",  "search": "https://www.example-portal.com/austin-tx",  "country": "US"},
    {"name": "uk_london", "search": "https://www.example-uk.com/london",          "country": "GB"},
    {"name": "es_madrid", "search": "https://www.example-es.com/madrid",          "country": "ES", "premium": True},
]

for portal in PORTALS:
    urls = collect_listing_urls(portal["search"], session)
    for url in urls:
        row = scrape_listing(url, country=portal["country"],
                             premium=portal.get("premium", False))
        # ... persist row

If you're doing geo-targeted collection for market analysis rather than a single portal, the same pattern powers broader work like using proxies for market research and data collection.


Beating anti-bot without residential everywhere

Real estate portals run some of the strictest bot mitigation on the public web, and the highest-value pages (detail pages with price history and valuations) are guarded harder than search pages. The mistake is reaching for the most expensive proxy tier on every request. The right move is an escalation ladder: start cheap, escalate only the requests that get blocked.

The ladder, cheapest to strongest:

  1. Rotating datacenter + JS render. Handles county portals, Redfin, Apartments.com, and most search pages. Set render_js=true.
  2. Add stealth=true. Layers extra anti-detection (fingerprint hardening) for portals that sniff headless browsers.
  3. Escalate to premium_proxy=true (residential exit IPs) for the sites and detail pages that hard-block datacenter ranges, like Zillow detail pages and Idealista.
  4. Vary device (desktop, mobile, random) so repeated hits don't share one fingerprint.

The core discipline is rate, not just IP quality. Even with rotation, sending 200 requests a minute at one portal looks nothing like human browsing. Spread the load and add jitter. Our full playbook on this is how to avoid getting your proxy blocked; the general scraping foundations are in using datacenter proxies for web scraping.

Map symptoms to fixes so you're not guessing when a run degrades:

SymptomLikely causeFix
`403` on the first requestDatacenter IP flagged, or thin headersAdd `stealth=true`; escalate to `premium_proxy=true`
`429` after N requestsPer-IP rate too highSlow down, widen delays, lean on rotation
Empty `price` / `beds` fieldsData hydrated after load`render_js=true` plus `wait_for` on the price selector
CAPTCHA or interstitial pageBehavioral detection`stealth=true`, `device=random`, slower cadence
Correct HTML but wrong-currency pricesGeo mismatchSet `country_code` to the portal's country
`451` unavailableLegal geoblockMatch `country_code`; review the portal's ToS

The one insight most guides skip: let the block response tell you when to escalate, instead of paying for residential up front. Send datacenter first, and only flip premium_proxy on for the exact URLs that return a 403. On a mixed target set, that typically keeps 70 to 90% of requests on the cheap tier and reserves the expensive tier for the pages that genuinely need it. The escalation logic in the next section does exactly this automatically.


A complete production scraper

Here's the whole thing wired together: a session, a request function with retry, backoff, and automatic datacenter-to-residential escalation, a detail-page extractor, and a runner that paginates, scrapes, cleans, and writes CSV.

import requests, json, time, csv, random, re
from bs4 import BeautifulSoup
from urllib.parse import urljoin

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"          # from your SparkProxy dashboard

session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})

FIELDS = {
    "price":          "[data-testid='price']",
    "address":        "h1[data-testid='address']",
    "beds":           "[data-testid='bed-bath-item']:nth-of-type(1)",
    "baths":          "[data-testid='bed-bath-item']:nth-of-type(2)",
    "sqft":           "[data-testid='bed-bath-item']:nth-of-type(3)",
    "days_on_market": "[data-testid='dom-value']",
    "listing_agent":  "[data-testid='listing-attribution'] .agent-name",
    "status":         "[data-testid='status-pill']",
}

def to_int(t):
    d = re.sub(r"[^\d]", "", t or "")
    return int(d) if d else None

def to_float(t):
    if not t: return None
    if "studio" in t.lower(): return 0.0
    m = re.search(r"\d+(\.\d+)?", t)
    return float(m.group()) if m else None

def api_get(url, country="US", extract=None, premium=False):
    """Single call with retry, backoff, and datacenter->residential escalation."""
    params = {
        "url": url,
        "render_js": "true",
        "country_code": country,
        "stealth": "true",
        "wait_for": "[data-testid='price']",
    }
    if extract:
        params["format"] = "json"
        params["extract_rules"] = json.dumps(extract)
    if premium:
        params["premium_proxy"] = "true"

    for attempt in range(4):
        try:
            r = session.get(API, params=params, timeout=90)
        except requests.RequestException:
            time.sleep(2 ** attempt)
            continue
        if r.status_code == 200:
            return r.json() if extract else r.text
        if r.status_code == 403 and not params.get("premium_proxy"):
            params["premium_proxy"] = "true"     # escalate this URL to residential
        if r.status_code in (403, 429, 500, 502, 503):
            time.sleep((2 ** attempt) + random.uniform(0, 1.5))
            continue
        r.raise_for_status()
    return None

def fetch_html(url, sess, country="US"):
    return api_get(url, country=country)

def collect_listing_urls(search_url, country="US", max_pages=20):
    found = []
    for page in range(1, max_pages + 1):
        html = api_get(f"{search_url}?page={page}", country=country)
        if not html:
            break
        soup = BeautifulSoup(html, "html.parser")
        links = [urljoin(search_url, a["href"])
                 for a in soup.select("a[data-testid='property-card-link']")
                 if a.get("href")]
        if not links:
            break
        found.extend(links)
        time.sleep(random.uniform(1.5, 4.0))
    return list(dict.fromkeys(found))

def scrape_listing(url, country="US", premium=False):
    raw = api_get(url, country=country, extract=FIELDS, premium=premium)
    if not raw:
        return None
    return {
        "url":            url,
        "price":          to_int(raw.get("price")),
        "address":        (raw.get("address") or "").strip(),
        "beds":           to_float(raw.get("beds")),
        "baths":          to_float(raw.get("baths")),
        "sqft":           to_int(raw.get("sqft")),
        "days_on_market": to_int(raw.get("days_on_market")),
        "agent":          (raw.get("listing_agent") or "").strip(),
        "status":         (raw.get("status") or "").strip().lower(),
    }

def run(search_url, out="listings.csv", country="US"):
    urls = collect_listing_urls(search_url, country=country)
    print(f"Found {len(urls)} listings")
    cols = ["url", "price", "address", "beds", "baths",
            "sqft", "days_on_market", "agent", "status"]
    with open(out, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=cols)
        writer.writeheader()
        for i, url in enumerate(urls, 1):
            row = scrape_listing(url, country=country)
            if row:
                writer.writerow(row)
            if i % 25 == 0:
                print(f"  {i}/{len(urls)} scraped")
            time.sleep(random.uniform(1.0, 3.0))   # polite pacing

if __name__ == "__main__":
    run("https://www.example-portal.com/austin-tx", out="austin.csv", country="US")

This survives the failure modes that stop naive scrapers: it waits for the price to render, backs off on 429s, escalates only blocked URLs to residential, dedupes listing links, and paces itself. Swap the selectors in FIELDS and the property-card-link selector for your target portal and it runs.


Cost per request: choosing the cheapest mode that works

The Scraping API bills in credits, and the mode you pick per request is the biggest lever on cost. Rendering JavaScript and routing through residential IPs cost more, so match the mode to what the target actually needs rather than defaulting to the strongest option.

Request modeCreditsUse it for
Rotating datacenter, HTTP only1Static county assessor pages, sitemaps, robots-friendly endpoints
Rotating datacenter + JS render5Most modern portals; search pages and standard detail pages
Premium (residential), no JS10Portals that hard-block datacenter ranges but serve static HTML
Premium (residential) + JS render25Zillow-class detail pages with the strongest anti-bot
Add-on: `stealth`, `country_code`, `js_scenario`, screenshot+5 eachLayer only when a target requires it

The math that matters at scale: a national run of 500,000 detail pages costs 2.5 million credits if you render JS on datacenter (5 each), but 12.5 million if you blindly use premium plus JS (25 each) on every page. The escalation ladder from the anti-bot section is what keeps you near the low end. Run datacenter first, escalate the blocked minority, and you pay the 25-credit rate only on the pages that truly won't yield any other way.


Frequently asked questions

FAQ

Scraping publicly displayed listing data that any anonymous visitor can view is generally defensible under current US case law, which has declined to treat access to public web pages as unauthorized access. The constraints come from elsewhere: don't bypass logins or MLS member portals, don't republish MLS-licensed data as a competing product, and comply with Fair Housing rules if the data feeds lending or rental decisions. Using public listing data for internal analysis is the lowest-risk case. Consult counsel for commercial redistribution.

Yes, with the right setup, though Zillow runs sophisticated behavioral detection beyond simple IP counting. Search and list pages often work on rotating datacenter IPs with JavaScript rendering and precise browser headers. Detail pages with price history and valuations are guarded harder and are more reliable through residential (premium) IPs plus stealth mode. The key is conservative per-IP rates (roughly 15 to 20 requests per hour) and high-variance delays, not just proxy quality.

The standard set is price, full address, beds, baths, square footage, lot size, days on market, listing status, property type, year built, listing agent, brokerage, MLS ID, and latitude/longitude. Higher-value derived signals include price history and price-cut events, which usually live in an expandable section that only appears after JavaScript renders, so you need render_js and a wait_for selector to capture them reliably.

Not for everything. County assessor portals, Redfin, and Apartments.com typically work fine on rotating datacenter proxies with JavaScript rendering. Reserve residential (premium) IPs for the sites and pages that hard-block datacenter ranges, such as Zillow detail pages and some international portals like Idealista. The cost-efficient pattern is to send datacenter first and escalate to residential only on the specific URLs that return a 403.

For numbered pages, increment the page parameter until a page returns no listing cards, then stop. For infinite scroll, drive the page with a js_scenario that scrolls and waits so lazy-loaded cards render before capture. The common trap is a hard cap of around 20 result pages regardless of total matches; the fix is narrower queries (by zip code, price band, or bedroom count), then union and dedupe the results across queries.

With a credit-based Scraping API, cost depends on mode: rotating datacenter with JavaScript rendering is about 5 credits per request, while residential plus rendering is around 25. Scraping 500,000 detail pages at the datacenter rate is roughly 2.5 million credits; blindly using the premium tier everywhere is five times that. An escalation strategy that only upgrades blocked URLs keeps the large majority of requests on the cheap tier.


Limited-time ยท 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon โ€” claim it before it's gone

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates proxy infrastructure and a managed Scraping API used for large-scale web data collection, including PropTech and real estate data pipelines. We run datacenter and residential proxy pools with country-level geo-targeting, headless rendering, and anti-bot handling built for the strict rate limits property portals enforce. The code and configuration in this guide reflect how we and our customers collect public listing data in production. Explore the SparkProxy Scraping API docs to start building.

Keep reading

Related articles