DEV Community

Cover image for How to Scrape Webmotors Car Listings into JSON with Python
ReventaData
ReventaData

Posted on Edited on Originally published at apify.com

How to Scrape Webmotors Car Listings into JSON with Python

By the end of this tutorial, you will have a Python script that turns a webmotors.com.br car search into structured JSON: one row per listing with the asking price in Brazilian reais, fabrication and model year, mileage in kilometres, transmission, body type, colour, engine, city, state, and the dealership's trade name when a dealer is selling.

Webmotors is a Brazilian vehicle classifieds site. Its search endpoint serves 24 cars per page by default, and in October 2026 testing it answered direct requests, a datacenter proxy and a residential proxy with a PerimeterX challenge page (HTTP 403) instead of results.

We will use Python and the Apify Actor Webmotors Brazil Scraper (reventadata/webmotors-scraper), which reads the site through Apify's Unblocker proxy by default. The sample row in this guide is a sample from a capture dated 2026-10-17, parsed with the Actor's own code.

⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.


Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ installed locally (apify-client 3.x requires it).
  2. The Apify API client for Python.
  3. An Apify account and API token.

Sign up for an Apify account, then copy your API token from the Integrations tab in Apify Console. Install the client:

pip install "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

The code loads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will turn a Webmotors search into rows you can load into a spreadsheet or database: listing id and link, title, make, model and version, fabrication and model year, price, odometer in kilometres, new or used, transmission, colour, doors, body type, engine size, horsepower, seller type, dealer name, city, state, a photo URL and the listing's feature tags (such as "Único dono", single owner).

Note on data boundaries: The Actor reads the site's search results only, never listing detail pages. Private sellers get no name in the row: the dealer name is filled only for dealership listings.

Example use case: You want Porsche 911 listings across Brazil with their prices and mileage. Setting searchQueries: ["porsche 911"] asks the site for that make and model, and each row tells you the year, the kilometres and which dealership is selling.


Step 1: Access Webmotors Brazil Scraper

Go to the Webmotors Brazil Scraper page on Apify Store to try it.

In this tutorial we start it from Python, so the same script can run again tomorrow.


Step 2: Configure the search

The main input is searchQueries, a list of make and model searches. The Actor turns each one into a Webmotors search path: the first word becomes the make and the rest becomes the model, so "porsche 911" becomes /carros/estoque/porsche/911. A one-word query is read as a make ("honda" lists every Honda); a model alone such as "civic" is skipped with a warning.

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: Porsche 911 listings, up to 20 rows
run_input = {
    "searchQueries": ["porsche 911"],
    "maxItems": 20,
}
Enter fullscreen mode Exit fullscreen mode

(Note: the sample row in this guide is one Porsche 911 listing captured from Webmotors on 2026-10-17 and parsed with the Actor's code. It is not the output of a run of this input.)


Step 3: Optional settings and filters

Every filter below goes into the Webmotors search URL, and the Actor checks each row against it again before saving it.

Parameter Type Default Description
searchQueries array ["honda civic"] Make and model searches, first word = make.
maxItems integer 100 Total listings to save, across all queries.
condition string "all" all, used, new.
minYear / maxYear integer empty Year range (model year, or fabrication year when there is none).
minPrice / maxPrice integer empty Price range in reais.
maxMileageKm integer empty Odometer cap in kilometres.
state string empty Two-letter state code, such as SP or RJ.
transmission string "all" all, automatic, manual.
sortBy string "relevance" relevance, lowest_price, highest_price, lowest_km, newest_year.
requireKeywordMatch boolean false Keep only rows whose title holds every query word.

A few practical notes:

  • The fixed lists are checked before the run starts. A value outside condition, transmission or sortBy is refused by the platform and the Actor is not started. state is free text, so type the code carefully.
  • maxItems is shared by all queries. The first query can use the whole budget; list the most important search first.

Step 4: Run the Actor

Start the run and wait for it with client.actor("reventadata/webmotors-scraper").call(run_input=run_input):

# 3. Start the Actor run and wait for it to complete
print("Starting Webmotors extraction...")
run = client.actor("reventadata/webmotors-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")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor sends each search to the site's own search endpoint through Apify's Unblocker proxy (the default). In the October 2026 test, Unblocker was the one connection that got results; no proxy, datacenter and residential all got the challenge page.
  • It asks for 24 cars per page and reads pages in order until it has maxItems rows or a page comes back with fewer than 24 cars.
  • A listing id it has already saved is skipped, also across queries.

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("odometerKm")
    mileage = f"{km:,} km" if km is not None else "not stated"

    print(f"Listing: {item.get('title')}")
    if not price:
        print("  Price: not listed")
    else:
        print(f"  Price: R$ {price:,.0f}")
    print(f"  Year: {item.get('yearFabrication')}/{item.get('yearModel')}")
    print(f"  Mileage: {mileage}")
    print(f"  Seller: {item.get('sellerType')} ({item.get('dealerName') or 'no dealer name'})")
    print(f"  Location: {item.get('city')}, {item.get('state')}")
    print("-" * 50)
Enter fullscreen mode Exit fullscreen mode

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-17, parsed with the Actor's own code. A saved row also carries scrapedAt, added at save time:

{
  "itemId": "78266189",
  "title": "PORSCHE 911 4.0 24V H6 GASOLINA GT3 MANUAL",
  "url": "https://www.webmotors.com.br/comprar/porsche/911/porsche-911-40-24v-h6-gasolina-gt3-manual/78266189",
  "price": 2399000.0,
  "make": "PORSCHE",
  "model": "911",
  "version": "4.0 24V H6 GASOLINA GT3 MANUAL",
  "yearFabrication": 2025,
  "yearModel": 2026,
  "odometerKm": 1563,
  "condition": "used",
  "transmission": "Manual",
  "color": "Prata",
  "doors": "2",
  "bodyType": "Cupê",
  "engineSize": "4.0",
  "horsePower": "510 cv",
  "sellerType": "dealership",
  "dealerName": "STUTTGART PORSCHE - BLUMENAU",
  "city": "Blumenau",
  "state": "Santa Catarina (SC)",
  "imageUrl": "https://image.webmotors.com.br/_fotos/AnuncioUsados/div/2026/202609/20260924/porsche-911-4-0-24v-h6-gasolina-gt3-manual-wmimagem15471201988.webp",
  "attributes": [
    "Aceita troca",
    "Único dono",
    "IPVA pago",
    "Licenciado"
  ],
  "matchedKeyword": "porsche 911"
}
Enter fullscreen mode Exit fullscreen mode

Understanding your results

The key fields:

  • itemId: the Webmotors listing id. url is a link the Actor builds from make, model, title and that id.
  • title, make, model, version: as the site writes them (in capitals, in the sample).
  • yearFabrication / yearModel: Webmotors gives both. The sample car was built in 2025 as a 2026 model.
  • price: the asking price in reais, as a number. It is 0.0 when the listing has no numeric price.
  • odometerKm: kilometres. The sample shows 1,563 km on a car listed as used.
  • condition: used or new, from the listing type.
  • transmission, bodyType, color: in Portuguese (Cupê is coupé, Prata is silver).
  • sellerType / dealerName: dealership or private; the trade name only for dealerships.
  • attributes: the listing's feature tags. "Aceita troca" means the seller accepts a trade-in, "IPVA pago" that the annual vehicle tax is paid.
  • matchedKeyword: the query that found the row.

Pro tips

  1. Write queries as make, then model. The first word goes into the make slot, so a two-word make such as Land Rover puts "land" there. Check the first rows of a new query before you rely on it.
  2. Narrow on the site, not afterwards. condition, the year, price and mileage ranges, state and transmission go into the Webmotors search URL, so you read only pages you want.
  3. Missing values drop out under a filter. With maxMileageKm set, a row without an odometer reading is skipped; with a year range, a row without a year is skipped.
  4. Keep each query to make and model. A version word joins the model: "honda civic hybrid" becomes the path honda/civic-hybrid, which Webmotors does not recognise, so the Actor skips that query with a warning instead of pushing every Honda.
  5. Pick a fixed sort for repeat runs. lowest_price, highest_price, lowest_km and newest_year map to the site's own sort orders, so two datasets are sorted the same way.
  6. Start small. Begin with maxItems: 20 to check the fields on one page of results, then raise it.

Troubleshooting

  • Run FAILED with "0 results for queries ...": a blocked run or one that cannot read a page ends FAILED; a search that loads normally but matches nothing ends SUCCEEDED with an empty dataset and a status message saying so. Loosen your filters or check the make and model spelling.
  • Run not started: a value outside a fixed list (condition, transmission, sortBy) is refused by the platform before the run starts.
  • A query that stops early: when a page keeps failing, the Actor ends that query, logs Aborting query with the error, and moves on to the next one.
  • Fewer rows than expected: the run's summary line reads Webmotors run finished: pushed N rows. Rejections summary: {...}, a count of dropped rows per reason (a filter, a duplicate id, or a listing it could not parse). Read it from Python with client.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 makes or states are you planning to track on Webmotors? Drop a comment! 💬

Top comments (0)