If you are trying to scrape Webmotors - Brazil's largest used car marketplace - to monitor prices, benchmark a dealership's stock, or feed a market research dashboard, you already know the frustrating part: it is not a static HTML page you can pull with a single requests.get(). This post walks through why scraping Webmotors is harder than it looks, what a DIY approach actually costs you in maintenance, and how to get clean structured data (price, odometer, seller, specs, FIPE percentage) without babysitting a scraper. Whether you searched "webmotors scraper", "webmotors api" or "scrape used cars brazil" to get here, the tradeoffs below apply either way.
Why scraping Webmotors is harder than it looks
Webmotors, like most modern car marketplaces, renders its listing grid client-side with JavaScript. A plain requests.get() returns a near-empty HTML shell - the actual make, model, price and photos get injected after the page hydrates in the browser. That alone rules out the simplest scraping approach and forces you into a headless browser.
Then there is anti-bot protection. Car marketplaces get scraped constantly by dealers, aggregators and price-comparison tools, so datacenter IPs get rate-limited or blocked outright after a handful of requests. You either rotate residential proxies yourself, or your scraper quietly dies after a few hundred listings.
On top of that:
-
Pagination is not always a simple
?page=2- it can involve infinite scroll, background API calls, and cursor-based paging -
Price parsing - listings show
"R$ 89.900"as a formatted string, not a usable number - Layout drift - the DOM changes whenever the marketplace ships a redesign, which quietly breaks CSS selectors and hands you empty or wrong fields with no error thrown
None of these problems is hard on its own. Together, they are why "just scrape it" turns into a part-time maintenance job.
Approach 1: DIY with Python
Attempt 1: plain requests (does not work)
import requests
r = requests.get("https://www.webmotors.com.br/carros/estoque?query=honda+civic")
print(r.status_code, len(r.text))
# 200, but the HTML body is a near-empty app shell.
# Listing data isn't in the initial response - it's fetched
# and rendered client-side after the page loads.
This is the first wall everyone hits: the response comes back 200 OK, but there is no listing data in it. You need a real browser.
Attempt 2: headless browser with Playwright
from playwright.sync_api import sync_playwright
def scrape_civic(max_ads=50):
listings = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://www.webmotors.com.br/carros/estoque?query=honda+civic")
page.wait_for_selector(".ResultCard", timeout=15000)
cards = page.query_selector_all(".ResultCard")
for card in cards[:max_ads]:
title = card.query_selector(".ResultCardTitle")
price_raw = card.query_selector(".ResultCardPrice")
listings.append({
"title": title.inner_text() if title else None,
"price_raw": price_raw.inner_text() if price_raw else None,
})
browser.close()
return listings
def parse_price(price_raw: str) -> int:
# "R$ 89.900" -> 89900
digits = "".join(c for c in price_raw if c.isdigit())
return int(digits) if digits else 0
This works locally, on your own IP, for a handful of requests. Selector names above are illustrative - the real ones you'll find in the page will differ, and that's exactly the point: the moment you scale this past a few dozen pages or run it on a schedule from a server, requests start getting blocked or CAPTCHA-walled, the selectors break the next time the site ships a frontend update, and you are now responsible for proxy rotation, retries and selector maintenance - forever, not once.
The real cost of DIY isn't writing the scraper, it's keeping it alive
A one-off script for 20 listings is genuinely fine to write yourself, no argument there. The cost shows up when you need this reliably, on a schedule, at volume:
| DIY (Playwright + your own infra) | Managed scraper (API) | |
|---|---|---|
| Upfront cost | Free (your time) | Pay per listing returned |
| JS rendering | You maintain the headless browser | Handled for you |
| IP blocking | You buy and rotate residential proxies | Already routed through a residential IP |
| Selector breakage | You fix it every time the site changes | Maintained on the provider's side |
| Price/field parsing | You write and maintain it | Comes back as typed JSON (price: 89900, not a string) |
| Scheduling | You wire up cron + monitoring | Native scheduler on the platform |
Neither column is objectively "right." If you need ten listings once for a one-off report, write the Playwright script above and move on. If you need this running daily, at hundreds or thousands of listings, the maintenance tax is the real cost, not the code.
Approach 2: a ready-made Webmotors scraper (API)
This is the part where I show you the shortcut. Webmotors Scraper is an Apify actor that handles the JS rendering, residential IP routing, pagination and price parsing described above, and returns structured JSON per listing.
Input
Two fields, no proxy configuration:
| Field | Type | Description | Example |
|---|---|---|---|
query |
string | Keyword to search on Webmotors | "honda civic" |
maxAds |
number | Maximum number of listings to return | 100 |
{
"query": "honda civic",
"maxAds": 100
}
Output
Every listing comes back flat, typed and ready to sort, filter or insert into a database - no nested objects, no string-to-number parsing:
{
"uniqueId": 71834512,
"title": "HONDA CIVIC 2.0 16V FLEXONE EXL 4P CVT",
"make": "HONDA",
"model": "CIVIC",
"version": "2.0 16V FLEXONE EXL 4P CVT",
"yearFabrication": 2023,
"yearModel": 2023,
"odometer": 34000,
"transmission": "Automática",
"bodyType": "Sedan",
"color": "Preto",
"price": 139900,
"sellerType": "PJ",
"sellerName": "AUTO PRIME",
"city": "Sao Paulo",
"state": "Sao Paulo (SP)",
"neighborhood": "Moema",
"fipePercent": 98,
"listingType": "U",
"imageCount": 12,
"thumbnail": "https://image.webmotors.com.br/_fotos/anunciousados/gigante/2026/.../honda-civic-wmimagem123.jpg",
"url": "https://www.webmotors.com.br/carros/honda/civic/71834512/"
}
price and odometer come back as integers, not "R$ 89.900" strings, so df["price"].median() or SELECT AVG(price) just works. fipePercent (asking price as a percentage of the FIPE reference table) is worth noticing on its own: it is a ready-made signal for underpriced listings, which is the whole basis for the arbitrage use case below.
Calling it from JavaScript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('plum_spear/aztec-webmotors').call({
query: 'honda civic',
maxAds: 100,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.length, 'listings');
console.log(items[0]);
Calling it from Python
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("plum_spear/aztec-webmotors").call(run_input={
"query": "honda civic",
"maxAds": 100,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
print(item["title"], item["price"], item["fipePercent"])
Calling it from the CLI or plain REST
apify call plum_spear/aztec-webmotors --input '{"query": "honda civic", "maxAds": 100}'
curl "https://api.apify.com/v2/acts/plum_spear~aztec-webmotors/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"query": "honda civic", "maxAds": 100}'
What people actually build with this
These map directly to how the data gets used in practice, not hypothetical scenarios:
-
Price monitoring and repricing. Run the same query daily on
honda civicortoyota corollaand track how the price distribution moves. Filter onfipePercent < 90to flag listings priced meaningfully below the FIPE reference table. - Dealership competitive intelligence. Pull every listing for the models your dealership stocks and benchmark your price against the live market, by state and mileage band.
-
Market research and segment analysis. Pull a broad query (
suv,pickup,sedan) and compute median price per brand for a report, instead of eyeballing a handful of listing pages. - Car buying research. Before a purchase, pull the model and year you're evaluating and see the full price spread across Brazil before you negotiate.
-
Arbitrage and reselling. Diff new listings by
uniqueIdon a frequent schedule and act on underpriced vehicles before anyone else refreshes the page.
Pricing
Pay-per-event: $0.15 per 1,000 listings returned, plus a minimal actor-start event. No subscription, no monthly minimum - you pay for the data you pull, not for idle time. Apify gives new accounts free monthly platform credits, so you can run a real query against your own use case before deciding whether it's worth it.
For context: if the DIY route above costs you a residential proxy subscription plus a few hours a month keeping selectors alive, $0.15 per 1,000 listings is the kind of number that stops being a debate past a certain volume. Below that volume, write the script above - it's genuinely fine.
Using it from an AI agent (MCP)
If you're wiring this into an agent instead of a script, actors published on Apify, including this one, are reachable through Apify's MCP server, which exposes them as callable tools for MCP-compatible clients. Same query / maxAds input, no separate integration to write.
Wrap-up
Scraping Webmotors yourself is doable, and for a one-off pull it's probably the right call - the Playwright snippet above will get you there. What it won't do on its own is survive a layout change, dodge IP blocking at scale, or hand you typed data you can pipe straight into a spreadsheet or database. That gap is what Webmotors Scraper on Apify closes: give it a query and a maxAds, get back clean JSON with parsed prices, FIPE comparison and seller details, priced at $0.15 per 1,000 listings with no monthly commitment.
Top comments (0)