By the end of this tutorial, you will have a Python script that turns a Standvirtual search into structured JSON: one row per listing with price in euros, first-registration year, mileage in kilometres, fuel, gearbox, engine power and capacity, town and district, and whether the seller is a dealer or a private seller.
Standvirtual (standvirtual.com) is a Portuguese marketplace for cars and motorbikes. Its search pages show 32 listings at a time, and each listing's specs come as display text such as "26157 km" or "245 cv". In October 2026 testing (2026-10-20), plain requests with no proxy got the full search pages.
We will use Python and the Apify Actor Standvirtual Scraper (reventadata/standvirtual-scraper), which reads the data embedded in Standvirtual's search pages, with no browser. The sample rows in this guide come from a capture dated 2026-10-20, parsed with the Actor's own code.
⏱️ Time estimate: one sitting.
Prerequisites
To follow this tutorial, you will need:
-
Python 3.11+ installed locally (
apify-client3.x requires it). - The Apify API client for Python.
- An Apify account and API token.
Sign up for an Apify account, then copy your API token from API & Integrations in Apify Console. Install the client:
pip install "apify-client~=3.0"
The code loads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here"
What you'll achieve
You will turn a Standvirtual search into rows you can load into a spreadsheet or database: listing id and link, title, make, model and version, year, price with Standvirtual's own price indicator, mileage, fuel, gearbox, engine power in cv and capacity in cm3, town and district, posting time, seller type, and the dealer's name and Standvirtual page when the seller is a dealer.
Note on data boundaries: The Actor reads search result pages only. It does not open listing pages, so the full ad text is not in the rows, and it returns no phone numbers or e-mails. Private sellers have no name in the data at all.
Example use case: You want the VW Golf GTI listings on Standvirtual. Setting searchQueries: ["golf gti"] sends that keyword to the site's own search, and the Actor keeps only rows whose title contains both words.
Step 1: Access Standvirtual Scraper
Go to the Standvirtual Scraper page on Apify Store to try it. The page shows the input fields, an output example and the README.
You can run it from Apify Console with Try for free. In this tutorial we start it from Python instead, so the same script can run again tomorrow.
Step 2: Configure the search
Put one or more keywords in searchQueries; each one is a separate search. Leave the list empty to browse the whole category with filters only. category is carros (cars, the default) or motos (motorbikes).
In Python, we create the client and the input:
import os
from apify_client import ApifyClient
# 1. Initialize ApifyClient with token from environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])
# 2. Define the search: "golf gti" cars, one page in Standvirtual's own order
run_input = {
"searchQueries": ["golf gti"],
"sortBy": "relevance",
"maxPages": 1,
}
(Note: the sample rows in this guide come from page 1 of this same search, "golf gti" cars in Standvirtual's Recommended order, captured on 2026-10-20. That page held 32 listings out of 145 results; 27 had both words in the title.)
Step 3: Optional settings and filters
The price, year, mileage, fuel and sort fields are sent to Standvirtual as part of the search URL, so the site does the filtering. A field you leave empty is left out of the request, never sent as 0.
| Parameter | Type | Default | Description |
|---|---|---|---|
searchQueries |
array | empty | One search per term. Empty browses the category. |
category |
string | "carros" |
carros (cars) or motos (motorbikes). |
make |
string | empty | Make as Standvirtual writes it in URLs: bmw, vw, mercedes-benz. |
minPrice / maxPrice
|
integer | empty | Price range in EUR. |
minYear / maxYear
|
integer | empty | First-registration year range. |
maxMileage |
integer | empty | Maximum mileage in km. |
fuelType |
string | "any" |
diesel, electric, gaz (petrol), gpl, plugin-hybrid and the site's other values. |
sortBy |
string | "newest" |
newest, priceAsc, priceDesc, relevance. |
requireKeywordMatch |
boolean | true |
Drop titles missing any search word. |
maxItems |
integer | 1000 |
Total rows to save. |
maxPages |
integer | 50 |
Pages per search, 32 listings each. |
A few practical notes:
-
The fixed lists are checked before the run starts. A value outside a list (a misspelt
fuelType, say) is refused by the platform and the Actor is not started. -
makemust be a URL slug. Plain letters, digits and hyphens only; anything else stops the run before the first page is fetched. - There is no model filter. Standvirtual ignores a model in the URL, so put the model in the search term.
Step 4: Run the Actor
Start the run and wait for it with client.actor("reventadata/standvirtual-scraper").call(run_input=run_input):
# 3. Start the Actor run and wait for it to complete
print("Starting Standvirtual extraction...")
run = client.actor("reventadata/standvirtual-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
status = run.status if run else "unknown"
raise SystemExit(f"Run did not succeed (status: {status}).")
# 4. Fetch the resulting dataset items
dataset = client.dataset(run.default_dataset_id).list_items().items
print(f"Extraction complete! Retrieved {len(dataset)} listings.\n")
Behind the scenes:
- The Actor puts the keyword in the search path (
/carros/q-golf-gti) and the filters in the query string, then fetches the page directly, with no proxy unless you turn one on. - It reads the listings from the JSON block Standvirtual embeds in the page, not from the visible cards, so mileage, power and capacity arrive as numbers.
- It walks
?page=2,?page=3and so on untilmaxPages,maxItemsor the last page of results. Duplicate listing ids are skipped.
Step 5: View and download results
Once the run finishes, print a short summary of each row:
for item in dataset:
price = item.get("price")
km = item.get("mileageKm")
print(f"Listing: {item.get('title')} ({item.get('year')})")
if price is None:
print(" Price: not listed")
else:
print(f" Price: {price:,.0f} {item.get('currency')}")
print(f" Mileage: {km:,} km" if km is not None else " Mileage: not stated")
print(f" Power: {item.get('enginePowerCv')} cv, {item.get('gearbox')}")
print(f" Seller: {item.get('sellerType')} ({item.get('dealerName') or 'no dealer name'})")
print(f" Where: {item.get('city')}, {item.get('region')}")
print("-" * 50)
Apify datasets also export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Here is one row, a sample from a capture dated 2026-10-20, parsed with the Actor's own code. One field is left out of this excerpt: the engine capacity in cm3, 1984 for this car.
{
"id": "8097670190",
"url": "https://www.standvirtual.com/carros/anuncio/vw-golf-ver-2-0-tsi-opf-dsg-gti-ID8Q0ZWI.html",
"title": "VW Golf 2.0 TSI OPF DSG GTI",
"description": null,
"price": 31999.0,
"currency": "EUR",
"priceEvaluation": "BELOW",
"make": "VW",
"model": "Golf",
"version": "2.0 TSI OPF DSG GTI",
"year": 2023,
"mileageKm": 26157,
"fuelType": "Gasolina",
"gearbox": "Automática",
"enginePowerCv": 245,
"city": "Nogueira da Regedoura",
"region": "Aveiro",
"postedAt": "2026-10-09T14:32:16Z",
"imageUrl": "https://ireland.apollo.olxcdn.com/v1/files/siem67vm28sp-STDVTLPT/image;s=640x480",
"sellerType": "professional",
"dealerName": "FMaia Car",
"dealerUrl": "https://FMaiaCar.standvirtual.com",
"badges": [
"WARRANTY"
],
"categoryId": "29",
"matchedKeyword": "golf gti"
}
Understanding your results
The key fields:
-
id/url: Standvirtual's listing id and the listing link. -
price/currency: the asking price as a number; EUR in every sample row. -
priceEvaluation: Standvirtual's own price indicator. The 27 kept sample rows hadBELOW12 times,IN12,ABOVE2 andNONEonce. -
year,mileageKm,enginePowerCvand the engine capacity in cm3: integers parsed from the site's parameters. All 27 kept sample rows had a 1984 cm3 engine. -
fuelType,gearbox: as the site writes them, in Portuguese (Gasolinais petrol,Automáticaautomatic). -
city/region: town and district. -
sellerType:professional(dealer) orprivate.dealerNameanddealerUrlare filled for dealers only. -
description: the short text shown in search results; empty in all 32 sample rows. -
matchedKeyword: which search term found the row.
In the 27 kept sample rows, 25 came from dealers and 2 from private sellers. All 25 dealer rows carried a dealerName and 24 a dealerUrl. Every kept row was petrol, 20 automatic and 7 manual, registered between 2006 and 2025, priced from 10,450 to 43,500 EUR. badges held WARRANTY on 26 rows and LOW_MILEAGE on 2.
Pro tips
-
Use
newestfor long runs. The defaultnewestorder pages cleanly; withrelevanceStandvirtual reshuffles between pages, so a run over many pages returns fewer distinct listings. The example usesrelevanceonly because the sample page was captured in that order. -
Filter on the site, not afterwards. A
maxPricecut is applied by Standvirtual: in the 2026-10-20 recon, a 20,000 EUR cap took "golf gti" from 145 results to 46. -
Keep
requireKeywordMatchon, but know what it drops. On the sample page it dropped a VW Bora, a Golf R and a 2017 245 cv car titled only "VW Golf". Turn it off and filter onversionyourself if titles are short. -
Make plus keyword. With a keyword,
makeis applied to the rows after fetching, so a "serie 3" search withmake: "bmw"drops other makes from the site's fuzzy results. -
Start small. Begin with
maxPages: 1to check the fields, then raise it.
Troubleshooting
-
Zero rows (status message "0 results for ..."): a page that was blocked or could not be read ends the run FAILED. When the pages load normally but nothing matches, the run ends SUCCEEDED with an empty dataset and that message. Loosen your filters, check the keyword, or turn off
requireKeywordMatch. -
Run FAILED at once on
make: the value is not a URL slug. Copy it from a Standvirtual URL, such asmercedes-benz. -
Run not started: a value outside a fixed list (
category,fuelType,sortBy) is refused by the platform before the run starts. -
Reading the log: each rejection reason is logged as
Rejected N: ..., thenPushed N row(s).. Read it from Python withclient.log(run.id).get(). For help, use the Store page.
Pricing and next steps
For current pricing and details, see the Store page.
To repeat a search, save it as a task and add an Apify schedule, which runs Actors and tasks on a cron expression. A webhook on ACTOR.RUN.SUCCEEDED sends the run object, including defaultDatasetId, to your endpoint.
Links:
Which models or districts are you planning to track on Standvirtual? Drop a comment! 💬
Top comments (0)