🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Scrape Idealista Property Listings

Learn how to scrape Idealista property listings across Spain, Italy, and Portugal: extract prices, locations, and features, and get past DataDome.

S SparkProxy 2 27 min read
Share

To scrape Idealista at any useful scale, the rendering is not what stops you. Idealista serves most of its listing data as plain server-rendered HTML, so there is no React hydration puzzle like the one Zillow or Airbnb hand you. The wall is DataDome, one of the strictest bot-management networks on the European web, and it treats a datacenter IP or a mismatched country the way a bouncer treats a fake ID. This guide stays hands-on. You will see which fields an Idealista listing exposes, how to pull them across the Spanish, Italian, and Portuguese sites, how to page past the 1,800-result search cap, how to get through DataDome without a headless browser fighting you, and working Python against SparkProxy's Scraping API. It is the European-portal companion to our general guide on scraping real estate listings, so here we go deep on what makes Idealista its own problem.

What you'll build

  • A field map for price, price per m², location, size, rooms, features, and the energy rating
  • A per-country config for the .com, .it, and .pt sites with the right geo-matched exit IP
  • Structured extraction with the API's extract_rules so one call returns clean JSON
  • A search paginator that beats Idealista's 60-page, 1,800-listing cap
  • A DataDome escalation ladder so a 403 challenge doesn't end the run

Scrape responsibly: ToS, GDPR, and the EU database right

Set the boundary before any code, because Europe changes the risk math. Three things apply to Idealista that a US-focused Zillow guide never mentions.

  • The EU database right. Idealista's listing database is protected by the sui generis database right (Directive 96/9/EC), separate from copyright. Extracting a "substantial part" of a protected database can infringe it even when no single listing is copyrightable. Collecting a modest, targeted slice for internal analysis is a very different profile from mirroring an entire national inventory. Know which side you are on.
  • GDPR covers the advertiser data. Many Idealista listings carry an agent or private seller name and phone number. Under the GDPR that is personal data, and processing it needs a lawful basis, whether or not you are based in the EU, because the data subjects are. The safe default is to skip contact fields entirely and keep only price, location, size, and features. If you must store contact details, you need a documented basis and you must honor access and erasure requests.
  • The Terms of Service prohibit automated collection, and Idealista enforces that technically through DataDome rather than politely. Breaching the ToS is a contract issue, not usually a criminal one for public data, but it tells you the site will fight you, so behave: go slow, keep a small footprint, and never touch anything behind a login.

The rest of this article assumes you are collecting public listing attributes for internal market analysis, not harvesting seller PII or rebuilding the portal. For the wider compliance framework around real estate data pipelines, see our real estate data aggregation guide. Anything you plan to publish or resell needs legal sign-off first.


Idealista is three sites, not one: .com, .it, .pt

The single biggest thing that separates Idealista scraping from a US portal: it runs three national sites with localized paths, and each one wants traffic from its own country. Idealista serves Spain from idealista.com, Italy from idealista.it, and Portugal from idealista.pt. The URL words change with the language, and so does the exit IP you need.

CountryDomainSale pathRent pathDetail path
Spain`idealista.com``/venta-viviendas/{loc}/``/alquiler-viviendas/{loc}/``/inmueble/{id}/`
Italy`idealista.it``/vendita-case/{loc}/``/affitto-case/{loc}/``/immobile/{id}/`
Portugal`idealista.pt``/comprar-casas/{loc}/``/arrendar-casas/{loc}/``/imovel/{id}/`

Two rules fall out of this table. First, the {id} in the detail path is your primary key. Titles get edited and prices move, but the numeric id in /inmueble/12345678/ is stable, so dedupe on it. Second, geo is not optional. DataDome scores a request partly on whether its origin makes sense for the site, and a Portuguese IP hitting idealista.pt looks native in a way a US datacenter range never will. Beyond bot scoring, Idealista tailors currency, language, and available inventory by region, so a matched IP also gets you the correct page. Pin each site to its country with the API's country_code parameter: es for .com, it for .it, pt for .pt. If you are new to geo routing, our explainer on what geo-targeting means in proxies covers the mechanics.

SITES = {
    "es": {"host": "https://www.idealista.com", "sale": "venta-viviendas",
           "rent": "alquiler-viviendas", "detail": "inmueble", "country": "es"},
    "it": {"host": "https://www.idealista.it",  "sale": "vendita-case",
           "rent": "affitto-case",      "detail": "immobile", "country": "it"},
    "pt": {"host": "https://www.idealista.pt", "sale": "comprar-casas",
           "rent": "arrendar-casas",     "detail": "imovel",   "country": "pt"},
}

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

What an Idealista listing exposes

Decide your schema up front. Re-scraping half a million pages because you forgot the energy rating is a bad day. Here is the field set most Idealista property data pipelines converge on, with the type and the catch that bites people.

FieldTypeExampleNotes
`ad_id`integer`98765432`The number in `/inmueble/{id}/`. Stable primary key, immune to title and price edits.
`price`integer`285000`Asking price in euros. On rentals this is monthly.
`price_per_m2`integer`3200`Shown on many listings, or derive it from `price / size`.
`operation`string`sale``sale` or `rent`. Comes from which search path you entered.
`property_type`string`flat`Piso/flat, casa/house, ático/penthouse, chalet, estudio/studio, dúplex.
`size_built`integer`92`Built area in m². Do not confuse with useful area (`superficie útil`), which is smaller.
`rooms`integer`3`Bedrooms (`habitaciones`, `locali`, `quartos`).
`bathrooms`integer`2`Baths (`baños`, `bagni`, `casas de banho`).
`floor`string`3`Floor plus a `has_lift` flag. A 4th-floor flat with no lift changes the price.
`location`object`{municipality, district, neighborhood, province}`Idealista's geo hierarchy. You will tile searches along it later.
`lat` / `lng`float`40.416, -3.703`Approximate map coordinates. Idealista fuzzes the exact point for privacy.
`energy_rating`string`E`The EU energy certificate letter (A to G). Legally required on EU listings, so it is widely present.
`features`array`["terrace", "air_con", "garage"]`Amenities. Names are localized in the raw HTML; normalize them.
`description`stringfree textThe listing blurb, in the site language.
`updated`date`2026-07-14`Last-modified date. Useful for detecting stale inventory.

The two fields most guides ignore are the ones that carry analytical weight. energy_rating is a real, comparable signal that only exists because EU law forces it onto every listing, and it correlates with age, renovation state, and running cost in a way square metres alone never capture. And updated lets you separate a fresh listing from a tired one that has sat for four months, which is the difference between market rate and an anchor you can push on.


Where Idealista keeps its data: HTML plus a hidden analytics blob

Here is the part that trips up people arriving from a Zillow or Airbnb build. Idealista is mostly server-rendered. The price, size, rooms, and features are already in the HTML that comes back from the first request, so you do not need to render JavaScript just to read them. That makes the happy path cheaper: no headless Chromium, no waiting for hydration.

You have two extraction surfaces, and using both makes the parser durable:

  1. The visible HTML. Parse it with CSS selectors. This is the primary source for every field in the table above. The catch is that Idealista rotates class names, so keep every selector in one dictionary and treat it as the thing you patch when a field goes missing.
  2. The analytics payload. Idealista drops a JavaScript object into the page for its tag manager. Grep the HTML for utag_data, and where it is present it hands you machine-readable fields such as the ad id, price, operation, and property type as key-value pairs. Analytics payloads change far less often than presentational CSS, so this is your stable fallback when a selector breaks mid-run.
import re, json

def parse_utag(html):
    """Idealista embeds a tag-manager object; use it as a stable fallback."""
    m = re.search(r"utag_data\s*=\s*(\{.*?\})\s*;", html, re.S)
    if not m:
        return {}
    raw = m.group(1)
    try:
        return json.loads(raw)          # some pages ship valid JSON
    except json.JSONDecodeError:
        # fall back to plucking individual keys when quoting is loose
        out = {}
        for key in ("adId", "price", "operation", "propertyType"):
            km = re.search(rf'["\']{key}["\']\s*:\s*["\']?([^"\',}}]+)', raw)
            if km:
                out[key] = km.group(1).strip()
        return out

The discipline that keeps an Idealista scraper alive for months: pull the stable id from the URL, cross-check price against utag_data, and treat CSS selectors as the layer most likely to drift. When a field vanishes, you inspect one saved page, find the renamed class, and edit one line. That is a five-minute fix, not a rewrite. Archive one raw HTML blob per country during development so you can diff it later.


Quickstart: one listing through the Scraping API

Idealista bundles two hard problems: DataDome behavioral fingerprinting and geo-gated content. A managed Scraping API handles proxy rotation, geo routing, and anti-bot behind one call, so you send a URL and a country and get back HTML. If you want the build-versus-buy math, 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. Because Idealista is server-rendered, the cheapest call that clears DataDome is often a residential IP with no JavaScript rendering. Start there:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.idealista.com/inmueble/98765432/" \
  --data-urlencode "premium_proxy=true" \
  --data-urlencode "country_code=es" \
  --data-urlencode "render_js=false"

The same call in Python, 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.idealista.com/inmueble/98765432/",
        "premium_proxy": "true",   # residential IP; DataDome hard-blocks datacenter
        "country_code": "es",      # match the site: es for .com, it for .it, pt for .pt
        "render_js": "false",      # Idealista is server-rendered, skip the browser
    },
    timeout=90,
)
resp.raise_for_status()
html = resp.text

Two parameters do the heavy lifting here. premium_proxy=true routes through a residential IP, which DataDome scores far more kindly than a datacenter range. country_code=es puts the exit in Spain so the origin matches idealista.com. Leave render_js off until a page proves it needs a browser, because rendering roughly quadruples the credit cost per request and Idealista rarely requires it for listing data.


Extracting fields with extract_rules

You can parse the HTML yourself, and for full control you will. But the API can also return structured JSON directly with extract_rules, a map of field names to CSS selectors, so a single call gives you a clean record with no local parsing. Keep the rules in one place so a class-name change is a one-line edit:

SELECTORS = {
    "title":     "h1.main-info__title-main",
    "price":     ".info-data-price .txt-bold",
    "location":  ".main-info__title-minor",
    "features":  ".info-features .txt-big",
    "energy":    ".icon-energy-c-certification + span",
    "description": ".comment p",
}

resp = requests.get(
    API,
    headers={"X-API-Key": API_KEY},
    params={
        "url": "https://www.idealista.com/inmueble/98765432/",
        "premium_proxy": "true",
        "country_code": "es",
        "extract_rules": json.dumps(SELECTORS),
    },
    timeout=90,
)
record = resp.json()      # {"title": "...", "price": "285.000", "location": "...", ...}

When you prefer to parse locally, a BeautifulSoup pass over the same selectors gives you the same fields plus room to normalize as you go. Idealista writes numbers in the local format, so 285.000 means two hundred eighty-five thousand, and you strip the dots before casting:

from bs4 import BeautifulSoup

def to_int(text):
    """'285.000 €/mes' -> 285000. Idealista uses '.' as a thousands separator."""
    if not text:
        return None
    digits = re.sub(r"[^\d]", "", text)
    return int(digits) if digits else None

def parse_detail(html, ad_id):
    soup = BeautifulSoup(html, "html.parser")
    def pick(sel):
        el = soup.select_one(sel)
        return el.get_text(" ", strip=True) if el else None

    record = {
        "ad_id":    ad_id,
        "title":    pick(SELECTORS["title"]),
        "price":    to_int(pick(SELECTORS["price"])),
        "location": pick(SELECTORS["location"]),
        "features": [f.get_text(strip=True)
                     for f in soup.select(SELECTORS["features"])],
        "energy":   pick(SELECTORS["energy"]),
    }
    # backfill from the analytics blob if a selector came up empty
    utag = parse_utag(html)
    if record["price"] is None and utag.get("price"):
        record["price"] = to_int(utag["price"])
    return record

The to_int helper matters more than it looks. A parser that reads 285.000 as 285 will silently corrupt an entire dataset, and euro formatting is exactly where that happens. Normalize currency and area at parse time, not in analysis.


Walking search results and pagination

Detail pages are the easy part. The real job is finding every listing in an area, which means walking search result pages. A search URL is the host plus the operation path plus a location slug, and Idealista paginates in the path. On the Spanish site page two of a Madrid sale search looks like https://www.idealista.com/venta-viviendas/madrid-madrid/pagina-2.htm.

Rather than hard-code the pagina-N.htm shape across three languages, follow the site's own next-page link. It is more durable and works identically on .it and .pt:

def search_url(site, operation, location, page=1):
    s = SITES[site]
    path = s["sale"] if operation == "sale" else s["rent"]
    base = f"{s['host']}/{path}/{location}/"
    return base if page == 1 else base + f"pagina-{page}.htm"

def fetch(url, site, render=False):
    r = requests.get(API, headers={"X-API-Key": API_KEY}, params={
        "url": url, "premium_proxy": "true",
        "country_code": SITES[site]["country"],
        "render_js": "true" if render else "false",
    }, timeout=90)
    return r

def parse_search(html):
    """Return (ad_ids, next_page_present) from a results page."""
    soup = BeautifulSoup(html, "html.parser")
    ids = []
    for card in soup.select("article.item"):
        link = card.select_one("a.item-link")
        if not link:
            continue
        m = re.search(r"/(?:inmueble|immobile|imovel)/(\d+)/", link.get("href", ""))
        if m:
            ids.append(m.group(1))
    has_next = soup.select_one("a.icon-arrow-right-after") is not None
    return ids, has_next

Collecting ids first, then fetching details, keeps concerns separate: a light search pass builds the work queue, and detail fetches drain it. It also lets you dedupe on ad_id before you spend a single detail request on a listing you already have.


Beating Idealista's 1,800-listing search cap

Now the wall that quietly caps every naive scraper. Idealista shows about 30 results per page and stops at 60 pages. That is 1,800 listings per search, no matter how many properties actually match. Ask for page 61 and you get nothing. Madrid alone holds tens of thousands of active sale listings, so one province-level search sees a small slice and misses the rest without a single error to warn you.

The fix is the same principle as Zillow's 500-result cap: do not ask for more pages, ask narrower questions. Idealista gives you two axes to slice on.

  • Geography. Idealista's location hierarchy runs province, then municipality, then district (distrito), then neighborhood (barrio). Instead of searching madrid-madrid, search each district slug (madrid/centro, madrid/salamanca, and so on). Each returns a fraction of the total and stays under 1,800.
  • Price bands. When a single district still pins the cap in a hot market, add a price filter and run one search per band (0 to 150k, 150k to 300k, and up). Combine both axes for full coverage.
def sweep(site, operation, sub_locations, per_page_delay=(8, 20)):
    """Union ad_ids across finer geographies; dedupe on the stable id."""
    import time, random
    seen = set()
    for loc in sub_locations:
        page = 1
        while page <= 60:                     # hard cap Idealista enforces
            r = fetch(search_url(site, operation, loc, page), site)
            if is_blocked(r):                 # DataDome check, defined below
                r = fetch(search_url(site, operation, loc, page), site, render=True)
            ids, has_next = parse_search(r.text)
            before = len(seen)
            seen.update(ids)
            time.sleep(random.uniform(*per_page_delay))   # DataDome hates fixed rhythm
            if not has_next or len(ids) == 0:
                break
            page += 1
        print(f"{loc}: running total {len(seen)}")
    return seen

Deduping on ad_id is not optional, because districts overlap at their borders and a listing on a boundary shows up in both searches. The geography-first, price-band-second approach is what turns "I scraped Madrid" from a 1,800-row sample into the actual market.


Getting past DataDome

DataDome is the reason this guide exists. It is a dedicated bot-management network that scores every request on IP reputation, TLS and browser fingerprint, header order, and request pacing. When it decides you are a bot it does not return a clean 403 with an empty body. It serves a challenge page, usually a slider puzzle, sets a datadome cookie, and from then on watches whether you behave. The failure most people hit is trusting the HTTP status: a DataDome block can arrive dressed as a 200 with a challenge in the body, so detect it in the content, not just the code.

def is_blocked(resp):
    body = resp.text[:6000].lower()
    signals = ("datadome", "geo.captcha-delivery.com",
               "verifying you are human", "unusual traffic")
    return resp.status_code in (403, 429) or any(s in body for s in signals)

The right response is an escalation ladder that starts cheap and only upgrades what actually gets blocked. Since Idealista is server-rendered, the ladder is a little different from a JavaScript-heavy target:

  1. Residential IP, no JS. premium_proxy=true, country_code matched, render_js=false. This clears the majority of Idealista pages and is the cheapest mode that works.
  2. Add stealth and a browser. When a page returns a challenge, retry with render_js=true and stealth=true. That hardens the TLS and browser fingerprint DataDome inspects and executes the client-side checks a bare HTTP request skips.
  3. Rotate the fingerprint. Vary device between desktop and mobile so repeated hits on one district do not share a single fingerprint.

The lever that outweighs proxy quality is pacing. DataDome flags rhythm as much as identity, so a fixed one-request-per-second loop gets caught even on clean residential IPs. Hold each session to a human cadence, 8 to 20 seconds between pages with real jitter, and spread load across IPs. Our full playbook lives in how to bypass DataDome when web scraping and the general rules are in how to avoid getting your proxy blocked. Map symptoms to fixes so you are not guessing mid-run:

SymptomLikely causeFix
Slider/captcha page or `datadome` cookieIP or fingerprint flaggedEscalate to `render_js=true` plus `stealth=true`
`403` on the first requestDatacenter IP or wrong country`premium_proxy=true` with `country_code` matched to the domain
Clears then blocks after N pagesPacing too fast or too regularWiden delays to 8 to 20s with jitter; lean on rotation
Wrong language or currency on the pageExit IP not in the target countrySet `country_code` to `es`, `it`, or `pt` for that site
Empty result cards but 200 statusCaptured a challenge dressed as 200Detect DataDome in the body, then escalate

The insight most guides skip: let the block response decide when to escalate, instead of paying for a rendered browser on every request. Run detail and search pages on the cheap residential-no-JS mode first, and flip on render_js plus stealth only for the exact URLs that come back challenged. On a mixed run that keeps most requests at the low tier.


A complete production Idealista scraper

Here is the whole thing wired together: a per-country config, a request helper with DataDome detection and residential-to-rendered escalation, the search sweep with the 1,800-cap workaround, the detail parser with a utag_data fallback, and a runner that writes clean rows to CSV.

import requests, re, json, time, csv, random
from bs4 import BeautifulSoup

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

SITES = {
    "es": {"host": "https://www.idealista.com", "sale": "venta-viviendas",
           "rent": "alquiler-viviendas", "detail": "inmueble", "country": "es"},
    "it": {"host": "https://www.idealista.it",  "sale": "vendita-case",
           "rent": "affitto-case",      "detail": "immobile", "country": "it"},
    "pt": {"host": "https://www.idealista.pt", "sale": "comprar-casas",
           "rent": "arrendar-casas",     "detail": "imovel",   "country": "pt"},
}
SELECTORS = {
    "title": "h1.main-info__title-main", "price": ".info-data-price .txt-bold",
    "location": ".main-info__title-minor", "features": ".info-features .txt-big",
    "energy": ".icon-energy-c-certification + span",
}

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

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

def is_blocked(resp):
    body = resp.text[:6000].lower()
    signals = ("datadome", "geo.captcha-delivery.com", "verifying you are human")
    return resp.status_code in (403, 429) or any(s in body for s in signals)

def api_get(url, country, render=False):
    """Residential-first with escalation to a stealth browser on a DataDome block."""
    params = {"url": url, "premium_proxy": "true", "country_code": country,
              "render_js": "true" if render else "false"}
    if render:
        params["stealth"] = "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 and not is_blocked(r):
            return r.text
        if is_blocked(r) and params["render_js"] == "false":
            params["render_js"] = "true"      # escalate this URL only
            params["stealth"] = "true"
        time.sleep((2 ** attempt) + random.uniform(2, 6))
    return None

def parse_utag(html):
    m = re.search(r"utag_data\s*=\s*(\{.*?\})\s*;", html, re.S)
    if not m:
        return {}
    try:
        return json.loads(m.group(1))
    except json.JSONDecodeError:
        return {}

# ---- search ----

def search_url(site, operation, location, page=1):
    s = SITES[site]
    path = s["sale"] if operation == "sale" else s["rent"]
    base = f"{s['host']}/{path}/{location}/"
    return base if page == 1 else base + f"pagina-{page}.htm"

def parse_search(html):
    soup = BeautifulSoup(html, "html.parser")
    ids = []
    for link in soup.select("a.item-link"):
        m = re.search(r"/(?:inmueble|immobile|imovel)/(\d+)/", link.get("href", ""))
        if m:
            ids.append(m.group(1))
    has_next = soup.select_one("a.icon-arrow-right-after") is not None
    return ids, has_next

def sweep(site, operation, sub_locations):
    seen = set()
    for loc in sub_locations:
        page = 1
        while page <= 60:                     # Idealista's hard page cap
            html = api_get(search_url(site, operation, loc, page),
                           SITES[site]["country"])
            if not html:
                break
            ids, has_next = parse_search(html)
            seen.update(ids)
            time.sleep(random.uniform(8, 20))  # human cadence beats DataDome
            if not has_next or not ids:
                break
            page += 1
        print(f"{loc}: {len(seen)} unique ids so far")
    return seen

# ---- detail ----

def scrape_detail(site, ad_id):
    s = SITES[site]
    url = f"{s['host']}/{s['detail']}/{ad_id}/"
    html = api_get(url, s["country"])
    if not html:
        return None
    soup = BeautifulSoup(html, "html.parser")
    def pick(sel):
        el = soup.select_one(sel)
        return el.get_text(" ", strip=True) if el else None
    utag = parse_utag(html)
    price = to_int(pick(SELECTORS["price"])) or to_int(str(utag.get("price", "")))
    return {
        "ad_id":   ad_id,
        "country": site,
        "title":   pick(SELECTORS["title"]),
        "price":   price,
        "location": pick(SELECTORS["location"]),
        "energy":  pick(SELECTORS["energy"]),
        "features": "|".join(f.get_text(strip=True)
                             for f in soup.select(SELECTORS["features"])),
        "url":     url,
    }

def run(site, operation, sub_locations, out="idealista.csv"):
    ids = sweep(site, operation, sub_locations)
    print(f"Found {len(ids)} listings; fetching details")
    cols = ["ad_id", "country", "title", "price", "location",
            "energy", "features", "url"]
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols); w.writeheader()
        for i, ad_id in enumerate(ids, 1):
            row = scrape_detail(site, ad_id)
            if row:
                w.writerow(row)
            if i % 25 == 0:
                print(f"  {i}/{len(ids)} detailed")
            time.sleep(random.uniform(8, 20))

if __name__ == "__main__":
    MADRID_DISTRICTS = ["madrid/centro", "madrid/salamanca",
                        "madrid/chamberi", "madrid/retiro"]
    run("es", "sale", MADRID_DISTRICTS, out="madrid_sale.csv")

This survives the failure modes that stop naive Idealista scrapers. It matches the exit country to the domain, detects DataDome in the body rather than trusting the status code, escalates only blocked URLs to a rendered stealth browser, dedupes on the stable ad id, tiles the search by district to slip under the 1,800 cap, backfills price from utag_data when a selector drifts, and paces itself like a person. Swap the site, operation, and district list for your market and it runs.


Cost per request: pick the cheapest mode that works

The Scraping API bills in credits, and the mode you pick per request is the biggest lever on cost. Idealista is friendlier here than a JavaScript-heavy portal, because its data is in the first response, so the expensive rendered-browser mode is the exception, not the rule.

Request modeCreditsUse it for
Residential, no JS10Your default. Clears most Idealista pages, both search and detail.
Residential + JS render25Only the URLs that come back with a DataDome challenge.
Add-on: `stealth`+5Layer onto the rendered retry for the hardest challenges.
Add-on: `country_code`+5Always on, one per site, so the origin matches the domain.

The math at scale: a sweep that collects 10,000 listings on residential-no-JS runs about 10 credits per detail page plus a light search pass, and only the small fraction that hit a challenge escalate to 25. Blindly rendering every request would more than double the bill for data you could have read from the raw HTML. The escalation ladder is what keeps you near the low end. New accounts get 1,000 free credits, which is enough to prove a district sweep end to end before you spend anything. See the SparkProxy Scraping API docs for the current credit table and every parameter.


Frequently asked questions

FAQ

Scraping public listing attributes such as price, size, location, and features for internal analysis is generally defensible, but Europe adds two constraints a US portal lacks. Idealista's database is protected by the EU sui generis database right, so extracting a substantial part of it can infringe even without copying copyrighted text, and agent or seller contact details are personal data under the GDPR that needs a lawful basis to process. Idealista's terms also prohibit automated collection. The safe footing is a targeted, non-contact dataset for internal use, with legal sign-off before you publish or resell anything.

Yes, Idealista is protected by DataDome, which scores IP reputation, browser and TLS fingerprint, and request pacing, then serves a slider challenge and a datadome cookie when it flags you. The reliable approach is a geo-matched residential IP for the target country, detecting the challenge in the response body rather than trusting the status code, and human-like pacing of 8 to 20 seconds between pages. Escalate a blocked URL to a rendered stealth browser only when the cheap residential-no-JS mode returns a challenge.

For anything beyond a handful of pages, yes. DataDome scores datacenter ranges harshly and Idealista also varies inventory, currency, and language by country, so you want a residential IP inside the site's country: Spain for idealista.com, Italy for .it, Portugal for .pt. Set premium_proxy=true with the matching country_code and most pages clear without a browser at all.

Idealista runs three national sites with localized paths, so keep a per-country config: idealista.com uses venta-viviendas and inmueble, idealista.it uses vendita-case and immobile, and idealista.pt uses comprar-casas and imovel. Route each site through its own country with country_code set to es, it, or pt, and follow the site's own next-page link instead of hard-coding pagination, since the path words differ by language.

A single Idealista search returns at most 60 pages of about 30 results, which caps you at roughly 1,800 listings regardless of how many properties match. To cover a market that holds more, split the search by Idealista's geographic hierarchy (province, municipality, district, neighborhood) and add price bands when a district still hits the cap. Dedupe on the numeric ad id from the detail URL, because searches overlap at district borders.

Usually not. Unlike Zillow or Airbnb, Idealista serves its price, size, rooms, and features as server-rendered HTML in the first response, so render_js=false reads every listing field and costs less. Reserve render_js=true with stealth=true for the specific URLs where DataDome throws a challenge, since executing the client-side checks helps clear it. Rendering everything just to read static data wastes credits.


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 valuation pipelines across European markets. We run datacenter and residential proxy pools with country-level geo-targeting in Spain, Italy, and Portugal, headless rendering, and anti-bot handling built for the DataDome-class defenses that portals like Idealista 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