Seven European car marketplaces. Seven national languages. Seven different ideas of what a "listing" is.
We ended up building a scraper for each one — Poland, Serbia, Croatia, Austria, Italy, Portugal, Finland — and the interesting part was never the fetching. It was deciding how much to normalize. Get that call wrong and you hand the buyer a dataset that looks clean and is quietly useless.
Here's what we learned.
🚗 The obvious problem: price is not a number
The naive schema is price: int. It survives about ten minutes of real data.
Poland's otomoto.pl quotes in PLN. Serbia's polovniautomobili.com quotes in either RSD or EUR — on the same results page, ad by ad, depending on what the seller picked. The rest of the batch — Croatia, Austria, Italy, Portugal, Finland — are euro markets.
So a single price column across seven countries is a trap: 4500 means one thing in Zagreb and something 100x different in Belgrade. Our row type carries the pair, always:
price: int | None # asking price, in the listing's own currency
currency: str | None # ISO-4217 — EUR, RSD, PLN
We deliberately don't convert to a common currency at scrape time. An FX rate is a point-in-time fact; baking one into a stored row means every future reader inherits whatever rate happened to be live the day we scraped, with no way to back it out. Emit the pair, let the analyst apply the rate their model needs. Cheap to do, impossible to undo.
🌍 The non-obvious one: don't translate the enums
This is the call we went back and forth on.
Serbian listings say Dizel and Benzin for fuel, Manuelni and Automatski for gearbox. Polish ones say Diesel and Benzyna. The tempting move is to map them all to a tidy English enum — diesel, petrol, manual, automatic — and ship something that looks beautifully uniform.
We don't. Our fuel and transmission fields keep the source vocabulary as published.
The reason is that a translation layer is a lossy guess that fails silently. Every marketplace has fuel values that don't map cleanly — plug-in hybrids, LPG conversions, dual-fuel, market-specific labels that only mean something locally. The moment you force those into a five-value English enum, you either drop a category or quietly file it under the wrong one. And nobody downstream can tell, because the output looks clean. That's the worst kind of bug: a dataset that passes review and is wrong.
Keeping Dizel is honest. It says "this is what the seller wrote." A buyer who wants an English enum can write a ten-line mapping they control and can audit — and, critically, they'll notice the values their mapping doesn't cover, because those fall through instead of silently becoming petrol.
What we do normalize is structure, not vocabulary: same field names, same types, same nullability, same units across all seven. mileage_km is always kilometres. engine_power_hp is always metric horsepower (these markets quote KS/KM/CV, not SAE hp — a real unit difference, not a naming one). Structure is objective. Vocabulary is a judgment call that belongs to whoever owns the analysis.
🛡️ And the part nobody enjoys: these sites push back
None of this is a polite public API. otomoto.pl sits behind DataDome. Several of the others will happily serve a captcha, or — worse — a clean 200 OK with an empty results container, which is the failure mode that quietly poisons a dataset because nothing looks broken.
What that costs in practice: browser TLS impersonation via curl-cffi so the handshake looks like real Chrome rather than Python, residential exits with a fresh session on each block, exponential backoff on 408/429/5xx with Retry-After honoured, and a guard that treats an empty-but-successful page as a failure to retry rather than a legitimately empty result.
One habit worth stealing even if you never touch our actors: pin the proxy country. A geo-random exit doesn't error — it returns a plausible, wrong page. Different currency defaults, different inventory, sometimes an entirely different locale of the site. Everything is 200 OK and the numbers are subtly garbage. We learned that one the expensive way, and every actor in this batch now pins its market and checks for the geo-splash page before parsing.
📦 The takeaway
Normalize units and structure aggressively. Normalize language never. And when you're pulling from seven countries at once, assume every "clean" number carries a hidden unit — currency, distance, power — until you've proven otherwise.
The seven actors above are live on the Apify Store, pay-per-result, no credit card to try them. Same row schema across all of them, so a Croatian dealer feed and a Finnish one land in the same table without a translation step.
We do the dirty work so your dataset stays clean. 😈
Top comments (0)