Scrape UK property listings from Zoopla (for-sale or to-rent) — price, address, bedrooms, agent phone, floor plan, EPC, photos, full description. Export to JSON or CSV. We handle the blocks so your dataset stays clean.
Fix: 22% customer success persists after 0.4 (build 0.4.1, live since
2026-09-09) — 20/21 runs TIMED-OUT over the trailing 30 days
(publicActorRunStats30Days), last customer run 2026-09-11. 0.2, 0.3,
and 0.4 each bounded one more unbounded SDK/browser round-trip (the
search+detail loop's total wall clock, open_page's
new_context/new_page, every post-loop Actor SDK call, and Camoufox's
own launch/teardown) but all three missed a call sitting right next to an
already-bounded one in the exact same function: src/browser.py::fetch_html()
calls page.goto(..., timeout=GOTO_TIMEOUT_MS) (bounded) immediately
followed by page.content() (not bounded). Unlike every other
Playwright Page method used in this codebase, Page.content() takes no
timeout argument at all and is not covered by
page.set_default_timeout() either (confirmed against the installed
playwright package's generated API) — so a page left unresponsive after
domcontentloaded (a stuck anti-bot challenge, a wedged Juggler/CDP
round-trip) hangs this call forever, with nothing standing between it and
the platform's declared run timeout. fetch_html is the busiest single
call in the run: once per search fetch AND once per every detail fetch
(up to maxProperties), so any one page going unresponsive after
domcontentloaded could hang the whole run — indistinguishable from a
TIMED-OUT run's own dashboard entry, with no explanation in the log.
Shared root cause, not shared fix: the identical unbounded
page.content() call (no timeout, same fetch-then-return-html shape)
exists in the sibling Camoufox actor cars-com-listings-scraper
(src/browser.py::fetch_page, line ~159), which shows the same 100%
TIMED-OUT signature right now — one bug in a shared fetch-page pattern
across two actors, not two independent defects. That actor is out of
scope for this fix (a separate fixer owns it) and was not touched here.
Fix: new CONTENT_TIMEOUT_S = 20.0 wraps page.content() in
asyncio.wait_for. A stall raises stdlib TimeoutError, already a
member of RECOVERABLE_BROWSER_ERRORS since 0.3, so it degrades
through fetch_html's existing except RECOVERABLE_BROWSER_ERRORS
clause (return None) — a stalled search fetch is treated as a
geo-splash-guard failure (session rotate + retry) and a stalled detail
fetch degrades that one listing to its search-card fields, exactly
mirroring how every other bounded call in this module already degrades.
Not yet separately confirmed as THE complete explanation: as with
every prior round, /v2/acts/{id}/runs only exposes this Actor's own 9
runs (all SUCCEEDED/FAILED, none TIMED-OUT — all pre-0.5) because the 20
failing customer runs execute under the customer's own account and
their logs are unreadable to this token
(reference-ppe-customer-runs-bill-us). This fix closes the one
remaining unbounded-wait gap a full audit of every await in the
request path found; a live 30-day window after 0.5 ships is the only
way to confirm no fifth stall site remains. Also notable:
publicActorRunStats30Days.totalUsers30Days is 1 — all 27 runs in the
window belong to a single customer/integration, not a broad customer
base, which does not change the fix but is worth flagging for the next
fixer if the pattern recurs.
2 new tests in tests/test_browser.py
(test_fetch_html_returns_none_when_content_stalls,
test_fetch_html_content_timeout_does_not_block_the_event_loop), 119
total green. ruff clean, pyright 0 errors.
0.4 — 2026-09-09
Fix: 11% customer success persists after 0.3 (build 0.3.1, live since
2026-09-01) — 17/19 runs TIMED-OUT over the trailing 30 days, last
customer run 2026-09-08. 0.3 bounded two unbounded SDK/browser
round-trips (open_page's new_context/new_page, and every
post-loop Actor SDK call in main.py) but missed a THIRD: Camoufox's
own process launch and teardown.
src/browser.py::open_browser() called AsyncCamoufox.__aenter__()
(the actual browser process launch) with no timeout of its own,
once per geo-splash-guard retry attempt in scraper.py::_try_once
— beforescraper.RunBudget is ever checked. A wedged launch
(an under-loaded or slow Camoufox process start, more likely under
sustained anti-bot pressure on a residential proxy exit) hangs the
entire run past the platform's declared timeoutSecs with nothing
to stop it — exactly the TIMED-OUT signature that persisted through
the whole 0.3.1 measurement window.
_close_browser_quietly()'s AsyncCamoufox.__aexit__() (process
teardown, called at the end of every attempt, including a
successful one) had the same gap — a wedged close could strand an
otherwise-complete, fully-scraped run the same way.
Fix: new BROWSER_LAUNCH_TIMEOUT_S = 60.0 wraps __aenter__ in
asyncio.wait_for; a stall raises stdlib TimeoutError, already a
member of RECOVERABLE_BROWSER_ERRORS since 0.3, so it degrades
through the existing _try_once except-clause (rotate session,
retry) instead of propagating uncaught. New
BROWSER_CLOSE_TIMEOUT_S = 30.0 wraps __aexit__; a stall there is
swallowed-and-logged (mirrors main.py's _set_status_message/
_set_value guards — a cosmetic teardown must never hold
already-built rows hostage).
Not yet separately confirmed as THE root cause: /v2/acts/{id}/runs
only exposes our own 8 runs (all SUCCEEDED, all on the 5-row QA
fixture) — the 17 failing customer runs execute under the
customer's own account and their logs are unreadable to this
token. This fix closes the one remaining unbounded-wait gap this
fleet's proven pattern (reference-fleet-fault-isolation-pattern)
predicts, verified against the live target locally, but a live
30-day window after this ships is the only way to confirm no
fourth stall site remains.
117 tests green (2 new: test_open_browser_raises_timeout_error_on_a_wedged_launch,
test_open_browser_teardown_swallows_a_stall in tests/test_browser.py),
ruff clean, pyright 0 errors, verify_input_prefill /
verify_no_scaffold_stub both OK.
0.3 — 2026-09-01
Fix: 18% customer success persists after 0.2, 9/9 failures TIMED-OUT.
0.2's RunBudget correctly bounds the search+detail loop's total wall
clock (3300s), but two unbounded SDK/browser round-trips remained —
the same defect shape that hit kick-chat-archive (0.15) and
vrbo-vacation-rentals-scraper (0.4) the same day:
src/browser.py::open_page() — neither Playwright's
browser.new_context() nor context.new_page() has a timeout of
its own, unlike fetch_html()'s page.goto()
(GOTO_TIMEOUT_MS). A wedged Camoufox process could hang either
call indefinitely. Bounded with a new PAGE_OPEN_TIMEOUT_S = 30.0
via asyncio.wait_for, and added stdlib TimeoutError to
RECOVERABLE_BROWSER_ERRORS so the bound firing degrades one
listing/search-fetch through the existing per-call
except RECOVERABLE_BROWSER_ERRORS handlers in scraper.py,
rather than propagating uncaught.
src/main.py — every Actor SDK round-trip AFTER the
budget-bounded loop (Actor.push_data, Actor.charge,
Actor.set_status_message, Actor.set_value) had no timeout of
its own and shares apify_client's own retry policy (confirmed
elsewhere in the fleet to stall up to ~48 minutes under a
platform-side hiccup). With RUN_BUDGET_SECONDS = 3300 and this
Actor's declared defaultRunOptions.timeoutSecs: 3600, only ~300s
of headroom remained for these calls — a single stall on any one
of them turns a functionally-complete run (rows built, ready to
push) into a platform-forced TIMED-OUT. Added
PUSH_TIMEOUT_S=60.0
and wrapped every call site: push_data now raises a new
PushStalledError on stall (fails fast and loud instead of
reporting a false "Done" for data that never landed — never
swallowed, unlike the others); charge swallows-and-logs
(unchanged non-fatal PPE contract, now bounded); the three
set_status_message call sites and two set_value call sites in
_finalize/_dump_empty_search_debug/_dump_detail_parse_miss_debug
now go through new guarded wrappers _set_status_message/
_set_value that swallow-and-log a stall — a cosmetic status
write or diagnostic dump must never hold already-landed data or
charges hostage.
Own-account runs never reproduced either gap: QA/local runs use a
tiny fixture that never approaches the wall-clock or SDK-retry
conditions needed to trigger them, and /v2/acts/{id}/runs cannot
see the failing customer runs at all (PPE customer runs execute
under the customer's own account) — confirmed via the REST API
that our own run history only holds 7 runs, all SUCCEEDED or
pre-0.2, so the 9 TIMED-OUT failures behind this fix are diagnosed
from code audit against the proven fleet-wide pattern, not a
reproduced log.
Version 0.2 → 0.3. 10 new tests across tests/test_main.py
(PushStalledError, _charge/_set_status_message/_set_value
stall-and-swallow/succeed pairs) and tests/test_browser.py
(open_page timeout + TimeoutError membership in
RECOVERABLE_BROWSER_ERRORS).
0.2 — 2026-08-25
Fix: 0% customer success, 5/5 runs TIMED-OUT (30-day public stats).
Root cause is reference-fleet-fault-isolation-pattern, the fleet's #1
recurring bug (previously hit workday-jobs-scraper for the identical
reason): every individual page fetch already had its own bounded
timeout (browser.GOTO_TIMEOUT_MS, 45s), but nothing capped the SUM
across many listings. With enrichDetails=true (the default) and a
degraded/slow proxy session, up to maxProperties (schema max 500;
default 50) sequential detail-page fetches could each burn close to
their full timeout, walking the total run time past the Actor's own
declared defaultRunOptions.timeoutSecs: 3600 and getting hard-killed
by the platform as TIMED-OUT instead of finishing with a partial
dataset. Own-account QA runs never reproduced it because the QA
fixture (maxProperties: 5) never accumulated enough sequential
detail-page time to approach the 1-hour ceiling — this is exactly why
the daily fleet-health signal (customer runs) caught it and QA didn't.
Added src/scraper.py::RunBudget — a wall-clock budget
(RUN_BUDGET_SECONDS = 3300, 55 min, comfortably under the
declared 3600s) shared across every geo-splash-guard retry in one
scrape() call (a retry does not reset the clock). clock is
injectable for tests.
_build_rows() checks the budget before each listing's detail
fetch and stops early once it's expired — the un-processed tail is
counted in ScrapeOutcome.listings_skipped (not silently dropped)
and ScrapeOutcome.budget_exhausted is set so the run reports a
clear partial-success status message
(main.py::_finalize — "... stopped early: run-time budget
reached") instead of running out the clock.
scrape()'s outer geo-splash-guard retry loop also checks the
budget between attempts so it doesn't sleep+retry pointlessly once
the run is already out of time.
This never changes the zero-rows fail-loud contract (ADR-0002 §6):
if the budget expires before any listing is built, the run still
fails loud with zero rows — only a genuine partial dataset gets the
softer partial-success path.
0.1 — 2026-08-20
Fix: first cloud run (Sfgpktp0siTm1O1Lk, build 0.0.1) reached the
search page and produced zero rows. Diagnosis + fix:
Pinned the proxy to RESIDENTIAL + apifyProxyCountry: "GB"
(.actor/input_schema.json, src/models.py default, and
src/browser.py::PROXY_COUNTRY_CODE, hardcoded — never read from
user input, mirrors the 99acres/funda single-market pattern). The
account is now on the STARTER plan; the old FREE-tier
RESIDENTIAL availableCount: 0 reasoning that justified an
unpinned default no longer applies, and a geo-random exit was the
likely cause of the empty/mismatched search page.
Added a zero-rows diagnostic: the last-fetched search page's raw
HTML is now captured in a debug_sink threaded through
scraper.scrape() → _try_once() → _fetch_search_hits(), and
dumped to the KVS key EMPTY_SEARCH_PAGE_HTML by
main._dump_empty_search_debug() before the run fails loud —
mirrors funda-netherlands-real-estate-scraper's
EMPTY_SEARCH_DEBUG_KEY pattern so a block vs. a parse-miss is
diagnosable from the KVS instead of guessed at.
Kept the existing no-geoip=True / static-locale Camoufox launch
pattern (proven fix, see src/browser.py module docstring and the
ai-overview-citations/vrbo-vacation-rentals-scraper precedent) —
pinning the proxy's own exit country is the safe way to fix the
locale/exit-geo mismatch the geoip=True warning flags, without
reintroducing the diagnosed pre-launch IP-echo-sweep crash risk.
Root cause of the zero rows, confirmed via the diagnostic above (cloud
run YmaJV5GMdj75QRo8O, real search-page HTML pulled from KVS): the
proxy fix cleared Cloudflare fine (a genuine 628 KB Zoopla page came
back, no challenge markers), but the search-card parser was wrong.
Zoopla now serves the Next.js App Router — there is no
<script id="__NEXT_DATA__"> blob (RSC streaming instead), and the
DOM shape the original hand-guessed selectors targeted
([data-testid="search-result"], data-listing-id, etc.) does not
exist on the live site. Rewrote src/parsers/search.py::parse_search_dom
against the real shape: cards live in
[data-testid="regular-listings"] div[id^="listing_"], price/beds/
baths/sqft/tenure are extracted from the row's own text via regex
(CSS module class names are hashed per build and not selected on),
address comes from the semantic <address> tag, and listing date
from <time datetime>. parse_search_json stays as a defensive
first try for a hypothetical legacy page. tests/fixtures/search_page.html
replaced with a fixture mirroring the confirmed real shape.
Added a second, unconditional diagnostic
(main._dump_detail_parse_miss_debug, KVS key DETAIL_PARSE_MISS_HTML)
that captures the first unparseable detail page in a run — detail-page
DOM has not been live-recon'd yet (still hand-built/spec-guessed), so
enrichDetails=True rows may currently degrade to search-card-only
fields (tracked gap, not a blocker: fault-isolated per
reference-fleet-fault-isolation-pattern, never fails the run).
0.0 — 2026-08-13
Scaffolded skeleton (T01). Not yet a working scraper — see
docs/specs/zoopla-uk-property-scraper/tasks.md for the build order
(T03 models, T04 Camoufox browser infra, T05 parser, T06 scraper
orchestration, T07 real main.py are the remaining hard gates).