Field-tested against www.rightmove.co.uk on 2026-10-23 using a Browser Use cloud browser and plain HTTP (urllib).
robots.txt (checked 2026-10-23) allows /property-for-sale/find.html, /property-to-rent/find.html and /properties/<id> for all user agents; los.rightmove.co.uk/robots.txt answered HTTP 401 (no file served).
Quick summary
- Search result pages (
/property-for-sale/find.html,/property-to-rent/find.html) are Next.js pages. Every listing on the page is in<script id="__NEXT_DATA__">atprops.pageProps.searchResults.properties. A plain HTTP GET gets it, no browser needed. - A free-text place ("Bristol", "Camden Town", "BS8") must first be turned into a
locationIdentifierlikeREGION^219orOUTCODE^295with the public typeahead endpointhttps://los.rightmove.co.uk/typeahead. - 24 organic listings per page plus 1 "featured" slot (25 objects). Paginate with
index=0, 24, 48 .... Hard cap: 42 pages /index=984(about 1,000 listings) even whenresultCountis higher. The code below reports when it hits that cap. - Property pages (
/properties/<id>) embedwindow.__PAGE_MODEL, a flattened JSON graph. Decode it to get key features, full description, tenure, council tax band, nearest stations and (for rentals) deposit / furnishing / available date. Plain HTTP to property pages is blocked (status 429); this skill reads them in a normal browser session. - Plain-HTTP requests (typeahead and search pages only) send a truthful client User-Agent naming this skill. An HTTP 403/429 whose body says the service is "for the use of Rightmove Group Limited only" is an access block:
fetch()stops and raises. Back off; do not retry or change the client to get past it. - In the browser the same two payloads are readable with
js(...). The OneTrust cookie dialog appears but does not block reading them.
What works
1. Location lookup + paginated listings (plain HTTP, no browser)
Plain Python, runs with python3 or inside browser-harness. scrape() returns why it stopped: last_page (a successful page had no next), max_pages (your limit, more pages exist) or rightmove_page_cap (page 42 reached, resultCount is larger). Failed requests are retried, then raise; they never look like the end of the results.
import re, json, time, gzip, email.utils, urllib.parse, urllib.request, urllib.error
from datetime import timezone
HEADERS = {
"User-Agent": "browser-harness-rightmove-skill/1.0 (+https://github.com/browser-use/browser-harness)", # truthful client UA
"Accept-Language": "en-GB,en;q=0.9",
"Accept-Encoding": "gzip",
}
RETRY_STATUS = {429, 500, 502, 503, 504}
MAX_WAIT = 120 # seconds; a longer Retry-After stops instead of sleeping
PER_PAGE, MAX_INDEX = 24, 984 # Rightmove serves at most 42 pages per search
CHANNEL_PATHS = {"buy": "property-for-sale", "rent": "property-to-rent"}
def _retry_after(value):
"""Retry-After is either seconds or an HTTP-date. Returns seconds or None."""
if not value:
return None
value = value.strip()
if value.isdigit():
return float(value)
try:
dt = email.utils.parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return max(0.0, dt.timestamp() - time.time())
def _decode(body, headers):
if headers.get("Content-Encoding") == "gzip":
body = gzip.decompress(body)
return body.decode("utf-8", "replace")
def fetch(url, accept="text/html", tries=4):
"""GET -> (status, final_url, text). Retries 429/5xx/network errors with backoff, then raises.
Raises at once on a 403/429 "Rightmove Group Limited only" access block or a Retry-After over MAX_WAIT.
Other HTTP errors (400, 404, 410) are returned for the caller to handle."""
last = None
for attempt in range(tries):
wait = None
try:
req = urllib.request.Request(url, headers={**HEADERS, "Accept": accept})
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.geturl(), _decode(r.read(), r.headers)
except urllib.error.HTTPError as e:
body = _decode(e.read(), e.headers)
if e.code in (403, 429) and "for the use of Rightmove Group Limited only" in body:
raise RuntimeError(f"HTTP {e.code} access block on {url}; stop and back off, do not retry")
if e.code not in RETRY_STATUS:
return e.code, url, body
last, wait = f"HTTP {e.code}", _retry_after(e.headers.get("Retry-After"))
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
last = repr(e)
if attempt == tries - 1:
break # no sleep after the final attempt
delay = wait if wait is not None else 2 * 2 ** attempt
if delay > MAX_WAIT:
raise RuntimeError(f"{url}: server asked to wait {delay:.0f}s (over {MAX_WAIT}s); stopping")
time.sleep(delay)
raise RuntimeError(f"giving up on {url} after {tries} tries: {last}")
def _norm(s):
return " ".join(re.sub(r"[^\w\s]", " ", s or "").lower().split())
def find_location(query):
"""Free text -> list of (locationIdentifier, displayName), best first. Punctuation breaks the endpoint (HTTP 400)."""
url = "https://los.rightmove.co.uk/typeahead?" + urllib.parse.urlencode({"query": _norm(query), "limit": 10, "exclude": "STREET"})
status, _, text = fetch(url, accept="application/json") # without this Accept you get XML
if status != 200:
raise RuntimeError(f"typeahead HTTP {status} for {query!r}")
return [(f"{m['type']}^{m['id']}", m.get("displayName")) for m in json.loads(text).get("matches") or []]
def pick_location(query):
"""First typeahead match whose displayName starts with the query. Raises instead of guessing."""
matches = find_location(query)
for loc_id, name in matches:
if _norm(name).startswith(_norm(query)):
return loc_id, name
raise LookupError(f"no Rightmove location starting with {query!r}; candidates: {matches}")
def search_page(location_id, channel="buy", index=0, **filters):
"""One results page. None if Rightmove redirected to /page-not-found (unknown location or index past 984)."""
if channel not in CHANNEL_PATHS:
raise ValueError(f"channel must be 'buy' or 'rent', got {channel!r}")
path = CHANNEL_PATHS[channel]
params = {"locationIdentifier": location_id, "index": index, **filters}
url = f"https://www.rightmove.co.uk/{path}/find.html?" + urllib.parse.urlencode(params) # ^ -> %5E
status, final_url, html = fetch(url)
if status != 200:
raise RuntimeError(f"HTTP {status} for {url}")
if urllib.parse.urlparse(final_url).path == "/page-not-found":
return None
m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.S)
if not m:
raise RuntimeError(f"no __NEXT_DATA__ on {final_url} (blocked or layout changed)")
sr = json.loads(m.group(1))["props"]["pageProps"]["searchResults"]
sp = sr.get("searchParameters") or {}
if sp.get("index") != str(index) or sp.get("locationIdentifier") != location_id:
raise RuntimeError(f"asked for {location_id} index {index}, got {sp.get('locationIdentifier')} index {sp.get('index')}")
return sr
def _d(x):
return x if isinstance(x, dict) else {}
def _num(x):
return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None
def _text(x):
return " ".join(x.split()) if isinstance(x, str) else None
def to_row(p):
price = _d(p.get("price"))
dps = price.get("displayPrices") if isinstance(price.get("displayPrices"), list) else []
dp = _d(dps[0]) if dps else {}
pid = p.get("id")
return {
"id": pid,
"address": _text(p.get("displayAddress")),
"price": _num(price.get("amount")),
"price_text": " ".join(x for x in [dp.get("displayPriceQualifier"), dp.get("displayPrice")] if isinstance(x, str) and x),
"frequency": price.get("frequency"),
"beds": _num(p.get("bedrooms")),
"baths": _num(p.get("bathrooms")),
"type": p.get("propertySubType"),
"agent": _d(p.get("customer")).get("branchDisplayName"),
"added_or_reduced": p.get("addedOrReduced"),
"first_visible": p.get("firstVisibleDate"),
"featured": p.get("featuredProperty"),
"url": f"https://www.rightmove.co.uk/properties/{pid}" if pid is not None else None,
}
def _count(x):
s = str(x or "").replace(",", "")
return int(s) if s.isdigit() else None
def scrape(query, channel="buy", max_pages=2, **filters):
loc_id, name = pick_location(query)
rows, seen, total, pages, stopped = [], set(), None, 0, "max_pages"
for n in range(max_pages):
index = n * PER_PAGE
if index > MAX_INDEX:
stopped = "rightmove_page_cap"
break
sr = search_page(loc_id, channel, index=index, **filters)
if sr is None:
raise RuntimeError(f"{loc_id} index {index} redirected to /page-not-found")
pages += 1
total = _count(sr.get("resultCount"))
props = sr.get("properties") or []
for p in props:
pid = p.get("id")
if pid is not None and pid in seen: # featured listings also show up organically on other pages
continue
if pid is not None:
seen.add(pid)
rows.append(to_row(p))
has_next = bool(_d(sr.get("pagination")).get("next"))
if not has_next:
capped = index == MAX_INDEX and total is not None and total > MAX_INDEX + PER_PAGE
stopped = "rightmove_page_cap" if capped else "last_page"
break
if not props:
raise RuntimeError(f"empty page at index {index} but pagination says there is more")
time.sleep(1.5) # polite pacing
return {"location": name, "location_id": loc_id, "result_count": total,
"pages": pages, "stopped": stopped, "rows": rows}
for query, channel, max_pages, filters in [
("Bristol", "buy", 2, {"sortType": 6, "minBedrooms": 2, "maxPrice": 400000}),
("Camden Town", "rent", 20, {}),
]:
res = scrape(query, channel=channel, max_pages=max_pages, **filters)
print(res["location"], res["location_id"], "results:", res["result_count"], "pages:", res["pages"],
"stopped:", res["stopped"], "rows:", len(res["rows"]))
for r in res["rows"][:2]:
print(r)Real output (trimmed):
Bristol REGION^219 results: 1994 pages: 2 stopped: max_pages rows: 50
{'id': 94594029, 'address': 'Bowden Place, Downend', 'price': 285000, 'price_text': '£285,000', 'frequency': 'not specified', 'beds': 2, 'baths': 1, 'type': 'End of Terrace', 'agent': 'Ocean, Downend', 'added_or_reduced': 'Added yesterday', 'first_visible': '2026-10-22T09:59:03Z', 'featured': True, 'url': 'https://www.rightmove.co.uk/properties/94594029'}
{'id': 94681503, 'address': 'St. Georges Avenue, St. George', 'price': 345000, 'price_text': '£345,000', 'frequency': 'not specified', 'beds': 3, 'baths': 2, 'type': 'Semi-Detached', 'agent': 'Boardwalk, covering Bristol and surrounding areas', 'added_or_reduced': 'Added today', ...
Camden Town, North West London REGION^85262 results: 297 pages: 13 stopped: last_page rows: 297
{'id': 94627404, 'address': 'Arlington Road, London, NW1', 'price': 2200, 'price_text': '£2,200 pcm', 'frequency': 'monthly', 'beds': 1, 'baths': None, 'type': 'Apartment', 'agent': 'The Chant Group, Bank', ...
{'id': 94455528, 'address': 'Engine House Way, Lansdowne House, NW1', 'price': 4750, 'price_text': '£4,750 pcm', 'frequency': 'monthly', 'beds': 2, 'baths': 2, 'type': 'Flat', 'agent': 'Austin Homes London, London', ...
Camden Town shows a complete run: 13 pages, 297 unique rows = resultCount. A separate run of scrape("Bristol", "buy", max_pages=50) (unfiltered, 3,748 results) fetched 42 pages in 98 s and stopped with rightmove_page_cap and 1,038 unique rows.
2. Property page: key features and details (browser)
Plain-HTTP requests to property pages got HTTP 429 with a notice that the API "is for the use of Rightmove Group Limited only", so this skill reads property pages in a normal browser session. Self-contained. get_property() returns None only when the page answered 404/410 (removed listing); anything else unexpected raises.
import json, time, html as htmllib, urllib.parse
def _is_timeout(e):
return isinstance(e, TimeoutError) or "timed out" in str(e)
def retry_browser(fn, *args, tries=3):
"""Browser calls can time out on a slow cloud browser: TimeoutError, or RuntimeError('... timed out ...')."""
for attempt in range(tries):
try:
return fn(*args)
except (TimeoutError, RuntimeError) as e:
if not _is_timeout(e) or attempt == tries - 1:
raise
time.sleep(2)
def open_and_confirm(url, check_js, first=False, timeout=30):
"""Navigate, then poll check_js until it returns 'ok' (True) or 'not_found' (False)."""
retry_browser(new_tab if first else goto_url, url)
if first:
activate_tab(current_tab()) # a hidden background tab stalled in the cloud browser
deadline = time.time() + timeout
while time.time() < deadline:
state = retry_browser(js, check_js)
if state in ("ok", "not_found"):
return state == "ok"
time.sleep(0.5)
raise TimeoutError(f"{url} not confirmed after {timeout}s; tab is at {retry_browser(js, 'location.href')}")
def search_check(location_id, index):
return f"""(() => {{
if (location.pathname === '/page-not-found') return 'not_found';
const s = document.getElementById('__NEXT_DATA__');
if (!s) return 'wait';
const sp = JSON.parse(s.textContent)?.props?.pageProps?.searchResults?.searchParameters || {{}};
return sp.index === {json.dumps(str(index))} && sp.locationIdentifier === {json.dumps(location_id)} ? 'ok' : 'wait';
}})()"""
def property_check(property_id):
return f"""(() => {{
if (location.pathname !== {json.dumps(f'/properties/{property_id}')}) return 'wait';
const nav = performance.getEntriesByType('navigation')[0];
if (nav && [404, 410].includes(nav.responseStatus)) return 'not_found';
return window.__PAGE_MODEL ? 'ok' : 'wait';
}})()"""
def decode_page_model(pm):
"""window.__PAGE_MODEL = {"data": "<json array>", "encoding": "on"}.
Every value inside an object/array is an index into that array; resolve recursively."""
arr = json.loads(pm["data"])
memo = {}
def r(i):
if not isinstance(i, int) or isinstance(i, bool) or not 0 <= i < len(arr):
return None
if i in memo:
return memo[i]
v = arr[i]
if isinstance(v, dict):
out = memo[i] = {}
out.update({k: r(x) for k, x in v.items()})
elif isinstance(v, list):
out = memo[i] = []
out.extend(r(x) for x in v)
else:
out = v
return out
return r(0)
def _d(x):
return x if isinstance(x, dict) else {}
def _station(s):
s = _d(s)
dist = s.get("distance")
return (s.get("name"), round(dist, 2) if isinstance(dist, (int, float)) and not isinstance(dist, bool) else None, s.get("unit"))
def get_property(property_id, first=False):
"""Decoded property page, or None if the listing is gone (404/410)."""
url = f"https://www.rightmove.co.uk/properties/{property_id}"
if not open_and_confirm(url, property_check(property_id), first=first):
return None
pm = json.loads(retry_browser(js, "JSON.stringify(window.__PAGE_MODEL)"))
pd = _d(_d(decode_page_model(pm)).get("propertyData"))
if str(pd.get("id")) != str(property_id):
raise RuntimeError(f"asked for {property_id}, page model is for {pd.get('id')}")
addr = _d(pd.get("address"))
return {
"id": pd.get("id"),
"address": " ".join((addr.get("displayAddress") or "").split()),
"postcode": " ".join(x for x in [addr.get("outcode"), addr.get("incode")] if isinstance(x, str) and x),
"price": _d(pd.get("prices")).get("primaryPrice"),
"beds": pd.get("bedrooms"),
"baths": pd.get("bathrooms"),
"type": pd.get("propertySubType"),
"tenure": _d(pd.get("tenure")).get("tenureType"),
"council_tax_band": _d(pd.get("livingCosts")).get("councilTaxBand"),
"key_features": [htmllib.unescape(k) for k in pd.get("keyFeatures") or [] if isinstance(k, str)], # may be empty
"added": _d(pd.get("listingHistory")).get("listingUpdateReason"),
"agent": _d(pd.get("customer")).get("branchDisplayName"),
"stations": [_station(s) for s in pd.get("nearestStations") or []],
"description_html": _d(pd.get("text")).get("description"),
"lettings": pd.get("lettings"), # deposit / let type / furnishing for rentals, None for sales
}
def first_ids(path, location_id, n=2, first=False):
url = f"https://www.rightmove.co.uk/{path}/find.html?" + urllib.parse.urlencode({"locationIdentifier": location_id, "index": 0})
if not open_and_confirm(url, search_check(location_id, 0), first=first):
raise RuntimeError(f"{url} redirected to /page-not-found")
sr = json.loads(retry_browser(js, "document.getElementById('__NEXT_DATA__').textContent"))["props"]["pageProps"]["searchResults"]
return [p["id"] for p in sr.get("properties") or [] if p.get("id") is not None and not p.get("featuredProperty")][:n]
ids = first_ids("property-for-sale", "REGION^219", first=True)
time.sleep(1.5)
ids += first_ids("property-to-rent", "REGION^85262") + [1] # 1 = a removed id
for pid in ids:
time.sleep(1.5)
prop = get_property(pid)
if prop is None:
print(pid, "listing not available (404/410)")
continue
print({k: v for k, v in prop.items() if k != "description_html"})
print(len(prop["description_html"] or ""), "chars of description HTML")Real output (trimmed):
{'id': '94488843', 'address': 'Discreet property in this area - Call for details', 'postcode': 'BS9 1LJ', 'price': '£3,750,000', 'beds': 6, 'baths': 4, 'type': 'Detached', 'tenure': 'FREEHOLD', 'council_tax_band': 'H', 'key_features': [], 'added': 'Added on 19/10/2026', 'agent': 'Goodman & Lilley, Henleaze', 'stations': [('Sea Mills Station', 0.75, 'miles'), ('Clifton Down Station', 1.57, 'miles'), ('Redland Station', 1.76, 'miles')], 'lettings': None}
304 chars of description HTML
{'id': '173429669', 'address': 'Clifton, Bristol, BS8', 'postcode': 'BS8 3EW', 'price': '£3,250,000', 'beds': 6, 'baths': 4, 'type': 'Semi-Detached', 'tenure': 'FREEHOLD', 'council_tax_band': 'H', 'key_features': ['Elegant circa 5400 sq. ft semi-detached family house', 'Gated off-street parking for three to five vehicles', ...
5095 chars of description HTML
{'id': '94455528', 'address': 'Engine House Way, Lansdowne House, NW1', 'postcode': 'NW1 8BT', 'price': '£4,750 pcm', 'beds': 2, 'baths': 2, 'type': 'Flat', 'tenure': None, 'council_tax_band': None, 'key_features': ['Fully Furnished', 'Private Balcony', '2 beds | 2 baths', 'Available now'], 'added': 'Reduced today', 'agent': 'Austin Homes London, London', 'stations': [('Chalk Farm Station', 0.22, 'miles'), ...], 'lettings': {'letAvailableDate': 'Ask agent', 'deposit': 5596, 'minimumTermInMonths': None, 'letType': 'Long term', 'furnishType': 'Furnished'}}
1302 chars of description HTML
{'id': '94452012', 'address': 'Camden Road, Holloway, N7', 'postcode': 'N7 0DN', 'price': '£1,900 pcm', 'beds': 1, 'baths': 1, 'type': 'Flat', 'tenure': None, 'council_tax_band': 'C', ... 'lettings': {'letAvailableDate': '03/12/2026', 'deposit': 2192, 'minimumTermInMonths': 12, 'letType': 'Long term', 'furnishType': 'Furnished'}}
849 chars of description HTML
1 listing not available (404/410)
3. Same data through the browser
Use this when you are already driving a tab. Pagination is by URL, no clicking needed. The first new_tab() is followed by one activate_tab(): in a fresh cloud browser a hidden background tab stalled (see Gotchas).
Each navigation is confirmed before parsing: a search page counts as loaded only when its __NEXT_DATA__ echoes the requested index and locationIdentifier, and a property page only when location.pathname is /properties/<id> and the decoded id matches. That stops the code from reading the previous page. Browser calls that time out are retried.
import json, time, urllib.parse
def _is_timeout(e):
return isinstance(e, TimeoutError) or "timed out" in str(e)
def retry_browser(fn, *args, tries=3):
"""Browser calls can time out on a slow cloud browser: TimeoutError, or RuntimeError('... timed out ...')."""
for attempt in range(tries):
try:
return fn(*args)
except (TimeoutError, RuntimeError) as e:
if not _is_timeout(e) or attempt == tries - 1:
raise
time.sleep(2)
def open_and_confirm(url, check_js, first=False, timeout=30):
"""Navigate, then poll check_js until it returns 'ok' (True) or 'not_found' (False)."""
retry_browser(new_tab if first else goto_url, url)
if first:
activate_tab(current_tab()) # a hidden background tab stalled in the cloud browser
deadline = time.time() + timeout
while time.time() < deadline:
state = retry_browser(js, check_js)
if state in ("ok", "not_found"):
return state == "ok"
time.sleep(0.5)
raise TimeoutError(f"{url} not confirmed after {timeout}s; tab is at {retry_browser(js, 'location.href')}")
def search_check(location_id, index):
return f"""(() => {{
if (location.pathname === '/page-not-found') return 'not_found';
const s = document.getElementById('__NEXT_DATA__');
if (!s) return 'wait';
const sp = JSON.parse(s.textContent)?.props?.pageProps?.searchResults?.searchParameters || {{}};
return sp.index === {json.dumps(str(index))} && sp.locationIdentifier === {json.dumps(location_id)} ? 'ok' : 'wait';
}})()"""
def property_check(property_id):
return f"""(() => {{
if (location.pathname !== {json.dumps(f'/properties/{property_id}')}) return 'wait';
const nav = performance.getEntriesByType('navigation')[0];
if (nav && [404, 410].includes(nav.responseStatus)) return 'not_found';
return window.__PAGE_MODEL ? 'ok' : 'wait';
}})()"""
def decode_page_model(pm):
arr = json.loads(pm["data"])
memo = {}
def r(i):
if not isinstance(i, int) or isinstance(i, bool) or not 0 <= i < len(arr):
return None
if i in memo:
return memo[i]
v = arr[i]
if isinstance(v, dict):
out = memo[i] = {}
out.update({k: r(x) for k, x in v.items()})
elif isinstance(v, list):
out = memo[i] = []
out.extend(r(x) for x in v)
else:
out = v
return out
return r(0)
def _d(x):
return x if isinstance(x, dict) else {}
base = "https://www.rightmove.co.uk/property-to-rent/find.html?"
location_id = "REGION^219"
params = {"locationIdentifier": location_id, "maxBedrooms": 2}
rows, seen, stopped = [], set(), "max_pages"
for n in range(2):
index = n * 24
if not open_and_confirm(base + urllib.parse.urlencode({**params, "index": index}), search_check(location_id, index), first=(n == 0)):
raise RuntimeError(f"index {index} redirected to /page-not-found")
sr = json.loads(retry_browser(js, "document.getElementById('__NEXT_DATA__').textContent"))["props"]["pageProps"]["searchResults"]
for p in sr.get("properties") or []:
pid = p.get("id")
if pid is not None and pid in seen:
continue
if pid is not None:
seen.add(pid)
dps = _d(p.get("price")).get("displayPrices")
dp = _d(dps[0]) if isinstance(dps, list) and dps else {}
rows.append((pid, " ".join((p.get("displayAddress") or "").split()), dp.get("displayPrice"),
p.get("bedrooms"), p.get("propertySubType"), _d(p.get("customer")).get("branchDisplayName"), p.get("addedOrReduced")))
pg = _d(sr.get("pagination"))
print("page", pg.get("page"), "of", pg.get("total"), "| results", sr.get("resultCount"))
if not pg.get("next"):
stopped = "last_page"
break
time.sleep(1.5)
print(len(rows), "rows, stopped:", stopped)
for r in rows[:3]:
print(r)
if rows and rows[0][0] is not None:
pid = rows[0][0]
time.sleep(1.5)
if not open_and_confirm(f"https://www.rightmove.co.uk/properties/{pid}", property_check(pid)):
raise RuntimeError(f"property {pid} answered 404/410")
pd = _d(decode_page_model(json.loads(retry_browser(js, "JSON.stringify(window.__PAGE_MODEL)"))).get("propertyData"))
if str(pd.get("id")) != str(pid):
raise RuntimeError(f"asked for {pid}, page model is for {pd.get('id')}")
print(pd.get("id"), _d(pd.get("prices")).get("primaryPrice"), pd.get("keyFeatures"), pd.get("lettings"))Real output (trimmed):
page 1 of 42 | results 1,142
page 2 of 42 | results 1,142
50 rows, stopped: max_pages
(91003179, 'Back Room, Oldbury Court Road, Fishponds', '£825 pcm', 1, 'House Share', 'Goldsmith Property, Covering Bristol', 'Reduced on 28/09/2026')
(94680672, 'Westbourne Place, Clifton, Bristol, BS8', '£1,295 pcm', 1, 'Flat', 'OpenRent, London', 'Reduced today')
(94679040, 'Princess Victoria Street, Clifton, Bristol, BS8', '£1,790 pcm', 2, 'Apartment', 'CJ Hole, Bishopston', 'Added today')
91003179 £825 pcm [] {'letAvailableDate': 'Now', 'deposit': 900, 'minimumTermInMonths': None, 'letType': 'Long term', 'furnishType': 'Furnished'}
Fields
Example 1, scrape() returns {"location", "location_id", "result_count", "pages", "stopped", "rows"}. Each row:
| Key | Source in searchResults.properties[i] | Example |
|---|---|---|
id | id (int) | 88550319 |
address | displayAddress, whitespace collapsed | "Parson Street, Bedminster, Bristol, BS3", "Engine House Way, Lansdowne House, NW1" (street + area + outcode at most) |
price | price.amount (int GBP, per frequency) | 342500; 1673 with frequency "weekly" for a "£7,250 pcm" rental |
price_text | price.displayPrices[0] qualifier + price | "£342,500", "£4,750 pcm" |
frequency | price.frequency | "not specified" (sale), "monthly" or "weekly" (rent) |
beds, baths | bedrooms, bathrooms | 3, 1 |
type | propertySubType | "Terraced", "Flat" |
agent | customer.branchDisplayName | "CJ Hole, Southville" |
added_or_reduced | addedOrReduced | "Added on 15/05/2026", "Reduced today" (dd/mm/yyyy) |
first_visible | firstVisibleDate | "2026-05-15T15:36:54Z" |
featured | featuredProperty | true on the 1 paid slot per page |
url | built from id | https://www.rightmove.co.uk/properties/88550319 |
Example 2, get_property() returns (from decoded __PAGE_MODEL -> propertyData):
| Key | Source | Example |
|---|---|---|
id | id (string here) | "94455528" |
address | address.displayAddress | "Engine House Way, Lansdowne House, NW1" |
postcode | address.outcode + address.incode | "NW1 8BT" |
price | prices.primaryPrice | "£350,000", "£4,750 pcm" |
beds, baths, type | bedrooms, bathrooms, propertySubType | 2, 2, "Flat" |
tenure | tenure.tenureType | "FREEHOLD", None on rentals |
council_tax_band | livingCosts.councilTaxBand | "H", can be None |
key_features | keyFeatures, HTML-unescaped | ["Fully Furnished", "Private Balcony", ...], may be [] |
added | listingHistory.listingUpdateReason | "Added on 19/10/2026" |
agent | customer.branchDisplayName | "Austin Homes London, London" |
stations | nearestStations[] as (name, miles, unit) | ("Chalk Farm Station", 0.22, "miles") |
description_html | text.description | HTML string |
lettings | lettings | {"letAvailableDate", "deposit", "minimumTermInMonths", "letType", "furnishType"}, None for sales |
Example 3 rows are tuples (id, address, display price, beds, property sub type, agent, added_or_reduced) from the same properties[i] fields; the last line prints propertyData.id, prices.primaryPrice, keyFeatures, lettings.
Other fields seen in the payloads but not returned by the code: searchResults.location.displayName, searchResults.searchParameters (echo of the applied filters), per listing location.latitude/longitude, propertyImages.images[].srcUrl, letAvailableDate, tenure.tenureType, listingUpdate, summary; per property page features.parking, features.garden.
URL parameters (observed)
| Parameter | Values seen | Effect |
|---|---|---|
locationIdentifier | REGION^219, REGION^85262, OUTCODE^295, STATION^1712 | From typeahead type^id. URL-encode ^ as %5E (urlencode does it). STATION^... searches default to radius=0.5 (miles) |
index | 0, 24, ... 984 | Page offset. 1008 and above redirect to /page-not-found |
sortType | 2 (for-sale default, highest price first), 6 (rent default, most recent first) | |
minBedrooms, maxBedrooms, maxPrice | 2, 400000 | Applied server-side: Bristol for sale went from 3,748 to 1,991 results with minBedrooms=2&maxPrice=400000 |
Gotchas
- Access block. Plain-HTTP requests to property pages returned HTTP 429 with a body saying the API "is for the use of Rightmove Group Limited only". That is an access block, not a rate limit: example 1's
fetch()raises on a 403/429 with that text instead of retrying, and property pages are read in the browser (examples 2 and 3). The same sentence also appears inside normal property-page payloads, so only treat it as a block on a 403/429. - Retry-After. Example 1's
fetch()honoursRetry-After(seconds or HTTP-date) up to 120 s and raises if the server asks for longer. It never sleeps after the last attempt. - Channel.
search_page()andscrape()accept onlychannel="buy"or"rent"; anything else raisesValueError. - Typeahead rejects punctuation.
query=Clifton, Bristolreturned HTTP 400;Clifton Bristolworked. The helper strips punctuation first. - Typeahead returns XML unless you send
Accept: application/json. - Ambiguous places.
Cliftonreturns Clifton in Bristol, Nottingham, York and more;Camden Townreturns the London region first, then the station and Camden Town in Gosport.pick_location()takes the top-ranked match whose name starts with your query and raises if none does; printlocationand check it, or pass a longer query ("Clifton Nottingham"). - Pagination cap.
pagination.totalnever exceeded 42 pages. Page 42 (index=984) has nopagination.nexteven whenresultCountis 3,748, which is whyscrape()comparesresultCountwith 1,008 to tell the cap from a real last page.index=1008redirects tohttps://www.rightmove.co.uk/page-not-foundwith HTTP 200 and no__NEXT_DATA__. An unknownlocationIdentifierdoes the same. - Featured slot repeats. Each page has one
featuredProperty: truelisting. For Camden Town rentals, all 4 featured ids on pages 1-4 also appeared as normal results on another of those pages; Bristol and Manchester for-sale pages 1-4 had no repeats. Dedupe byid. - Rental
price.amountis perprice.frequency, not always monthly. One Camden listing hadamount: 1673,frequency: "weekly"and display price"£7,250 pcm". Normalise withfrequencybefore comparing rents. - Removed listings answer HTTP 410 Gone (
/properties/1). The 410 page still has a__PAGE_MODEL, soproperty_check()reads the status fromperformance.getEntriesByType('navigation')[0].responseStatusfirst. __PAGE_MODELis not plain JSON of the property. It is{"data": "<string>", "encoding": "on"}wheredatais a JSON array and every value in an object or array is an index into that array. The decoder above resolves it. There is nowindow.PAGE_MODEL(without underscores) on the current pages.- Background tab stalls. In a fresh cloud browser, a search page in a hidden
new_tab()stayed atreadyState"interactive"for 15 s or more (one 30 s wait for"complete"timed out), thenRuntime.evaluatestarted timing out. Afteractivate_tab(current_tab())the same pages reached"complete"within about 5 s. Examples 2 and 3 activate their tab once, retry timed-out browser calls, and confirm each page by its payload instead of waiting forreadyState. - Cookie dialog. "Choose your cookies" (OneTrust) covers the page in the browser. Reading
__NEXT_DATA__/__PAGE_MODELworks without clicking it; click "Reject all" only if you need screenshots or clicks. - No JSON-LD. Property pages have no
application/ld+json, so do not look for it. - Display addresses are partial (street, area, outcode). The property page adds
incode, giving the full postcode. - Pacing. 42 consecutive search pages with a 1.5 s sleep all returned 200 with data, no 429 or challenge seen. Keep 1-2 s between requests and do not run many in parallel.
What does not work
https://www.rightmove.co.uk/typeAhead/uknostreet/...(older lookup path) returned the "We couldn't find the place you were looking for" HTML.- Going past page 42 of any search (see Gotchas).
index=1008and above redirect to/page-not-found. - Plain HTTP to property pages: blocked (status 429, body says the API "is for the use of Rightmove Group Limited only"). This skill uses a normal browser session for them.
- Saved searches, alerts, "Save" and agent contact forms need an account; not covered here.