# Rightmove Listings + Investor Deal Scoring (UK) (`steadycrawl/rightmove-deal-finder`) Actor

Pull live Rightmove for-sale listings by city/town or search URL (UK-wide: England, Scotland, Wales, Northern Ireland), and score every listing 0-100 for investor deal quality: price/sqft (or price/bedroom when size isn't listed) vs the batch median, days-on-market percentile, and gross rent yield.

- **URL**: https://apify.com/steadycrawl/rightmove-deal-finder.md
- **Developed by:** [Ian Rose](https://apify.com/steadycrawl) (community)
- **Categories:** Real estate, Automation, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.70 / 1,000 listing scoreds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## Rightmove Listings + Investor Deal Scoring (UK)

Pull live Rightmove for-sale listings by city/town or search URL -- **UK-wide
coverage: England, Scotland, Wales, and Northern Ireland** -- and get every
listing back with a transparent **0-100 investor deal score**, not just a
field dump.

### What it does

1. You give it one or more searches: a city/town name (`"Leeds"`,
   `"Newcastle upon Tyne"`), or a full `rightmove.co.uk/property-for-sale/*.html`
   search URL.
2. It requests Rightmove's own server-rendered search-results page directly
   \-- the same HTML the site itself renders -- and reads the listing data
   out of its embedded `__NEXT_DATA__` JSON. No browser, no separate
   resolver/typeahead call, no residential proxy spend.
3. Each listing comes back normalized (price, bedrooms, bathrooms, size in
   sqft where listed, address, lat/lng, property type, tenure, days on
   market, listing-update reason/date, the agent's summary, listing URL,
   image count, and the branch name where Rightmove exposes it) **plus**
   investor-relevant metrics and a single blended deal score, computed
   transparently from the listings in your own search batch.

### Why deal scores (and why this is different from the field-dump actors)

Most Rightmove scrapers hand you the same raw listing cards the site's own
search page already shows you. This actor adds the layer an investor
actually wants: is this listing *cheap for the area*, has it been *sitting
long enough to negotiate*, and does it *cash-flow* at your assumed rent.
Every input to the score is documented below -- nothing is a black box.

### Sample output

One real listing, exactly as this actor pushed it -- normalized and scored
against a live 50-listing Leeds batch (no rent assumption supplied, so the
rent-yield component is `null`; the `summary` string is shown in full here):

```json
{
  "id": 154815185,
  "price": 2750000,
  "bedrooms": 6,
  "bathrooms": 4,
  "sizeSqFt": 8103,
  "displayAddress": "Wigton Lane, Alwoodley, Leeds",
  "latitude": 53.858769,
  "longitude": -1.518732,
  "propertySubType": "Detached",
  "tenure": "FREEHOLD",
  "firstVisibleDate": "2024-11-11T09:21:36Z",
  "daysOnMarket": 646.8434082986112,
  "listingUpdateReason": "price_reduced",
  "listingUpdateDate": "2025-11-01T11:56:58Z",
  "summary": "A substantial family residence, occupying an established prime position in one of the most sought after residential addresses in Alwoodley",
  "propertyUrl": "https://www.rightmove.co.uk/properties/154815185#/?channel=RES_BUY",
  "imageCount": 32,
  "agent": "Butler Ridge, Wetherby",
  "ppsf": 339.3804763667777,
  "ppsfDeltaPct": -17.576099294463283,
  "pricePerBed": null,
  "ppBedDeltaPct": null,
  "valueAxis": "ppsf",
  "valueDeltaPct": -17.576099294463283,
  "valueAxisReason": null,
  "domPercentile": 97,
  "grossRentYieldPct": null,
  "dealScore": 85.6,
  "batch": {
    "search": "Leeds",
    "ppsfMedian": 411.7500791175008,
    "ppsfMedianReason": null,
    "ppBedMedian": 406785.7142857143,
    "ppBedMedianReason": null,
    "batchSize": 50,
    "validPpsfCount": 36,
    "validPpBedCount": 50,
    "resultCount": 4821,
    "truncated": true,
    "pagesFailed": 0
  }
}
```

**Where 85.6 comes from** -- every number above is reproducible from the
weights table below:

- **Value axis (£/sqft, since this listing has a size):** `2750000 / 8103 =
  £339.38` per sqft vs. the batch median of `£411.75` -> `valueDeltaPct =
  -17.58%` (17.6% cheaper per sqft than the batch). Mapped over
  `-30%..+30% -> 100..0`, that's a sub-score of `79.29`.
- **Days on market:** this listing has been up `646.8` days -- longer than
  every other listing in the batch bar one, so its midrank percentile is
  `97.0`. Percentiles are used directly as the sub-score.
- **Rent yield:** `null` (no `monthlyRentPerBedroomGBP` given), so its
  weight drops out and the remaining two renormalize over `0.45 + 0.25 =
  0.70`.
- **Blend:** `79.29 x (0.45/0.70) + 97.0 x (0.25/0.70) = 50.98 + 34.64 =
  85.62` -> **`dealScore` 85.6**.

**The £/bed fallback axis, on the same batch** -- an 8-bed estate on
Colliers Lane (`id 91889727`, £3,500,000) had no `displaySize` at all
(`sizeSqFt: null`), so its value axis fell back to price-per-bedroom
instead: `£3,500,000 / 8 beds = £437,500/bed` vs. the batch's own £/bed
median of `£406,785.71` -> `valueDeltaPct = +7.55%` (7.55% *more expensive*
per bedroom than the batch). Mapped over that axis's own wider
`-60%..+60%` band, that's a sub-score of `43.71`; combined with a very fresh
listing (`domPercentile = 9.0`, near the bottom -- newly listed, least
negotiable) it blends to `dealScore = 31.3`. Same formula, same weights,
just a different value metric feeding it -- `valueAxis: "ppbed"` on the row
tells you which one was used.

**And when a listing isn't comparable at all** -- the same batch contained
"Development Opportunity - Moor Road, Headingley" (`id 168402920`,
£2,250,000, 58 "bedrooms", 25,198 sqft): a development site priced like
land, `78.3%` below the batch's £/sqft median. Clamping that to a perfect
100 would have crowned a non-comparable listing the batch's best deal, so
the value axis is **dropped** instead -- `valueAxis: "none"`,
`valueDeltaPct: null`, and a `valueAxisReason` string saying exactly why.
Its score then comes from the remaining axes alone (here DOM only, `89.0`).
The raw `ppsfDeltaPct` stays on the row either way, so you can still see and
filter on the number.

### Input examples

**1. City/town search (spaces slugify to hyphens, matching Rightmove's own URL form):**

```json
{ "searches": ["Leeds", "Newcastle upon Tyne"], "maxListingsPerSearch": 300 }
```

**2. Full Rightmove search URL:**

```json
{ "searches": ["https://www.rightmove.co.uk/property-for-sale/Manchester.html"], "maxListingsPerSearch": 500 }
```

**3. Investor filters + rent assumption (enables the rent-yield metric):**

```json
{
  "searches": ["Cardiff"],
  "monthlyRentPerBedroomGBP": 700,
  "minPrice": 100000,
  "maxPrice": 300000,
  "minBeds": 2
}
```

### Scoring methodology

`dealScore` (0-100) is a weighted blend of up to three sub-scores. If a
component can't be computed for a given batch or listing (e.g. no rent
assumption supplied, or fewer than 8 listings in the batch), its weight is
dropped and the rest are renormalized -- a listing missing one metric is
still comparable to one with all three.

| Component | Weight | What it measures | Mapping |
|---|---|---|---|
| Value axis: £/sqft (or £/bedroom fallback) | 45% | Listing's price/sqft vs. the **median** price/sqft of your search batch -- or, when the listing (or the whole batch) has no size data, price/bedroom vs. the batch's own £/bed median instead | £/sqft: -30%..+30% delta -> 100..0. £/bed: -60%..+60% -> 100..0 (cheaper = higher score) |
| Days-on-market percentile | 25% | How long this listing has sat vs. the rest of the batch | 0..100 **midrank** percentile, used directly (older = more negotiable = higher score) |
| Gross rent yield | 30% | `12 * monthlyRentPerBedroomGBP * max(bedrooms, 1) / price * 100`, only if you supply `monthlyRentPerBedroomGBP` | 0%..12% -> 0..100 |

**Rent is per BEDROOM, not per property.** `monthlyRentPerBedroomGBP` is
multiplied by each listing's own bedroom count (a missing or 0-bed listing
counts as 1, so studios still get a yield). A single flat whole-property
rent applied to every listing degenerates into an inverse-of-price proxy --
it just favours whatever is cheapest regardless of how many bedrooms it has
\-- which is why this input is stated per bedroom.

**The two value axes use different clamp bands, and are not strictly
comparable listing-to-listing.** £/bed spreads far wider than £/sqft in the
same batch (bedroom counts are coarse buckets; floor area is continuous), so
the ±30% band that fits £/sqft saturates most £/bed listings at 0 or 100 and
throws away the ranking. £/bed therefore gets its own ±60% band. The
practical consequence: a `"ppsf"` row and a `"ppbed"` row with the same
`dealScore` are not making the same claim about relative value. Compare
within an axis (filter on `valueAxis`) when ranking matters.

**Non-comparable listings are dropped from the value axis, not clamped.**
A selected-axis delta beyond **±60%** (a land/development plot's £/sqft, a
shared-ownership part-price's £/bed) is treated as a listing that isn't
comparable to the batch at all: `valueAxis` becomes `"none"`,
`valueDeltaPct` becomes `null`, `valueAxisReason` explains it, and the
remaining weights renormalize. Clamping those to a perfect 100 instead would
put a distortion at the top of every ranking. The raw `ppsfDeltaPct` /
`ppBedDeltaPct` are still reported on the row.

**Why no last-sale axis.** Rightmove's for-sale search payload carries no
sold-price history for a live listing (that data lives behind a separate
sold-comps product this actor doesn't call -- see Limitations). Rather than
ship a fourth weight slot that always drops out and silently distorts every
listing's renormalization, the 10% the Redfin sibling actor gives to
last-sale delta is folded directly into rent yield here (20% -> 30%), so the
three real axes always sum to 100% of the score when all are available.

**Value axis** (`ppsfMedian` / `ppBedMedian`, the batch's £/sqft and £/bed
baselines) each require at least **8** listings in the batch with the
relevant fields (price+sizeSqFt for ppsf, price+bedrooms for ppbed); below
that, the relevant median and every listing's delta come back `null` with a
`*MedianReason` string explaining why. **UK listings frequently omit
`displaySize`** (confirmed live: 14 of 50 in the sample Leeds batch above
had no size at all) -- when a listing has no size, or the batch is too small
for a ppsf median, its value axis falls back to price-per-bedroom
automatically; `valueAxis` on each row (`"ppsf"`, `"ppbed"`, `"none"` if the
delta tripped the outlier guard above, or `null` if
neither could be computed) tells you which metric actually fed the score.

**How the DOM percentile handles ties.** `daysOnMarket` is computed as a
continuous value (`now - firstVisibleDate`, in fractional days) rather than
a whole-day count, so exact ties are rare -- but when they happen, ties are
scored by **midrank** (listings strictly below, plus half the listings tied
with you): a group all tied at the freshest end lands mid-pack rather than
each member claiming the whole tied group as "older than me".

**What's in the batch.** Every scored row carries a `batch` object with the
search it came from, both medians (and their reasons if null), the batch
size, the site's own `resultCount` for that search, and a
`truncated`/`pagesFailed` pair -- see "Coverage and dedupe" below.

### Coverage and dedupe

- **UK-wide, spike-verified.** This actor's server-rendered-HTML approach
  was live-tested against 12 cities and towns spanning all four home
  nations -- Manchester, Birmingham, Leeds, Glasgow, Edinburgh, Cardiff,
  Swansea, Belfast, Bristol, Newcastle-upon-Tyne, plus two small-market
  probes, Inverness and Truro -- with a 100% success rate (17/17 live page
  fetches returned a real, parseable listing payload). Market size varies
  hugely (Manchester: `resultCount` 6,899; Belfast: 4), and both extremes
  worked identically.
- **Deduped across the whole run, not just within one search.** A listing
  is keyed by its Rightmove `id` and pushed at most once per run, so
  overlapping searches never bill you twice for the same home or double it
  in the dataset. Rightmove's own pagination also overlaps by one listing
  between consecutive pages (confirmed live: a 24-step `index` but 25
  listings returned per page) -- the same dedupe absorbs that for free.
- **Failed searches never charge you.** A search that can't be resolved
  (an empty string, or a non-`rightmove.co.uk` / wrong-shape URL), *and* a
  search whose slug resolves but returns no usable results page at all (a
  misspelled town, usually), is pushed to the dataset as an uncharged
  `{ searchInput, error }` row -- the latter with a "check spelling; you can
  paste a rightmove.co.uk search URL" hint -- so you can see exactly what
  went wrong. If *every* search fails, or every page fetch fails, or nothing
  survives to be pushed, the run itself fails rather than reporting success
  on an empty dataset.
- **A blocked or challenged proxy pool stops the run, loudly.** A 403, or a
  CAPTCHA/anti-bot marker in the response body, aborts the entire run
  immediately with a clear error rather than quietly returning empty pages
  \-- that's a proxy-pool problem, not a "this page happened to fail"
  problem, and retrying it just burns your budget for the same answer.
  Ordinary page hiccups (a soft-404 shape, an unparseable body) get one
  retry, then that search's pagination stops and the batch carries whatever
  it collected with `truncated: true`.

### Pricing

Pay-per-event: **$0.70 per 1,000 listings scored** (`$0.0007` per
`listing-scored` event). Apify bills in **USD** regardless of the market
this actor covers, so that is the actual charge -- roughly **£0.55 per
1,000** at typical USD/GBP rates, but the GBP figure is an approximation
that moves with the exchange rate, not a fixed price. Failed searches and
error rows are never charged.

### Integration

**curl (Apify API):**

```bash
curl "https://api.apify.com/v2/acts/steadycrawl~rightmove-deal-finder/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -X POST -H "Content-Type: application/json" \
  -d '{"searches": ["Leeds"], "maxListingsPerSearch": 100}'
```

**Python (apify-client):**

```python
from apify_client import ApifyClient

client = ApifyClient(APIFY_TOKEN)
run = client.actor("steadycrawl/rightmove-deal-finder").call(
    run_input={"searches": ["Leeds"], "monthlyRentPerBedroomGBP": 600}
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["dealScore"], item["displayAddress"])
```

**Make / n8n:** use the built-in Apify app/node -- run this actor, then feed
`defaultDatasetId` into a "Get dataset items" step and sort/filter on
`dealScore`.

**MCP:** this actor is callable from any MCP-compatible client via Apify's
[Actors MCP Server](https://mcp.apify.com) -- point your MCP client at it
and call `steadycrawl/rightmove-deal-finder` like any other tool.

### Limitations (transparent, by design)

- **For-sale search results only, v1.** No sold-comps search, no
  price-drop history beyond the single `listingUpdateReason`/
  `listingUpdateDate` pair the search payload itself carries.
- **`sizeSqFt` is genuinely sparse.** In live sampling, 28% of listings
  (14/50 in a Leeds batch) had no size at all -- Rightmove's own
  `displaySize` field ships as an empty string on those. That's *why* the
  price-per-bedroom fallback exists as a first-class value axis, not a
  patch: every listing gets a value-axis score whenever it has *either* a
  size or a bedroom count, and `valueAxis` tells you which one fed it.
- **Rent yield needs your own assumption.** There's no automatic market
  rent estimate; supply `monthlyRentPerBedroomGBP` (a per-bedroom figure, not
  a whole-property total) to enable that metric.
- **`propertySubType` is Rightmove's own free-text category** ("Terraced",
  "Semi-Detached", "Detached", "Flat", "Penthouse", ...), passed through
  as-is.
- **Architecture constraint:** this actor calls exactly one Rightmove
  route -- `GET /property-for-sale/<slug>.html` (+ `?index=` pagination)
  \-- and reads its embedded `__NEXT_DATA__` JSON. No typeahead/resolver
  call, no separate search API: the city/town name *is* the URL slug, so
  region resolution needs no offline lookup table or extra request. This
  was a deliberate architecture correction -- an earlier round of discovery
  targeted two JSON-API endpoints that both turned out to be dead routes
  (0% success); the server-rendered HTML page is the only route confirmed
  live.

### Support

Issues get a same-day response. Open one on the actor's Issues tab with a
sample input and what you expected to see.

### Changelog

- **1.0.0** -- initial release: server-rendered-HTML crawl with
  `__NEXT_DATA__` extraction, UK-wide coverage (England/Scotland/Wales/NI),
  automatic £/sqft-or-£/bed value-axis fallback, 3-component deal score,
  pay-per-event pricing at $0.70/1,000 listings scored (USD -- see Pricing).

# Actor input Schema

## `searches` (type: `array`):

One or more UK city/town names (e.g. "Leeds", "Newcastle upon Tyne" -- spaces become hyphens to match Rightmove's own URL slug) or full rightmove.co.uk /property-for-sale/\*.html search URLs.

## `maxListingsPerSearch` (type: `integer`):

Cap on deduped, filtered listings pushed per search item.

## `monthlyRentPerBedroomGBP` (type: `integer`):

Optional. A per-bedroom rent figure, not a whole-property total -- the actor multiplies it by each listing's own bedroom count (minimum 1) to estimate that listing's rent, so it scales sensibly from studios to large houses. If set, enables the gross rent yield metric and its weight in the deal score.

## `minPrice` (type: `integer`):

Optional client-side filter.

## `maxPrice` (type: `integer`):

Optional client-side filter.

## `minBeds` (type: `integer`):

Optional client-side filter.

## `useApifyProxy` (type: `boolean`):

Always datacenter -- this actor's economics assume datacenter proxy only. There is no group/residential picker: the code forces the datacenter pool regardless of input.

## Actor input object example

```json
{
  "searches": [
    "Leeds",
    "Manchester"
  ],
  "maxListingsPerSearch": 200,
  "useApifyProxy": true
}
```

# Actor output Schema

## `scoredListings` (type: `string`):

Every Rightmove for-sale listing found across England, Scotland, Wales and Northern Ireland, normalized and enriched with a 0-100 investor deal score plus its component metrics (price/sqft or price/bedroom vs batch median, days-on-market percentile, gross rent yield). Failed searches appear as uncharged error records with a searchInput and error field.

# 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 = {
    "searches": [
        "Leeds",
        "Manchester"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadycrawl/rightmove-deal-finder").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 = { "searches": [
        "Leeds",
        "Manchester",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("steadycrawl/rightmove-deal-finder").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 '{
  "searches": [
    "Leeds",
    "Manchester"
  ]
}' |
apify call steadycrawl/rightmove-deal-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadycrawl/rightmove-deal-finder"
        }
    }
}

```

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/Yzf6EKqtgYKRkyeiU/builds/OU9ITkbCOdw6HPRd9/openapi.json
