This commit is contained in:
Andras Schmelczer 2026-07-03 18:01:10 +01:00
parent c2070693fb
commit 909e241907
55 changed files with 594 additions and 223 deletions

View file

@ -36,7 +36,7 @@ _MAX_INDEX = 1008
# ---------------------------------------------------------------------------
#
# The search API (_paginate) only returns an outcode-level `displayAddress`
# (e.g. "Akerman Road, Brixton, London, SW9") never the full postcode. Each
# (e.g. "Akerman Road, Brixton, London, SW9"), never the full postcode. Each
# listing's detail page, however, embeds the property's OWN full postcode in a
# `window.__PAGE_MODEL` script as `propertyData.address.{outcode, incode}`
# (e.g. outcode "SW9" + incode "0HD" → "SW9 0HD"), independently corroborated by
@ -51,8 +51,8 @@ _MAX_INDEX = 1008
# __PAGE_MODEL is a "devalue"-style flattened object graph: its `data` field is
# a JSON STRING holding a flat array where every integer inside a container is
# an index reference into that same array (so the graph can dedupe). We
# brace-match the (large, deeply-nested) object literal a non-greedy regex
# cannot then rehydrate the reference graph before reading the address.
# brace-match the (large, deeply-nested) object literal (a non-greedy regex
# cannot), then rehydrate the reference graph before reading the address.
_PAGE_MODEL_RE = re.compile(r"window\.__PAGE_MODEL\s*=\s*")
@ -128,7 +128,7 @@ def parse_detail_postcode(html: str) -> str | None:
Pure and network-free so it is unit-testable: callers pass the page HTML.
Reads ``propertyData.address.outcode`` + ``.incode`` from window.__PAGE_MODEL
and returns a normalised full postcode (e.g. "SW9 0HD"), or None when the
page has no parseable address (the property location wrapper can be empty
page has no parseable address (the property location wrapper can be empty;
the caller then keeps the coordinate fallback). The returned outcode is
re-validated against the joined postcode so a malformed incode is dropped.
"""
@ -193,7 +193,7 @@ def _fetch_detail_postcode(client: httpx.Client, property_id: str) -> str | None
"""GET a listing detail page and return its true full postcode (or None).
Results (including failures) are cached by listing id. The detail page is a
plain HTML GET no Cloudflare, unlike Zoopla so a single httpx call
plain HTML GET (no Cloudflare, unlike Zoopla), so a single httpx call
suffices; any error degrades gracefully to the coordinate fallback. Safe to
call concurrently: distinct listing ids write distinct cache keys, and the
shared RATE_LIMITER spaces the GETs."""
@ -245,7 +245,7 @@ def _needs_detail_fetch(prop: dict) -> bool:
Skips listings the search already pins precisely: an "ACCURATE_POINT"
``pinType`` means rooftop-exact coordinates, so the coordinate-nearest
postcode is trustworthy and the detail page would only confirm it. Listings
with an approximate pin or no ``pinType`` field at all still get fetched,
with an approximate pin (or no ``pinType`` field at all) still get fetched,
so this degrades safely to the previous behaviour when the search payload
omits ``pinType``."""
if not RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS:
@ -262,8 +262,8 @@ def _prime_detail_postcodes(
) -> None:
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
Picks the fresh (uncached, not-skipped) listings up to ``detail_cap`` per
outcode then fetches their detail pages CONCURRENTLY, bounded by
Picks the fresh (uncached, not-skipped) listings, up to ``detail_cap`` per
outcode, then fetches their detail pages CONCURRENTLY, bounded by
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
request rate polite). Cached listings cost neither a slot nor a GET. The
worklist is deduplicated, so distinct ids write distinct cache keys and the
@ -305,8 +305,8 @@ def _collect_search_props(
) -> tuple[list[dict], int]:
"""Paginate the search API for one outcode+channel, collecting raw results.
Returns ``(raw_props, result_count)``. Pagination stays serial each page
reveals the next but is cheap relative to detail fetching, and the
Returns ``(raw_props, result_count)``. Pagination stays serial (each page
reveals the next) but is cheap relative to detail fetching, and the
RATE_LIMITER spaces the page GETs. Collection stops at ``max_properties`` raw
listings, the end of results, or Rightmove's ``_MAX_INDEX`` page cap."""
raw_props: list[dict] = []
@ -324,6 +324,8 @@ def _collect_search_props(
"channel": channel_cfg["channel"],
"transactionType": channel_cfg["transactionType"],
}
# Optional per-channel filters, e.g. `mustHave=newHome` for the new-homes pass.
params.update(channel_cfg.get("extra_params", {}))
data = fetch_with_retry(client, SEARCH_URL, params)
if not data:
log.warning(
@ -372,7 +374,7 @@ def _paginate(
) -> tuple[list[dict], int]:
"""Collect search results, recover true postcodes, and transform them.
Search pages are paginated serially; then when ``fetch_details`` is set
Search pages are paginated serially; then, when ``fetch_details`` is set,
up to ``detail_cap`` listings per outcode have their detail page fetched
CONCURRENTLY for the property's TRUE full postcode (see
``parse_detail_postcode``), with listings the search already pins precisely