Scrape used-car listings from leboncoin.fr — price (EUR), brand, model, year, mileage, fuel, gearbox, horsepower, body type, colour, Crit'Air, seller (pro/private), location, and photos. Export to JSON or CSV; optionally enrich each listing with its full French description.
A search that matched nothing no longer fails the run._iter_listings
collapsed two different outcomes with if not ads: and raised on page 1,
throwing away the distinction its own return type already carried
(list[...] | None): None means we could not READ the page (no
__NEXT_DATA__, or a DataDome block _get_text already retried), while []
means __NEXT_DATA__ parsed cleanly and searchData.ads was empty — the
search RAN and genuinely matched nothing, which is easy to do with a narrow
brand + price + region filter combination. actor-start is charged before the
scrape, so the old behaviour billed the customer and then handed them a FAILED
run. 22 of the last 227 customer runs (30d) were FAILED.
A block on page 1 still fails loud — reporting a wall as "no results"
would give the customer a wrong answer, which is worse than an error.
14 of this Actor's 24 tests never ran.tests/test_ip_rotation.py and
tests/test_proxy_guard.py drove coroutines through
asyncio.get_event_loop().run_until_complete(...), which raises "There is no
current event loop in thread 'MainThread'" on Python 3.11+. So the entire
IP-rotation and residential-proxy-guard suite — the regression cover for the
0.4.0 root-cause fix — was silently failing. Switched to asyncio.run:
28 tests pass, was 14 failing.
Added tests/test_empty_search_succeeds.py pinning all four branches
(empty page 1, unreadable page 1, unreadable later page, exhausted result set).
0.4.0 — 2026-06-13
Root-cause fix for 70% failure rate (2026-06-10 to 2026-06-12): DataDome was
blocking specific residential exit IPs with HTTP 403. The old retry loop rotated
browser profiles but kept the same proxy URL (same session_id = same exit IP),
making all 5 retries futile against a blocked IP.
Fix: on each HTTP block (403/429/401/405), _get_text now calls
proxy_configuration.new_url(session_id=fresh) to obtain a different
residential exit IP before the next attempt. Proxy URL is propagated back
through the call chain so subsequent requests use the fresh address.
_resolve_proxy_url renamed to _resolve_proxy_configuration and now returns
the ProxyConfiguration object (not a plain URL string) so the scraper can
rotate IPs at will.
Proxy URL passed per-request via session.get(proxy=url) instead of baking
it into the AsyncSession constructor, enabling per-retry IP rotation without
recreating the session.
6 new unit tests covering IP rotation: session_id uniqueness, rotation on block,
no rotation on network error, returned proxy_url reflects rotated address.
0.3.0 — 2026-06-10
Force-residential proxy guard: _resolve_proxy_url now always injects
groups=["RESIDENTIAL"] regardless of customer input, preventing the
datacenter-exit footgun that causes DataDome HTTP 403 on leboncoin.fr.
Fail-loud on unavailable proxy: raises RuntimeError with a clear message
instead of silently falling back to direct routing and returning empty results.
Added unit tests asserting RESIDENTIAL is forced and raises when proxy is
unavailable.
0.2.0 — 2026-06-05
Config-refresh re-push: ship RESIDENTIAL proxy prefill to live build so Apify auto-QA routes through residential exits (DataDome-class anti-bot requires residential IPs).
Bumped version to 0.2 — strictly greater than published 0.1.
0.1.3 — 2026-06-03
First live cloud publish. Fixed network-error retry (proxy 595 → retryable like HTTP blocks).
Added per-request Referer + Sec-Fetch-Site headers so DataDome sees a proper
same-origin navigation chain (landing → search → detail). Resolves HTTP 403
on the search endpoint from cloud residential IPs.