Scrape listings from Milanuncios.com (Spain largest classifieds). Supports any category, search queries, price/location filters, and optional detail page enrichment with 77 fields.
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 · Ledger delta engine, geo, coordinates, and three silent bugs fixed
Fixed — three filters that failed without saying so
All three were found while measuring national volume for a client quote, not by a failing run.
Each returned HTTP 200 and plausible-looking output.
tag_km and tag_year were always null. The parser keyed tags on kilometros and
anno; the source writes kilómetros and año. Measured over 1,000 live listings: the
source supplies km on 974 and year on 1,000, and the parser found 0 of 1,000 for both.
combustible carries no accent, so that one field kept working and the block looked healthy.
Tag keys are now folded (accent-stripped, lowercased) on the way in, so the next tilde cannot
empty a field in silence. Values are parsed to integers — "299.000 kms" → 299000 — with
absent distinguished from a genuine 0 kms.
The price filter never filtered.minPrice/maxPrice were sent as precio_min /
precio_max query parameters, which Milanuncios ignores. Verified on Soria: 298 listings
unfiltered, and 298 with precio_max=1. Price is a path segment on this site —
/precio/hasta-3000-euros/, /precio/desde-N-hasta-M-euros/. Every price filter a user set
was a no-op and nothing in the run said so.
An unknown province returned the whole of Spain. Verified: a made-up slug and a
misspelled one produce byte-identical national feeds — listings from Barcelona, León, Lugo,
Madrid, Teruel — with a 200 and no warning. Our own input schema documented bizkaia, while
the site uses vizcaya, so the documented example itself would have swept nationally and
billed for it. The 52 real slugs are now embedded (multi-word ones use an underscore:
ciudad_real, las_palmas), with generous normalisation for accents, co-official forms and
slash variants, and a fuzzy fallback for typos. Only genuinely unrecognisable input raises —
and it stops the run rather than sweeping the country.
Added
Incremental mode (enableCursor) running the same ledger engine as the Wallapop and
coches.net actors. A listing is emitted when its id is unknown, or when a fingerprint over
price / title / isReserved / sellerType / financedPrice / isHighlighted / cityName moves.
Emitted rows carry _delta_status, _changed_fields, _previous_price and _price_delta.
cursorId — namespaces the ledger. Runs sharing an id share one ledger, so a listing
surfaced by several queries (or several sources) is delivered once.
emitSeenIds — writes every observed listing id to SEEN_IDS, including those skipped as
unchanged, so consumers can expire vehicles that have sold.
tag_transmission — the cambio tag, present on ~3% of listings and previously dropped.
fetchCoordinates — fetches the listing detail page to get the seller's real coordinates
from ad.author.location. Because it runs after the delta decision, only new and changed
listings pay the extra request.
Why timestamps are not used as the change signal
Milanuncios publishes publishDate, updateDate and sortDate, and updateDate reads like a
seller-edit signal. Measured against the live site, it isn't:
?orden=date -> sortDate 40/41 descending
updateDate 40/41 descending <- moves in lockstep
publishDate 29/41<- not the sort key
updateDate and sortDate sit a fixed ~58-minute offset apart and dozens of listings share the
same value to the second. That is a bulk re-index, the same pattern that made Wallapop's
modified_at useless.
The feed also churns hard: two identical sweeps seconds apart shared 1 listing out of 40. Any
watermark scheme would advance past listings the run never saw.
So nothing is trusted except the listing id and the content itself. Verified by test: a listing
whose timestamps moved but whose content did not is not re-emitted.
Geo resolution, and its honest ceiling
province.idis the official INE province code. city.id is not the INE municipality code
— it is Milanuncios' own identifier — so the direct lookup that works for coches.net does not
transfer. Municipality is resolved by matching city.name within the province against the official
register, with name variants for bilingual spellings (Galdakao/Galdacano/Galdakano) and trailing
articles (Palmas de Gran Canaria, Las).
Measured on 42 live listings: 100% get coordinates, 62% at municipality precision. The
remaining 38% are listings where Milanuncios reports a sub-municipal locality — El Bocal,
Urbanizacion los Almendros, Barrio de el Castillo — which is not a municipality and is
deliberately not force-matched to one. Those fall back to the province centroid, labelled.
That gap is precisely why fetchCoordinates matters more on this source than on coches.net: the
detail page has real seller coordinates for exactly the listings a centroid cannot place. Measured
on a sample: 3 of 5 detail fetches returned seller coordinates, ~2 km from the centroid.
Fixed
Pushes are batched with per-row fallback. A single malformed listing can no longer abort a
sweep — the failure mode that silently truncated the coches.net actor for three weeks.
Container entrypoint.CMD ["python", "src/main.py"] put the file's own directory on
sys.path instead of the project root, so the new relative imports failed at runtime with a
green build. Now python3 -m src.
Compatibility
Backward compatible. Existing inputs keep their names and behaviour; new fields are optional. With
enableCursor: false the actor behaves as before, plus the location fields and the safer push.