perfect-postcode/finder/README.md
2026-06-28 11:59:44 +01:00

224 lines
9.5 KiB
Markdown

# 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](#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](#performance--caching).
---
## 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
### Docker Compose (recommended, the only way that does Zoopla)
`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.
```bash
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.
```bash
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):
```bash
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. |