Scrape vehicle listings and the full dealer directory from Coches.net, Spain's largest car marketplace. Extract prices, specs, photos, location, and dealer contacts. Filter by brand, model, fuel, price, year, or province. Incremental mode for zero-duplicate feeds.
New boolean input stripSellerPII (default: false). When enabled, phone is set to null
for non-professional sellers. Coordinates are already municipal centroids and are not modified.
Professional seller data is never affected.
0.2 — 2026-08-23 · Stop at the last page, and say so when the slice cannot be finished
Coches.net reports initialResults.totalPages. We were not reading it, so a sweep discovered the
end of its slice by fetching one page too many — and on this source that extra request frequently
returns an anti-bot challenge. The run then reported sweep_ended: "no_payload" and
reconcilable: false, marking a sweep untrustworthy for delisting when it had in fact covered
everything it was allowed to reach.
Now the sweep stops on page >= totalPages with sweep_ended: "last_page", which is one fewer
request and one less chance of being challenged.
The finding that matters more
robots.txt disallows pg=7 onward, so we cap at 6 pages ≈ 210 listings. Measured while fixing
this:
Slice
Listings
Pages
Fits in 6?
Soria, everything
275
10
no
Soria from €6,000
196
7
no
Soria from €10,000
147
5
yes
Segovia, everything
614
21
no
Soria is the smallest province in Spain and it does not fit. Province granularity is therefore
never enough for delisting on coches.net: a sweep that cannot exhaust its slice cannot say a listing
is gone. A first-page warning now states this explicitly when totalPages > maxPages, naming the
listing count a slice has to stay under.
The canary was re-pointed accordingly (Soria from €10,000, 147 listings, 5 pages). Seeded and
repeated: last_page, reconcilable: true, 147 of 147 recognised.
0.2 — 2026-08-22 · SEEN_IDS carries its own trustworthiness
SEEN_IDS was a bare list of ids. A consumer reading held − SEEN_IDS = sold would have deleted
live vehicles whenever a sweep was truncated or blocked — and a rate-limited run, which scans
nothing, would have read as "every car in this area sold overnight".
The record now reports whether it can be trusted:
reconcilable — true only when the sweep exhausted its slice AND scanned more than zero.
sweep_ended — feed_exhausted / last_page / max_pages / max_items / http_error /
no_payload / unhandled_error. Every termination path is now labelled at the point it happens.
scope — the filters that define the slice this list speaks for. Absence is only meaningful
inside it; comparing a provincial sweep against a national catalogue deletes the country.
An unusable sweep logs a warning instead of passing silently.
SUMMARY carries sweep_complete and sweep_ended too, so the canary can track truncation.
Verified on the platform: a finished sweep reports reconcilable: true, sweep_ended: "last_page";
a capped sweep reports false with the reason; and the promotion smoke run hit a real coches.net
rate limit and correctly reported reconcilable: false, sweep_ended: "http_error".
0.2 — 2026-08-21
Fixed — P0: runs were silently truncating to a handful of listings
Since build 0.1.45 (31 July 2026, 21:20 UTC) every run has been delivering roughly five
listings and then stopping, while reporting success.
dataset_schema.json declared videos as an array of strings. Coches.net returns objects:
Apify validates every push_data against the dataset schema, so the first listing with a video
raised InvalidRequestError: Schema validation failed. That exception propagated out of the
scrape loop into a broad except Exception, which logged it and let the Actor exit 0. The
console showed a green run with output, which is why it went unnoticed for three weeks.
Measured on 137 live listings: 27 carry a video — 20% — so a run died after ~5 listings.
Two changes:
videos is normalised to a list of URLs in _row() and _row_from_detail(), which is
what the schema documents and what consumers expect. Re-validated: 137/137 pass.
Pushes are batched with per-row fallback. A batch failure degrades to row-by-row and an
unpushable listing is dropped with a warning. One malformed listing can no longer abort a
sweep — that was the real defect; the type mismatch was just what triggered it.
Added
latitude, longitude, ine_municipality_code, geo_precision.
Coches.net publishes no per-listing coordinates on any surface — search payload, detail page
or Adevinta gateway. What it does expose is location.cityId, which is the official INE
municipality code (verified 14/14 against the official register, present on 171/171 listings
sampled). Coordinates are resolved offline from an embedded table of 8 217 municipality
centroids (georef-spain-municipio, from official IGN/INE boundaries), covering 8 122 of the
8 124 municipalities. No extra requests, no per-listing cost, no geocoding key.
geo_precision is "municipality" (typically 1–8 km) or "unavailable".
postalCode + radiusKm — radius search. Coches.net filters on
locationLat/locationLong/locationDistance; the visible text box is cosmetic. The site
geocodes what the user types with the Google Places API in the browser; we resolve the postal
code from an embedded GeoNames table instead. Note the radius is exact for private sellers,
while dealers with national or multi-province stock appear in any radius search regardless of
distance — that is coches.net's own behaviour, documented rather than hidden.
province — restrict to one province by INE code. Useful for sharding a large extraction
into queries that stay within the page limit.
cursorId — namespaces the incremental ledger.
emitSeenIds — writes every observed listing id to SEEN_IDS for delisting detection.
Geo and Delta views in the dataset schema.
Offline test suite (tests/test_v02.py, 38 assertions) including schema conformance
against 137 live listings.
Changed
Incremental mode rebuilt on a ledger. v0.1 stored max(id) as a watermark. Coches.net ids
are sequential and monotonic with creation date (verified 170/170), so the primitive looked
sound — but the search feed is ordered by relevance, which coches.net shuffles between
sessions. With a shuffled feed the watermark advances past ids the run never saw, and since it
only moves forward those listings fall below it permanently. Same class of loss as the Wallapop
timestamp cursor, reached by a different route.
The ledger assumes nothing about ordering: a listing is emitted when its id is unknown, or when
a fingerprint over price / km / title / reservation / price drop / seller / offer type moved.
Emitted rows carry _delta_status, _changed_fields, _previous_price and _price_delta.
Cursor state is namespaced per stream. v0.1 used one global key, so a search for one make
would cause a later search for another to skip everything below its watermark.
maxPages is hard-capped at 6.robots.txt disallows pg=7 through pg=69 and this
Actor respects it. The previous default of 20 when the field was absent violated it on any
API call that omitted maxPages. Build a large catalogue by sharding the query space
(province × make × price band), not by paginating deeper. A capped request is logged.
SUMMARY is now diagnostic: scanned, emitted_new, emitted_updated,
unchanged_skipped, ledger_size, max_pages_used, engine. A delta run that scans listings
and emits none is a correct outcome and logged as such; a run that scans nothing raises.
Migration
Automatic. v0.2 uses a new store (cochesnet-delta-state) and does not carry over the v0.1 id
watermark, which could not distinguish "already delivered" from "never seen". The first run with
enableCursor: true captures a full baseline and seeds the ledger.
Anyone who ingested between 31 July and this release should treat their coches.net data as
incomplete and re-run the baseline: the truncation dropped listings that cannot be recovered
retrospectively.
Compatibility
Backward compatible. Existing inputs keep their names and semantics; new fields are optional.
maxPages values above 6 are capped rather than rejected.