# Zoopla Property Scraper (UK) (`scrapyx/zoopla-properties-scraper`) Actor

Scrapes property listings for sale or rent from Zoopla, the UK's #2 property portal. Search by location, property type and price/bedroom filters; returns price, address, coordinates, features and agent from a single search call, with an optional detail pass.

- **URL**: https://apify.com/scrapyx/zoopla-properties-scraper.md
- **Developed by:** [Ibnu Adzim](https://apify.com/scrapyx) (community)
- **Categories:** Real estate, Lead generation, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.56 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Zoopla Property Scraper (UK)

Scrapes property listings for sale or rent from
**[Zoopla](https://www.zoopla.co.uk)** — the UK's #2/#3 property portal.
UK coverage in this portfolio was OnTheMarket only; this adds a second,
larger source (Rightmove, the #1 portal, blocks `ClaudeBot`/`CCBot` in
`robots.txt` and was not pursued).

Public data only. No login, no cookies, no browser.

### The one thing you need to know before using this

Zoopla sits behind a **Cloudflare managed challenge**. Two TLS profiles
(`chrome99_android`, `edge101`) are blocked deterministically and excluded
from the pool entirely; the remaining profiles are challenged
**intermittently** (roughly 1 in 3–4 requests). The client retries with a
rotated profile on every challenge — this is normal, expected traffic for
this target, not a sign something is broken. Give it a real retry budget
(the default `maxConcurrency`/`minRequestInterval` already do this) rather
than lowering concurrency to "fix" occasional log warnings.

Rendering is Next.js App Router RSC streaming (`self.__next_f.push(...)`),
not `__NEXT_DATA__` — same protocol family as
`boligsiden-properties-scraper` and `justjoinit-jobs-scraper` elsewhere in
this portfolio. See [`CRAWLING_METHOD.md`](CRAWLING_METHOD.md) for the full
trail.

### What you get

Three record types share one dataset, told apart by `recordType`.

#### `PROPERTY` — one row per listing

Search rows (`listing`) already carry price, full address, coordinates,
title, features, tags, photo gallery and the listing agent/branch. Turn on
**Fetch listing detail pages** to also attach `propertyDetail`, which adds
floor area, bed/bath counts, tenure, EPC, nearby stations and price
history — none of which are present in search results.

#### `SEARCH_SUMMARY` — one row per (section, property type, location) query

Pages fetched, rows returned, upstream's own `totalResults`, and
`segmentResolved` — whether the requested location/property-type
combination actually matched something upstream.

#### `ERROR` — one row per input that failed

So every entry in **Locations** maps to at least one output row.

### Input

| Field | What it does |
| --- | --- |
| **Locations** | place slugs as they appear in Zoopla's own URLs (`london`, `manchester`, `rye`) — one search per entry |
| **For sale or to rent** | applies to every location in the run |
| **Property type** | all types, houses, flats, bungalows or retirement homes — each verified live to genuinely narrow the result count |
| **Min/max price, min/max bedrooms** | all four verified live as real filters, not silently ignored |
| **Fetch listing detail pages** | adds floor area/tenure/EPC/stations/price history (off by default — one extra request per listing) |
| **Max properties / max pages per search** | pagination caps — Zoopla pages honestly and caps itself at ~1,000 listings/query regardless |
| **Max concurrent requests / Min seconds between requests** | tuned for the Cloudflare challenge described above — lowering these will not remove the occasional retry, it will just make the run slower |

#### Example

```json
{
  "locations": ["london", "manchester"],
  "section": "for-sale",
  "propertyType": "houses",
  "priceMax": 500000,
  "includePropertyDetails": true,
  "maxItems": 100
}
```

### Notes on reliability

- **Filters are genuinely honest, a rare case in this portfolio's
  REAL\_ESTATE family**: an unrecognised location OR an unrecognised
  property-type path segment both answer a clean Next.js "not found" page
  (`segmentResolved: false`, zero rows) — Zoopla does **not** silently
  widen to a national/parent baseline the way most other targets here do.
- **Pagination is genuinely honest**: `?pn=N` really advances (verified: 0
  listing-id overlap between consecutive pages), and a page past the real
  ceiling (`pageNumberMax`, ~40 pages/query) answers a clean HTTP 404 — no
  clamp-back-to-page-1 trap like several other actors in this portfolio
  have to work around.
- **`totalResults` is a real structured field** (not scraped from page
  copy), but Zoopla itself caps the *displayed* figure at 50,000 and flags
  this via `totalResultsWasLimited: true` — the true count can be higher.
- **A dead/renamed detail listing answers HTTP 200 with a "not found" page
  shell**, not a 404 — `propertyDetail` is simply `null` for that row
  rather than treated as a run failure.
- **An invalid `section`/`propertyType` from the API/CLI (bypassing the
  Console's enum picker) is refused up front** with a clear `ERROR` row —
  never silently substituted with the default.

### Output envelope

Every record carries `_input`, `_source` and `_scrapedAt`. Upstream field
names pass through **verbatim** under `listing` (and `propertyDetail` when
requested) — no renaming.

See [`CRAWLING_METHOD.md`](CRAWLING_METHOD.md) for the full
reverse-engineering trail, including the Cloudflare TLS-profile ladder, the
RSC flight-stream parsing approach, and the property types/filters that
were probed but NOT verified (so are deliberately not offered).

# Actor input Schema

## `locations` (type: `array`):

Place names/slugs as they appear in Zoopla's own URLs, e.g. 'london', 'manchester', 'rye', 'west-london'. One search per entry, each with its own SEARCH\_SUMMARY row. An unrecognised location is reported as segmentResolved=false rather than silently returning a national baseline.

## `section` (type: `string`):

Which market to search. Applies to every location in this run.

## `propertyType` (type: `string`):

Narrows results to one property type. 'property' means all types combined. Only types verified live to genuinely filter (distinct totals from the 'property' baseline) are offered.

## `priceMin` (type: `integer`):

Only listings priced at or above this (GBP). Verified live to genuinely filter, not silently ignored. Leave 0/empty for no minimum.

## `priceMax` (type: `integer`):

Only listings priced at or below this (GBP). Verified live to genuinely filter, not silently ignored. Leave 0/empty for no maximum.

## `bedsMin` (type: `integer`):

Only listings with at least this many bedrooms. Verified live to genuinely filter. Leave 0/empty for no minimum.

## `bedsMax` (type: `integer`):

Only listings with at most this many bedrooms. Verified live to genuinely filter. Leave 0/empty for no maximum.

## `includePropertyDetails` (type: `boolean`):

Also fetch each listing's detail page for floor area, bed/bath counts, tenure, EPC, nearby stations and price history -- none of which are present in search results. Costs one extra request per listing. Off by default because search results already carry price, address, coordinates, features, images and the listing agent.

## `maxItems` (type: `integer`):

Stop paginating a search after this many properties. Set to 0 for unlimited (still bounded by Max pages and Zoopla's own ~1,000-listing ceiling per query).

## `maxPages` (type: `integer`):

Hard cap on pagination depth, independent of maxItems. Zoopla pages honestly (a page past the real ceiling answers a clean HTTP 404, not a repeat/clamp) and caps queries at pageNumberMax=40 (25 listings/page, ~1,000 total) regardless of this setting.

## `maxConcurrency` (type: `integer`):

Upper bound on requests in flight at once, across searches and detail fetches. Kept moderate by default: this target sits behind a Cloudflare managed challenge that two TLS profiles fail deterministically and the rest fail intermittently (cleared by retry+profile-rotation, built into the client).

## `minRequestInterval` (type: `integer`):

Paces request starts (not held inside a concurrency slot) rather than raw concurrency. Kept at 1s by default as a courtesy to reduce how often the Cloudflare challenge fires.

## `proxyConfiguration` (type: `object`):

Zoopla sits behind a Cloudflare managed challenge (two TLS profiles blocked outright, the rest challenged intermittently). Residential proxy pinned to the UK is the default, matching this portfolio's standard baseline for a single-country site behind an active WAF.

## Actor input object example

```json
{
  "locations": [
    "london"
  ],
  "section": "for-sale",
  "propertyType": "property",
  "priceMin": 0,
  "priceMax": 0,
  "bedsMin": 0,
  "bedsMax": 0,
  "includePropertyDetails": false,
  "maxItems": 100,
  "maxPages": 20,
  "maxConcurrency": 4,
  "minRequestInterval": 1,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "GB"
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "locations": [
        "london"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/zoopla-properties-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "locations": ["london"] }

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/zoopla-properties-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "locations": [
    "london"
  ]
}' |
apify call scrapyx/zoopla-properties-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/zoopla-properties-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/9RS2xPTbPzXwprvf7/builds/vGYrIu5BFWnpGUztd/openapi.json
