AutoScout24 is Europe's largest car marketplace, with more than 2 million live listings across 18 countries. That's a lot of pricing data sitting behind a search box.
This guide shows you four ways to scrape AutoScout24 with Python, ordered from easiest to hardest. You build and run all of them yourself. No paid scraping API, no black box.
What is AutoScout24 scraping?
Scraping AutoScout24 means pulling vehicle listing data straight from the site with code instead of copying it by hand. You get prices, mileage, year, fuel type, transmission, and seller details as structured records. The reliable way is to read the embedded JSON the page already ships, then handle Akamai's bot checks with a real browser only when you have to.
Most tutorials skip the first part. They parse HTML classes that break every release. We'll start with the durable approach instead.
Why scrape AutoScout24?
People scrape AutoScout24 for a handful of concrete reasons, and they all come back to pricing.
Dealers watch competitor prices across markets. A BMW 3 Series can sit €2,000 cheaper in Italy than in Germany, and that gap is only visible if you're pulling both markets.
Analysts model depreciation. Feed a few thousand listings into a notebook and you can see exactly how mileage and age bend resale value for a given model.
Buyers build alerts. Rather than refreshing a search page, a scraper pings you the moment a car matching your filters shows up under budget.
Whatever the reason, the shape of the job is the same: pull structured listings at some cadence and store them. Let's set up the tools.
What you'll need
Before any code, get these in place:
- Python 3.9 or newer
requests,beautifulsoup4, andlxmlfor the lightweight methodsplaywrightandnodriverfor the browser methods- Basic comfort reading JSON in your browser's dev tools
Install the core packages in one line:
pip install requests beautifulsoup4 lxml
We'll install the browser tools in their own sections so you only grab what you use.
How AutoScout24 is built (read this first)
AutoScout24 runs on Next.js. That single fact decides your whole strategy.
Every page ships a <script id="__NEXT_DATA__"> tag containing the full page payload as JSON. The prices and specs you see on screen are already in that blob before any JavaScript runs.
So you rarely need to parse HTML at all. You grab the script tag, load the JSON, and read clean fields.
There's a catch, and it's the reason plain scrapers fail. AutoScout24 sits behind Akamai Bot Manager, which fingerprints your TLS handshake, sets an _abck cookie, and throws 403s at anything that looks automated.
Next.js also hashes its CSS class names. A selector like Price_price__APlgs looks stable until the next deploy renames it to something else. Build a scraper on those classes and it rots within a release cycle.
The JSON payload doesn't have that problem. Its shape stays consistent far longer than any class name. That's why we lead with it.
Want to see it yourself before writing code? Open any AutoScout24 search page, hit F12, and go to the Console tab.
Paste this and press enter:
JSON.parse(document.getElementById("__NEXT_DATA__").textContent).props.pageProps
You'll get an expandable tree of every listing on the page. Spend two minutes here noting the field names for your market. It'll save you an hour of guessing later.
The 4 methods at a glance
| Method | How you fetch | How you parse | Detection risk | Best for |
|---|---|---|---|---|
| 1. Embedded JSON | requests |
__NEXT_DATA__ |
Medium | Clean data, small to medium jobs |
| 2. HTML parsing | requests |
CSS + data-testid |
Medium | Quick one-offs, missing JSON fields |
| 3. Playwright | Headless browser | __NEXT_DATA__ |
Lower | When plain requests get 403s |
| 4. Nodriver | Stealth browser | __NEXT_DATA__ |
Lowest | When Akamai catches Playwright |
Start at the top. Only move down when the site forces you to.
Method 1: Grab the embedded JSON with requests
This is the method almost every tutorial hides behind a paid API. It's the easiest and the most durable, so it goes first.
The idea: fetch the page with a normal request, find the __NEXT_DATA__ script, and read the listings out of JSON.
Set up a believable session
Akamai reads your headers before anything else. A bare requests call with no headers gets flagged instantly.
import requests
def make_session():
session = requests.Session()
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/122.0.0.0 Safari/537.36"
),
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
# Sec-Fetch headers make the request look like a real navigation
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
})
return session
The de-DE language header matters. You're hitting a German-first site, so speaking German lowers suspicion.
Pull and load the JSON
Now find the script tag and parse it. Note that we don't hardcode a deep key path, because that path shifts between releases.
import json
from bs4 import BeautifulSoup
def get_next_data(html):
soup = BeautifulSoup(html, "lxml")
tag = soup.find("script", id="__NEXT_DATA__")
if not tag:
return None
return json.loads(tag.string)
If get_next_data returns None, you were either blocked or the page shape changed. That's your signal to jump to Method 3.
Find the listings inside the blob
The page data lives under props.pageProps. Instead of guessing the exact key, walk the structure and grab the first list of listing-shaped objects.
def find_listings(node):
"""Recursively search the JSON for the listings array."""
if isinstance(node, list):
# A listing usually has both a price and a vehicle id
if node and isinstance(node[0], dict) and "price" in node[0]:
return node
for item in node:
found = find_listings(item)
if found:
return found
elif isinstance(node, dict):
for value in node.values():
found = find_listings(value)
if found:
return found
return None
This survives key renames because it looks for the shape of a listing, not a fixed address. Open __NEXT_DATA__ in your dev tools console once to confirm the fields on your target market.
Tie it together
Here's the full method 1 run against BMW listings in Germany.
def scrape_json(url):
session = make_session()
resp = session.get(url, timeout=20)
if resp.status_code != 200:
print(f"Blocked or error: HTTP {resp.status_code}")
return []
data = get_next_data(resp.text)
listings = find_listings(data) if data else []
return listings
if __name__ == "__main__":
url = "https://www.autoscout24.com/lst/bmw?atype=C&cy=D&sort=standard"
cars = scrape_json(url)
print(f"Found {len(cars)} listings")
When this works, you get richer data than the visible page shows, including fields AutoScout24 never renders. When it 403s, you've learned Akamai is challenging your IP, and it's time for a browser.
Clean up the records and save them
Raw listing objects carry more nesting than you want. Flatten each one to the fields you'll actually use.
def clean_listing(raw):
"""Pull the useful fields out of one raw listing dict."""
return {
"make": raw.get("make"),
"model": raw.get("model"),
"price": raw.get("price"),
"mileage": raw.get("mileage"),
"year": raw.get("firstRegistration"),
"fuel": raw.get("fuelType"),
"url": raw.get("url"),
}
Field names vary by market, so confirm them against your own __NEXT_DATA__ dump. The .get() calls mean a renamed key returns None instead of crashing the run.
Then write the cleaned records to CSV:
import csv
def save_csv(listings, filename="autoscout24.csv"):
if not listings:
return
rows = [clean_listing(item) for item in listings]
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
print(f"Saved {len(rows)} rows to {filename}")
For anything you re-scrape on a schedule, use the listing url as a unique key so repeat runs don't pile up duplicates.
Method 2: Parse the HTML with BeautifulSoup
Sometimes you want a field that isn't in the JSON, or the JSON shape changed and you need a quick fix today. That's when you parse the rendered HTML directly.
Be honest with yourself about the tradeoff. This is the brittle path, so lean on data-testid attributes, which AutoScout24 keeps stable for its own tests.
def parse_html_listings(html):
soup = BeautifulSoup(html, "lxml")
cars = []
for article in soup.select("article[class*='cldt-summary-full-item']"):
# data-testid survives longer than hashed class names
price = article.select_one("[data-testid='regular-price']")
mileage = article.select_one("[data-testid='VehicleDetails-mileage_road']")
year = article.select_one("[data-testid='VehicleDetails-calendar']")
cars.append({
"price": price.get_text(strip=True) if price else None,
"mileage": mileage.get_text(strip=True) if mileage else None,
"year": year.get_text(strip=True) if year else None,
})
return cars
Notice the class*= selector. It matches a partial class name, so a hash change on the suffix won't break it outright.
Still, treat this as a stopgap. If your data-testid selectors start returning None across the board, the markup moved, and Method 1's JSON is the better long-term home.
Method 3: Render with Playwright
When plain requests hit a wall of 403s, run a real browser. Playwright drives Chromium, passes Akamai's JavaScript challenge, and lets the _abck cookie get set the normal way.
Install it and its browser binary:
pip install playwright
playwright install chromium
The second command downloads the actual Chromium build Playwright controls.
Launch a less-detectable browser
A default headless launch leaks the navigator.webdriver flag. Strip it, and set a realistic viewport and timezone.
from playwright.sync_api import sync_playwright
def make_context(pw):
browser = pw.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled"],
)
context = browser.new_context(
viewport={"width": 1920, "height": 1080},
locale="de-DE",
timezone_id="Europe/Berlin",
)
# Hide the automation flag before any page script reads it
context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
)
return browser, context
The add_init_script runs before the page's own JavaScript, so the flag is already gone when Akamai checks for it.
Reuse the JSON extractor
Here's the payoff for leading with Method 1: once the browser renders the page, you pull the same __NEXT_DATA__ JSON. One parser, two fetch strategies.
def scrape_playwright(url):
with sync_playwright() as pw:
browser, context = make_context(pw)
page = context.new_page()
page.goto(url, wait_until="networkidle", timeout=30000)
html = page.content()
browser.close()
data = get_next_data(html)
return find_listings(data) if data else []
wait_until="networkidle" gives Akamai's challenge time to resolve before you read the HTML. If you still get an empty result, the IP itself is flagged, which proxies fix.
Method 4: Go stealth with Nodriver
Akamai in 2026 can spot Playwright by its Chrome DevTools Protocol traffic. When that happens, Nodriver is the next step. It drives Chrome without the CDP signals that give automation away.
pip install nodriver
Nodriver manages its own browser, so there's no separate install step.
import asyncio
import nodriver as uc
async def scrape_nodriver(url):
browser = await uc.start()
page = await browser.get(url)
await page.sleep(3) # let the challenge clear
html = await page.get_content()
await browser.stop()
data = get_next_data(html)
return find_listings(data) if data else []
if __name__ == "__main__":
url = "https://www.autoscout24.com/lst/audi/a4?atype=C&cy=D"
cars = asyncio.run(scrape_nodriver(url))
print(f"Found {len(cars)} listings")
The sleep(3) is deliberate. Akamai's challenge can take a couple of seconds to run, and reading the HTML too early gives you an interstitial page instead of listings.
Nodriver is slower than plain requests, but it's the most reliable option when detection is aggressive. Keep it in reserve rather than reaching for it first.
Handling pagination
One page gives you 20 listings. AutoScout24 caps a single search at 20 pages, so 400 results per query.
Add a page parameter and loop, with a pause between requests.
import random, time
def scrape_all_pages(base_url, max_pages=20):
all_cars = []
for page in range(1, max_pages + 1):
sep = "&" if "?" in base_url else "?"
url = f"{base_url}{sep}page={page}"
cars = scrape_json(url)
if not cars:
break # blocked or out of results
all_cars.extend(cars)
time.sleep(random.uniform(3, 7)) # look human
return all_cars
To pull more than 400 records, split the search with tighter filters like price bands or registration year. Each narrowed search gives you a fresh set of 400.
Going deeper: individual car pages
Search results give you the summary. The full picture lives on each car's own page: service history, number of owners, options list, and every technical spec.
The good news is that detail pages are also Next.js. The same __NEXT_DATA__ extractor you already wrote works without changes.
def scrape_detail(url):
session = make_session()
resp = session.get(url, timeout=20)
if resp.status_code != 200:
return None
data = get_next_data(resp.text)
# detail data sits under pageProps, not in a list
return data["props"]["pageProps"] if data else None
Feed it a listing url from your search results and you get the complete record for that car.
Pace yourself here. A detail page per listing means 400 extra requests per search, so keep your delays generous and rotate IPs if you're doing this at volume.
When you need proxies
Akamai blocks datacenter IPs fast. If you're scraping past a few hundred pages, one IP won't cut it, and cheap datacenter ranges get flagged on sight.
Residential IPs from the country you're targeting work best, since a German listing search from a German home IP looks exactly like a real buyer. Roundproxies rotating residential pools handle this for AutoScout24's .de, .at, and .ch domains.
Wiring a proxy into requests is two lines:
def make_session_with_proxy(proxy_url):
session = make_session()
session.proxies = {"http": proxy_url, "https": proxy_url}
return session
For Playwright, pass the proxy at launch instead:
browser = pw.chromium.launch(
headless=True,
proxy={"server": "http://your-proxy:port",
"username": "user", "password": "pass"},
)
Match the proxy country to the domain you're scraping. Mismatches are a quiet way to get flagged.
Common errors and fixes
403 Forbidden on every request. Akamai flagged your fingerprint or IP. Move from Method 1 to Playwright or Nodriver, and add residential proxies. Our 403 Forbidden guide breaks down the causes.
__NEXT_DATA__ is missing. You got an interstitial challenge page, not the listing page. Switch to a browser method and wait for the network to settle.
All selectors return None. The HTML changed and your Method 2 classes are stale. Move that logic to the JSON in Method 1.
Empty list after a few hundred requests. You hit a soft IP block or the 400-result cap. Rotate proxies and split the search with filters.
HTTP 429. Too many requests, too fast. Add longer random delays and back off exponentially before retrying.
Here's a retry wrapper that doubles the wait after each failure, so a brief block doesn't kill your whole run.
import time
def fetch_with_backoff(url, session, max_retries=3):
delay = 5
for attempt in range(max_retries):
resp = session.get(url, timeout=20)
if resp.status_code == 200:
return resp
if resp.status_code in (429, 403):
time.sleep(delay)
delay *= 2 # 5s, then 10s, then 20s
else:
return None
return None
Three retries with doubling delays clears most soft rate limits. If you're still blocked after that, the problem is the IP, not the pace, and you need proxies.
Which method should you use?
Don't overthink it. Follow the ladder.
Use Method 1 for anything small to medium. It's fast, the data is clean, and it survives site updates. This should be your default.
Reach for Method 2 only when you need a field the JSON doesn't carry, or you need a fix in the next ten minutes.
Move to Method 3 the moment plain requests start returning 403s. A real browser clears most of Akamai's checks.
Save Method 4 for the hardest cases, where even Playwright gets caught. It's the slowest but the stealthiest.
For scaling any of them, add rotating residential proxies. Speed and staying unblocked are the same problem at volume.
A note on responsible scraping
Scrape the public data, not the site into the ground. Check robots.txt, keep your request rate reasonable, and add real delays.
Prices and specs on public listings are fair game for research and monitoring. Personal seller contact details are not.
Reusing data commercially can also cross AutoScout24's terms. When in doubt about commercial use, ask a lawyer, not a blog.
FAQ
Is scraping AutoScout24 legal? Collecting public listing data for personal research is generally fine. Republishing it or using it commercially may breach their terms, so check your specific case.
Why do my requests get blocked instantly? Datacenter IPs and bare headers trip Akamai on the first request. Use realistic headers, and residential proxies when you scale. Our bot detection guide covers the signals it reads.
Do I really not need a browser? For most jobs, no. Method 1's __NEXT_DATA__ approach pulls everything from a single request. You only need a browser once Akamai starts challenging your IP.
How many listings can I pull per day? Each search caps at 400 results. With proxy rotation and sensible delays, thousands of listings a day is realistic. Push too fast and you'll get blocked.
Which country domains work the same way? All of them. .de, .at, .ch, .it, and the rest all run the same Next.js setup, so the JSON method carries over. Just match your proxy country to the domain.
Can I scrape historical prices? No. AutoScout24 only shows current listings. To build history, scrape on a schedule and store the results yourself over time.
Wrapping up
You now have four ways to scrape AutoScout24, from a single clean request to a full stealth browser. The order is the point.
Start with the embedded JSON, and only add weight when the site pushes back.
Method 1 handles most of what people actually need. Keep Playwright and Nodriver in your pocket for the days Akamai gets serious, and add residential proxies once you scale past a few hundred pages.
If you're scraping other European marketplaces next, the same Next.js JSON trick works on plenty of them. Here's the same approach applied to Immobilienscout24.