perfect-postcode/finder
2026-07-03 18:01:10 +01:00
..
.gitignore all good 2026-05-17 10:16:30 +01:00
constants.py wip 2026-06-28 11:59:44 +01:00
docker-compose.yml scraping and data 2026-05-31 15:36:33 +01:00
Dockerfile scraping and data 2026-05-31 15:36:33 +01:00
flaresolverr.py wip 2026-06-28 11:59:44 +01:00
http_client.py ok 2026-07-03 18:01:10 +01:00
main.py wip 2026-06-28 11:59:44 +01:00
onthemarket.py wip 2026-06-28 11:59:44 +01:00
postcode_cache.py ok 2026-07-03 18:01:10 +01:00
pyproject.toml has issues 2026-05-25 13:20:17 +01:00
README.md wip 2026-06-28 11:59:44 +01:00
rightmove.py ok 2026-07-03 18:01:10 +01:00
scraper.py ok 2026-07-03 18:01:10 +01:00
shutdown.py ok 2026-07-03 18:01:10 +01:00
spatial.py Add back finder 2026-05-16 20:22:23 +01:00
storage.py ok 2026-07-03 18:01:10 +01:00
test_http_client.py ok 2026-07-03 18:01:10 +01:00
test_main.py ok 2026-07-03 18:01:10 +01:00
test_onthemarket.py ok 2026-07-03 18:01:10 +01:00
test_postcode_cache.py Small fixes 2026-06-14 14:52:44 +01:00
test_rightmove.py scraping and data 2026-05-31 15:36:33 +01:00
test_rightmove_channels.py ok 2026-07-03 18:01:10 +01:00
test_rightmove_concurrency.py Small fixes 2026-06-14 14:52:44 +01:00
test_scraper_concurrency.py Small fixes 2026-06-14 14:52:44 +01:00
test_shutdown.py Small fixes 2026-06-14 14:52:44 +01:00
test_transform.py scraping and data 2026-05-31 15:36:33 +01:00
test_zoopla.py scraping and data 2026-05-31 15:36:33 +01:00
transform.py scraping and data 2026-05-31 15:36:33 +01:00
uv.lock has issues 2026-05-25 13:20:17 +01:00
zoopla.py Cache postcodes 2026-06-14 14:50:38 +01:00
zoopla_flaresolverr.py scraping and data 2026-05-31 15:36:33 +01:00

Finder: property listing scraper

Scrapes Greater-London sale listings from Rightmove, OnTheMarket, and Zoopla, recovers each property's true full postcode, and writes a single parquet (data/online_listings_buy.parquet) that the rest of the app consumes (after a separate enrich step, see Output).

main.py is the only entry point; everything else is library code.


How it works (and why it's careful about postcodes)

Every portal's search API exposes only an outcode-level address (e.g. "…, London, SW9") plus map coordinates, never the full unit postcode. The full postcode lives on each listing's detail page, so the scraper fetches detail pages to recover it, and only trusts a detail postcode when its outcode agrees with the coordinate-nearest postcode (so a stale/wrong value can never silently relocate a listing). When no trustworthy detail postcode is found, it falls back to the coordinate-nearest postcode. See the module docstrings in rightmove.py, onthemarket.py, and zoopla.py for the per-portal data model.

Detail fetching is the dominant cost, so it is:

  • cached across runs: data/detail_cache/{source}.json maps listing id → recovered postcode; a re-run only fetches newly-appeared listings;
  • fetched concurrently for the HTTP portals (Rightmove, OnTheMarket), bounded by a shared global rate limiter so the VPN egress stays polite;
  • gated and capped per outcode.

See Performance.


Prerequisites

The scraper egresses through a VPN. There are two supported ways to provide it:

  • Shared netns (compose, recommended): an external media_gluetun container (qmcgaw/gluetun) must already be running on the host. It is managed by a different compose; finder/docker-compose.yml attaches to its network namespace via network_mode: "container:media_gluetun".
  • HTTP proxy (standalone): reach a Gluetun HTTP proxy at GLUETUN_PROXY (default http://gluetun:8888), or set GLUETUN_PROXY="" for a direct, un-tunnelled connection.

Also required: the ARCGIS postcode parquet at ../property-data/arcgis_data.parquet (override with ARCGIS_PATH).


Running

finder/docker-compose.yml brings up the scraper plus FlareSolverr (which solves Zoopla's Cloudflare challenge), both sharing media_gluetun's netns. This is the intended production-like path.

cd finder

# Start the sidecars (finder stays up via `sleep infinity`).
docker compose up -d --build flaresolverr finder

# Run scrapes inside the container (uv run uses the image's /opt/venv):
docker compose exec finder uv run python main.py --source all
docker compose exec finder uv run python main.py --source zoopla --outcodes SW9 --test

docker compose down

If a leftover finder_flaresolverr container exists from earlier testing, remove it first: docker rm -f finder_flaresolverr.

In this setup GLUETUN_PROXY="" (the shared netns already tunnels everything), ZOOPLA_FETCHER=flaresolverr, and DATA_DIR / ARCGIS_PATH are preset by the compose file.

Standalone (quick Rightmove / OnTheMarket dev runs)

Zoopla needs FlareSolverr, so standalone is for the HTTP portals. You just need a venv and VPN reachability.

cd finder

# One-time: create the venv from the lockfile.
uv sync --frozen          # creates .venv with httpx, polars, fake-useragent, …

# Small, safe run into a temp dir (does NOT touch real data/):
.venv/bin/python main.py --source rightmove --outcodes SW9 \
  --max-properties-per-source 20 --output-dir /tmp/finder-smoke

# Go direct instead of via the gluetun proxy hostname:
GLUETUN_PROXY="" .venv/bin/python main.py --source onthemarket --outcodes SW9 \
  --output-dir /tmp/finder-smoke

(uv run python main.py … works too and resolves the env automatically.)


CLI reference (main.py)

Flag Default Meaning
--source rightmove,onthemarket all Comma-separated portal(s): any of rightmove, onthemarket, zoopla, or all.
--outcodes SW9,E14,BR1 none Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS.
--limit-outcodes N none Cap the number of outcodes (quick smoke).
--max-properties-per-source N none Stop each source after N transformed listings.
--output-dir DIR data/ Where the parquet (and detail_cache/) are written.
--test off ~10 likely-London outcodes, ≤100 listings/source, writes to data/test/.

Always pass --output-dir /tmp/... for testing: the default data/ holds the real listings the app consumes.

Stopping a run

Ctrl+C (SIGINT), or docker stop (SIGTERM), triggers a graceful shutdown: every source stops at its next outcode boundary, in-flight delays and retry backoffs wake immediately, and the run still persists the detail caches and writes the listings collected so far before exiting (code 130). Press Ctrl+C a second time to force-quit. See shutdown.py.


Sources & what each needs

Source Transport Needs FlareSolverr? Concurrency Notes
Rightmove plain httpx no concurrent detail fetches main path
OnTheMarket plain httpx no concurrent detail fetches __NEXT_DATA__ JSON
Zoopla browser / FlareSolverr yes (ZOOPLA_FETCHER=flaresolverr, default) serial (browser-bound) Cloudflare-protected; skipped gracefully if FlareSolverr is unavailable

Rightmove and OnTheMarket run concurrently in worker threads; Zoopla runs on the main thread (its per-outcode wall-clock guard uses SIGALRM, which only fires on the main thread). One source failing never kills the others.


Output

Each run writes <output-dir>/online_listings_buy.parquet.

A separate enrich step (outside finder/) turns that into online_listings_buy_enriched.parquet, which is what the Rust backend actually loads (--actual-listings-path …/online_listings_buy_enriched.parquet in the top-level docker-compose.yml). That enrich/scheduling pipeline is not documented here. Only the raw scrape is documented.

The top-level docker-compose.yml (Rust server, frontend, pocketbase, screenshot) is the web app; it is downstream of the scrape and is not required to run the scraper.


Performance & caching

Mechanism Where Effect
Persistent detail cache data/detail_cache/{source}.json A listing's postcode never changes, so a re-run reuses cached results and only fetches new listings. Delete this folder to force a full re-fetch.
Concurrent detail fetches Rightmove, OnTheMarket Detail pages fetched in parallel instead of one-at-a-time.
Global rate limiter http_client.RATE_LIMITER Caps the combined request rate across all threads/portals so concurrency stays polite.

Note on the "accurate-pin skip" flag (RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS): it is currently a no-op. The idea was to skip the detail fetch for listings the search already pins precisely (location.pinType == "ACCURATE_POINT"), but Rightmove's live search API does not include pinType in the payload (only latitude/longitude), so nothing is ever skipped. It degrades safely (no accuracy loss) but provides no speed-up today.


Configuration

Environment variables (override the defaults in constants.py):

Variable Default Purpose
DATA_DIR finder/data Output root.
ARCGIS_PATH ../property-data/arcgis_data.parquet Postcode reference data.
GLUETUN_PROXY http://gluetun:8888 HTTP proxy for egress; "" = direct.
GLUETUN_CONTROL_URL http://gluetun:8000 Gluetun control API.
FLARESOLVERR_URL http://gluetun:8191/v1 FlareSolverr endpoint (Zoopla).
ZOOPLA_FETCHER flaresolverr flaresolverr or camoufox.
ZOOPLA_OUTCODE_TIMEOUT_SECONDS 300 Per-outcode wall-clock budget for Zoopla.
DETAIL_FETCH_CONCURRENCY 8 Parallel detail fetches (Rightmove/OTM).
REQUESTS_PER_SECOND 10 Global request-rate cap. Lower it if you see 429/403.
RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS 1 Inert today (see note above).

Non-env code constants worth knowing (constants.py / onthemarket.py): RIGHTMOVE_FETCH_DETAILS, RIGHTMOVE_MAX_DETAILS_PER_OUTCODE (4000), OTM_FETCH_DETAILS, OTM_MAX_DETAILS_PER_OUTCODE (400), ZOOPLA_FETCH_DETAILS, ZOOPLA_MAX_DETAILS_PER_OUTCODE (4000).


Tests

pytest is not a declared dependency; run it ephemerally with uv (no project change needed):

cd finder
uv run --with pytest pytest -q

Repo layout

File Responsibility
main.py CLI entry point: parse args, build the postcode index, call run_scrape.
scraper.py Orchestration: per-source runners, provider parallelism, cache load/save, merge + write.
rightmove.py / onthemarket.py / zoopla.py Per-portal search + detail scraping and parsing.
transform.py Raw listing → output schema; postcode trust rules.
http_client.py Shared httpx client, retry/backoff, and the global RATE_LIMITER.
postcode_cache.py Persistent (cross-run) detail-cache load/save.
spatial.py Grid spatial index for coordinate → nearest postcode.
storage.py Parquet writer (server-ready column names).
constants.py Tunables and endpoints.
test_*.py Unit tests.