# mobile.de Car & Vehicle Scraper - Listings, Prices, Dealers (`fanndev/mobile-de-scraper`) Actor

Seven mobile.de scrapers in one - no login, no browser, no captcha. Search Germany's largest vehicle marketplace with 130 filters, read full vehicle pages, pull a dealer's whole stock, and get the legal imprint behind any listing. Every listing carries mobile.de's own price rating.

- **URL**: https://apify.com/fanndev/mobile-de-scraper.md
- **Developed by:** [Faisal Ahdan naufal](https://apify.com/fanndev) (community)
- **Stats:** 2 total users, 1 monthly users, 50.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## mobile.de Car & Vehicle Scraper — Listings, Prices, Dealers

Seven mobile.de scrapers in one Actor. No login, no browser, no captcha —
plain HTTP against mobile.de's own JSON API.

mobile.de is Germany's largest vehicle marketplace: about **1.57 million cars**
online at any moment, plus motorbikes, motorhomes, trucks, trailers,
construction machinery and e-bikes. This Actor reads all of it, and it reads
the one field that makes the data worth more than a price list:
**mobile.de's own price rating** — whether a vehicle is cheap or expensive
against the market for its exact spec, the six price thresholds it was judged
against, and how far off the market it sits.

***

### What you can pull

| Mode | What it returns |
|---|---|
| **`search`** | Vehicle listings for a filter set or a pasted mobile.de search URL, with facet counts |
| **`detail`** | The full vehicle page: seller's own description, complete equipment list, all attributes, image gallery, KBA type key |
| **`dealer_inventory`** | Every vehicle one seller currently has online, plus their headline count |
| **`dealer_contact`** | A seller's legal imprint — company, street address, phone, email, Handelsregister and VAT numbers, named directors |
| **`similar_ads`** | mobile.de's own "similar vehicles" set for a listing |
| **`market_count`** | Result count only, one request per filter set — for sizing a market without pulling listings |
| **`reference_data`** | The make and model id tree and every filter's allowed values |

***

### Quick start

**All 2018+ diesel VW Golfs under €20,000 from dealers, newest first:**

```json
{
  "mode": "search",
  "vehicleCategory": "Car",
  "makeId": "25200",
  "modelId": "14",
  "priceMax": 20000,
  "firstRegistrationMin": 2018,
  "fuelType": "DIESEL",
  "sellerType": "dealer",
  "sortBy": "age",
  "sortDescending": true
}
```

**Don't know the ids?** Run `reference_data` once with your vehicle category.
Each model comes back with the exact filter value already built:

```json
{"label": "3er Reihe (Alle)", "isGroup": true,  "modelGroupId": "21", "ms": "3500;;21;"}
{"label": "116",              "isGroup": false, "modelId": "2",       "ms": "3500;2;;"}
```

**Or just paste URLs** — but read the warning under *Filters that don't work*
first, because mobile.de's browser URLs mostly don't carry filters.

***

### The three things worth knowing before you run it

#### 1. Any single query tops out at ~2,000 listings

mobile.de will happily tell you a query has 1,574,052 matches. You can reach
**about 2,000 of them**. The result window is capped at item offset 1900 —
past that the API returns an empty list while still reporting
`hasNextPage: true`.

This is a server limit, not an Actor limit, and no amount of paging gets
around it. To harvest more than 2,000 vehicles, **slice the query and run the
slices**: by price band, registration year, single model, or postcode plus
radius. `search_summary.data.reachableTotal` tells you when a query is larger
than its own window, so you know to split it.

Every listing row carries the `offset` it came from, so slices stitch together
cleanly and de-duplicate on `id`.

#### 2. Filters that don't work — silently

mobile.de accepts several URL styles and **ignores most of them without an
error**. Same HTTP 200, same full page of cars, just the wrong ones:

| URL you might paste | What mobile.de actually returns |
|---|---|
| `/fahrzeuge/search.html?makeModelVariant1.makeId=3500` | **1,574,052** — every car on the site |
| `/s/auto/bmw/` (what the browser shows) | **1,574,052** — every car on the site |
| `/fahrzeuge/search.html?vc=Car&ms=3500;;;` | 136,896 — BMWs, correct |
| `/auto/bmw-3er-reihe.html` | 27,157 — correct, but ignores any parameter you add |

So a URL copied out of the address bar usually carries **no filter at all**.

SEO landing pages have a second problem: they ignore the paging parameters
too, serving a fixed 24 rows where `ps=200` comes back 20/24 duplicate. The
Actor works around this by probing the URL once, reading back the filters
mobile.de says it applied, and replaying them as a canonical search — same
result set, and it pages properly. You'll see that in the run log as
*"does not honour mobile.de's paging parameters; replaying its filters as …"*.

This Actor catches it. mobile.de echoes back the filters it really applied,
and the Actor diffs that against what it sent — anything dropped is named in
the run log as a warning and listed in
`search_summary.data.droppedFilters`. **If that field is non-empty, your
results are broader than you asked for.** Using the structured filter fields
instead of a pasted URL avoids the problem entirely.

#### 3. Model vs. series are two different fields

`modelId` is one model (`116`, `A4`, `Golf`). `modelGroupId` is a whole series
(BMW 3 Series, 1 Series). They are **not interchangeable** — mobile.de keeps
both in one number space but reads them from different slots, so a series id
put in `modelId` returns a *different model's* cars with no error:

```
modelId      = 20  →  2,250 cars, all BMW 525
modelGroupId = 20  → 17,102 cars, the BMW 1 Series
```

Give one or the other, never both. `reference_data` marks which is which and
hands you the ready-made value.

***

### Output

Every record shares one envelope, so a single dataset can hold listings,
detail, dealers, counts and diagnostics and still be joined on `id`:

```json
{
  "item_type": "listing",
  "id": "41691080604224",
  "data": {
    "title": "Volkswagen Golf VII Variant GTD BMT*Navi*BI-Xenon",
    "price": { "gross": "16.950 €", "grossAmount": 16950, "grossCurrency": "EUR" },
    "priceRating": {
      "rating": "REASONABLE_PRICE",
      "ratingLabel": "Fairer Preis",
      "thresholdLabels": ["11.400 €","14.800 €","16.000 €","17.800 €","19.100 €","21.200 €"],
      "vehiclePriceOffset": 52
    },
    "attr": {
      "loc": "Paderborn", "z": "33106", "fr": "02/2018", "ml": "126.871 km",
      "pw": "135 kW (184 PS)", "ft": "Diesel", "tr": "Schaltgetriebe",
      "c": "EstateCar", "emc": "Euro6", "pvo": "2"
    },
    "sellerId": 27941698,
    "type": "topAd",
    "numImages": 21
  },
  "metadata": {
    "scrapedAt": "2026-09-22T00:20:51Z",
    "mode": "search",
    "sourceUrl": "https://suchen.mobile.de/fahrzeuge/details.html?id=41691080604224",
    "rank": 1,
    "offset": 0
  }
}
```

mobile.de's fields are passed through close to verbatim — the site ships
changes without notice, and unlisted fields flow through rather than failing
the run.

**`data.type` is worth keeping.** mobile.de mixes paid placements into
organic results: `ad` is organic, while `topAd`, `eyecatcherAd` and `page1Ad`
are paid. In a typical page of 250 rows, roughly 34 are paid. If you are
computing market averages, filter on `type == "ad"`.

**Failures produce rows too.** A dead ad id emits
`{"item_type": "error", "data": {"_error": "not_found", ...}}` rather than
vanishing — a missing row looks like the Actor crashed, an error row tells you
the input was processed.

***

### Chaining modes

The modes are designed to feed each other:

1. `search` → every listing carries `data.sellerId`
2. those ids → `dealer_inventory` for each dealer's full stock
3. any of their ad ids → `dealer_contact` for the imprint (B2B lead lists)
4. any ad id → `detail` for the description text and full equipment list

***

### Performance and cost

`pageSize` defaults to **200**, which mobile.de honours. A full 2,000-listing
query therefore costs **10 HTTP requests**, not 100. The validation run in
this repo pulled 250 verified-unique listings in **3 requests**.

Paging uses mobile.de's item-offset parameter rather than its page number —
the page number strides a fixed 20 rows no matter what page size you ask for,
so page 2 at size 100 would re-deliver 80 rows you already had.

***

### Proxy

**Optional.** mobile.de gates on the TLS fingerprint, not the IP address —
this Actor's requests succeed from an ordinary unproxied address while a
plain Python HTTP client is refused from that same address.

A **residential proxy with country `DE`** is still the safer setting for long
runs, and makes prices, delivery options and regional availability match what
a German buyer sees. If a proxy is requested but cannot be set up, the run
continues directly and says so in the log.

***

### Limits and known behaviour

- **~2,000 listings per query.** Slice the query to go deeper.
- **`hasNextPage` is unreliable** — it stays `true` past the result window.
  The Actor stops on an empty page instead.
- **Unknown vehicle categories are not rejected by mobile.de** — it quietly
  searches cars instead. The Actor validates the 13 real categories locally
  and fails with a clear message.
- **No dealer-profile endpoint exists.** A dealer's imprint is only reachable
  through one of their ad ids, which is why `dealer_contact` takes ad ids.
- **`similar_ads` rows have their own schema** (price as `p`, plus `created`,
  `modified`, `hasDamage`, `images`) and are tagged `similar_listing`, not
  `listing`.
- **Two ad-id formats coexist** — 9-digit and 14-digit. Both are valid.
- **`accept-language` changes label text only**, not field names. `net`,
  `netAmount` and `vat` appear only on VAT-deductible listings.

***

### Blocking

mobile.de is behind Akamai Bot Manager. If it ever starts refusing this
Actor, the run log will say so explicitly and the dataset will carry a
`{"_error": "blocked"}` row. The Actor rotates through six browser TLS
profiles before giving up. The fix in that case is a newer `curl_cffi`
release, not a configuration change.

There is no captcha solving, no login and no session token in this Actor, by
design.

***

### Legal note

This Actor reads only publicly served, unauthenticated listing pages. The
imprint data returned by `dealer_contact` is published by sellers for
statutory disclosure (§5 DDG). Using it for unsolicited marketing is
separately regulated under the UWG — check your obligations before building
outreach on it, and respect mobile.de's Terms of Use for your jurisdiction.

Technical detail, the full recon record and every trap found while building
this are in [`CRAWLING_METHOD.md`](CRAWLING_METHOD.md).

# Actor input Schema

## `mode` (type: `string`):

search = vehicle listings for a filter set or a pasted mobile.de search URL. detail = the full vehicle page for an ad, including the seller's description text and the complete equipment list. dealer\_inventory = every vehicle one seller currently has online. dealer\_contact = a seller's legal imprint (company, street address, phone, Handelsregister and VAT numbers), which on mobile.de is only reachable through one of their ads. similar\_ads = mobile.de's own 'similar vehicles' set for an ad. market\_count = result counts only, one request per filter set, for sizing a market without pulling listings. reference\_data = the make and model id tree plus every filter's allowed values, which is what you need to turn 'BMW 3 Series' into the ids the search filters take.

## `searchUrls` (type: `array`):

Paste mobile.de search result URLs to scrape them as-is. Used by the search and market\_count modes, and takes precedence over the filter fields below. IMPORTANT: mobile.de honours only its short filter codes (vc, ms, p, ml, fr, ...). A URL built on the legacy makeModelVariant1.makeId= parameters, and the pretty /s/auto/<make>/ links the site shows in the address bar, both return every car on the site rather than the ones you filtered for. mobile.de gives no error when this happens, so the actor compares what it sent against the filters the API echoes back and writes a droppedFilters warning into the run log and the search\_summary record. SEO landing pages such as /auto/bmw-3er-reihe.html do filter correctly, but ignore any extra parameters you add to them.

## `vehicleCategory` (type: `string`):

Which mobile.de marketplace to search. mobile.de does not reject an unknown category - it quietly searches Cars instead and returns the full car count - so the actor validates this value before sending it.

## `makeId` (type: `string`):

mobile.de's numeric id for the manufacturer, for example 3500 for BMW, 1900 for Audi, 25200 for Volkswagen. Run the reference\_data mode once to get the full list with names. Names are not accepted here because mobile.de's search API only takes ids.

## `modelId` (type: `string`):

mobile.de's numeric id for a SINGLE model, such as 116 or 318 - not for a whole series. Only unique within a make, so it must be given together with Make ID. Leave empty to search every model of the make. Use Model Group ID instead when you want a whole series. Run the reference\_data mode to get both, already paired with the exact filter value to use.

## `modelGroupId` (type: `string`):

mobile.de's numeric id for a whole series, such as 21 for the BMW 3 Series or 20 for the 1 Series. This is a SEPARATE field from Model ID and the two are not interchangeable: mobile.de keeps series and single models in one number space but reads them from different slots, so putting a series id in Model ID silently returns a different model's cars with no error at all. Give one or the other, never both. The reference\_data mode marks which is which.

## `searchQuery` (type: `string`):

Free-text search across listing titles and descriptions, for example 'Golf GTI Clubsport'. Combines with the other filters.

## `priceMin` (type: `integer`):

Gross consumer price floor in euros. Leave empty for no floor.

## `priceMax` (type: `integer`):

Gross consumer price ceiling in euros. Leave empty for no ceiling. Price bands are also the most practical way to slice a query that is larger than mobile.de's ~2,000-row depth ceiling.

## `mileageMin` (type: `integer`):

Odometer floor in kilometres.

## `mileageMax` (type: `integer`):

Odometer ceiling in kilometres.

## `firstRegistrationMin` (type: `integer`):

Earliest year of first registration, for example 2018.

## `firstRegistrationMax` (type: `integer`):

Latest year of first registration.

## `powerMin` (type: `integer`):

Engine power floor in kilowatts, not PS. 100 kW is roughly 136 PS.

## `powerMax` (type: `integer`):

Engine power ceiling in kilowatts, not PS.

## `fuelType` (type: `string`):

Restrict to one fuel type. The reference\_data mode lists every value mobile.de accepts for the chosen vehicle category.

## `transmission` (type: `string`):

Restrict to manual, automatic or semi-automatic gearboxes.

## `condition` (type: `string`):

New vehicles, used vehicles, or leave empty for both.

## `sellerType` (type: `string`):

Restrict to commercial dealers or to private sellers. Leave empty for both.

## `includeDamaged` (type: `boolean`):

Turn off to exclude vehicles listed as damaged or unrepaired. mobile.de includes them by default in a plain search, though its own SEO landing pages exclude them.

## `country` (type: `string`):

Two-letter country code of the seller, for example DE, AT, NL, IT. Leave empty for all countries.

## `zipCode` (type: `string`):

German postcode to search around. Only has an effect together with Radius.

## `radiusKm` (type: `integer`):

Search radius in kilometres around the postcode. Postcode plus radius is a good way to slice a query that is too large for mobile.de's ~2,000-row depth ceiling.

## `maxDaysOnline` (type: `integer`):

Only vehicles first published within this many days. Set to 1 to poll for new listings.

## `sortBy` (type: `string`):

How mobile.de orders the results. Sorting changes which ~2,000 listings are reachable on a query larger than the depth ceiling, so 'Listing age' plus descending is the right choice for monitoring new stock.

## `sortDescending` (type: `boolean`):

Reverse the sort order. Combine with 'Listing age' to put the newest listings first.

## `adIds` (type: `array`):

mobile.de ad ids for the detail, dealer\_contact and similar\_ads modes, for example 446326880. The numeric id at the end of any mobile.de vehicle URL.

## `adUrls` (type: `array`):

Full mobile.de vehicle URLs, as an alternative to Ad IDs, for the detail, dealer\_contact and similar\_ads modes. Both /fahrzeuge/details.html?id=... and /auto-inserat/<slug>/<id>.html forms are accepted.

## `sellerIds` (type: `array`):

mobile.de numeric seller ids for the dealer\_inventory mode. Every listing record this actor produces carries the seller's id in data.sellerId, so a search run feeds this field directly.

## `maxItems` (type: `integer`):

Stop after this many records across the whole run. Leave empty to take everything the query can reach. Note that mobile.de caps any single query at roughly 2,000 listings no matter how many matches it reports.

## `pageSize` (type: `integer`):

How many listings to pull per HTTP request, up to 200. The default of 200 is the efficient setting: mobile.de honours it, so a full 2,000-listing query costs 10 requests instead of 100.

## `language` (type: `string`):

Language for mobile.de's localised labels (price text, equipment names, condition wording). The underlying vehicle data is the same either way.

## `requestDelaySecs` (type: `string`):

Pause between HTTP requests. The default 0.4s was comfortable during recon - mobile.de never rate-limited this API - but raise it for very long runs.

## `maxRetries` (type: `integer`):

How many times to retry a failed request before writing a diagnostic row and moving on. Retries use exponential backoff.

## `timeoutSecs` (type: `integer`):

Per-request timeout.

## `proxyConfiguration` (type: `object`):

Optional. mobile.de gates on the TLS fingerprint rather than on the IP address, so this actor works without a proxy. A RESIDENTIAL proxy with country DE is still the safer setting for long runs, and makes prices and regional availability match what a German buyer sees.

## Actor input object example

```json
{
  "mode": "search",
  "searchUrls": [],
  "vehicleCategory": "Car",
  "includeDamaged": true,
  "sortDescending": false,
  "adIds": [],
  "adUrls": [],
  "sellerIds": [],
  "pageSize": 200,
  "language": "de",
  "requestDelaySecs": "0.4",
  "maxRetries": 4,
  "timeoutSecs": 45,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Every vehicle listing, vehicle detail, dealer inventory row, dealer imprint, market count, reference record and diagnostic from this run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchUrls": [],
    "adIds": [],
    "adUrls": [],
    "sellerIds": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("fanndev/mobile-de-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchUrls": [],
    "adIds": [],
    "adUrls": [],
    "sellerIds": [],
}

# Run the Actor and wait for it to finish
run = client.actor("fanndev/mobile-de-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchUrls": [],
  "adIds": [],
  "adUrls": [],
  "sellerIds": []
}' |
apify call fanndev/mobile-de-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fanndev/mobile-de-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/IlCwz4Xs5bMvX4Uxr/builds/QFLruHHhq90oj1mKK/openapi.json
