DEV Community

Roberto Kerber
Roberto Kerber

Posted on

I pulled 100 used-car listings from Portugal's largest marketplace — what prices actually look like

If you follow the Portuguese car market — as a buyer, a dealership, or an analyst — you have probably noticed something: there is no FIPE table for Portugal. No central reference price. No official depreciation curve. The market price is whatever people are asking on Standvirtual, and until you pull the live listings, you are guessing.

So I pulled 100 live listings and looked at what the data actually says.

The finding: electric has overtaken diesel on the listing page

I expected diesel to dominate. Portugal has been a diesel country for decades. The measured split says otherwise:

Fuel type Listings Share
Eléctrico 35 35%
Diesel 25 25%
Híbrido 19 19%
Gasolina 15 15%
Híbrido Plug-In 6 6%

Electric is the single largest category, and diesel is second. Combined, electrified vehicles (electric + hybrid + plug-in) account for 60 of 100 listings.

Before over-reading this: it is the first page of a nationwide, unfiltered query. Standvirtual sorts by relevance, and newer, higher-margin inventory tends to surface first. Whether this reflects the whole national stock or just what dealers push to the top is not something a single page can settle. What it does show is that electric inventory is abundant enough to fill a third of the front page — which was not true a few years ago.

Prices

Across the 100 listings, after filtering out three entries under €500 that are almost certainly monthly-payment figures rather than sale prices:

EUR
Minimum 1.500
Median 28.900
Mean 32.548
Maximum 128.900

Median asking price is €28.900 — a number that would look extreme in Brazil and is unremarkable in Portugal. The mean sits well above the median, which is the usual signature of a right-skewed market: a handful of six-figure listings dragging the average up while most inventory clusters lower.

Age and mileage

Value
Median year 2022
Year range 2005 – 2025
Median mileage 44.804 km
Mileage range 5 – 293.000 km

A median of 2022 with 45.000 km is young for a used-car marketplace. Again, front-page selection effects likely apply.

Transmission

Automatic dominates: 83 of 100 listings, against 17 manual. For a European market with a strong manual tradition, that ratio is striking — and consistent with the electric share, since EVs are single-speed by construction.

Getting the data

Standvirtual runs on the OLX Group platform and serves listings server-side. No headless browser, no proxy, no TLS impersonation. A plain HTTP GET returns the full page.

But there is a trap worth documenting, because I fell into it.

The page ships a schema.org OfferCatalog in ld+json containing every car with clean numeric prices. It is tempting to parse that and join it to the article links by index. Do not. The catalog order does not match the rendered order, and the catalog carries no per-item URL. Joining by index produces records where a "Renault Kangoo" title sits on an MG MG4 URL — silently wrong data that looks fine until you click a link.

The reliable approach is to read each <article> block directly. The markup carries semantic labels next to their values:

import re, html, urllib.request

req = urllib.request.Request(
    "https://www.standvirtual.com/carros",
    headers={"User-Agent": "Mozilla/5.0"},
)
page = urllib.request.urlopen(req).read().decode()

for block in re.findall(r"<article[^>]*>(.*?)</article>", page, re.DOTALL):
    parts = [p.strip() for p in html.unescape(re.sub(r"<[^<]+?>", "|", block)).split("|") if p.strip()]
    fields = {parts[i]: parts[i + 1] for i in range(len(parts) - 1)
              if parts[i] in ("mileage", "fuel_type", "gearbox", "first_registration_year")}
    print(fields)
Enter fullscreen mode Exit fullscreen mode

Two more details that cost me time:

  • Prices are split by non-breaking spaces and inline tags (21<span> </span>900 EUR), so a naive text split loses them. Read them with a regex against the raw markup.
  • Some cards render the price client-side only. For those, the ld+json catalog is useful — matched by title, not by index. That fallback took price coverage from 11% to 100%.

Output shape:

{
  "id": "8Q0NET",
  "title": "BMW 740",
  "brand": "BMW",
  "price": 17900,
  "currency": "EUR",
  "year": 2012,
  "mileage": 174005,
  "fuelType": "Diesel",
  "transmission": "Automática",
  "isPromoted": false,
  "url": "https://www.standvirtual.com/carros/anuncio/bmw-740-ver-d-auto-ID8Q0NET.html"
}
Enter fullscreen mode Exit fullscreen mode

All six numeric and categorical fields came back populated for 100/100 listings after the fix.

Limits of this sample

  • 100 listings, one query, one day. No brand filter, no region filter, default sort.
  • Front-page selection. Relevance sorting is not random sampling. The electric share in particular should be read as "what surfaces first", not "what exists nationally".
  • Asking prices, not transaction prices. Nobody publishes what Portuguese cars actually sell for.
  • Three sub-€500 entries were excluded as probable monthly-payment figures; that is a judgement call, not a rule.

If you want to run this yourself

The scraper is on the Apify Store — Standvirtual Scraper. It handles the article parsing, the price fallback, and pagination, and returns the flat JSON above. It is how I pulled the 100 listings in this post.

What I would measure next

A single snapshot cannot show depreciation. The interesting version is the same query run daily: which listings cut their price, by how much, and how long they sit before doing it. Time-on-market plus price-cut magnitude is a far better proxy for real value than any asking-price average — and in a country with no FIPE equivalent, it may be the closest thing available.

If you track the Portuguese or wider European used-car market, I would be curious whether the electric share holds up in your data, or whether I am looking at a front-page artefact.

Top comments (0)