# Willhaben Scraper — Marktplatz, Autos, Immobilien & Jobs (`shluca/willhaben-scraper`) Actor

Scrape willhaben.at, Austria's biggest marketplace: Marktplatz, Autos, Immobilien & Jobs. Search by keyword or willhaben URL, look up ads by ID, or monitor listings for new ads and price drop. No browser, 100 listings per request.

- **URL**: https://apify.com/shluca/willhaben-scraper.md
- **Developed by:** [Luca Haid](https://apify.com/shluca) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.60 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## willhaben-scraper

The **fastest willhaben.at scraper on the Apify Store — and the best price among production-grade competitors** ($0.60/1k vs $0.89–1.00+ elsewhere; the handful of $0.05/1k listings are abandoned bait listings with 1–3 users). Search classifieds, look up ads by ID, and monitor listings for price drops, in one actor. Austria's biggest marketplace (kleinanzeigen.at's Austrian counterpart): ~95k+ marktplatz ads, 20k+ immo listings, cars, jobs.

### Why the price is low (and why that's not a red flag)

| | This actor | Typical browser-based scrapers |
|---|---|---|
| Requests per 100 listings | **1** | 30–300 |
| Traffic per listing | **~0.7 KB** | ~300 KB–1 MB |
| Proxy cost per 1k rows | **~$0.005** | ~$2–8 |
| Browser | none | yes |

We read the same JSON the willhaben mobile app consumes (`ad-search.willhaben.at`, 100 rows per request) and fall back automatically to the JSON embedded in willhaben.at web pages. No browser, no selector scraping — the data is a plain JSON document that would break willhaben's own frontend if it changed. That's why our low price is sustainable, not a race to the bottom.

### Features

- **Search** — keyword and/or any willhaben.at search URL (all categories: marktplatz, immobilien, cars, jobs, …). Category URLs are automatically upgraded to the fast mobile-API path **including all filter parameters already set in the URL** (price, rooms, area, state, category tree …).
- **Filters** — native `priceMin`/`priceMax`, `sellerType` (private/commercial), `sortBy` (newest / priceAsc / priceDesc), plus free passthrough of any willhaben filter param via `searchUrl`.
- **Ad lookup by ID** — one request per ad, full detail including all listing attributes.
- **Price-drop monitor** — scheduled runs report only changes since the last run: `new`, `priceDropped`, `priceIncreased`, `updated` (title/body edited), `reappeared` (relisted within 30 days), `delisted`. Zero-change runs exit cleanly (schedule-friendly).
- **Vertical attributes** — cars: mileage, year, fuel, transmission, owners, dealer flag. Immobilien (search **and** detail level): rooms, living area, €/m², floor + surface, building type, property type, ownership, energy HWB + class, rent net/gross, operating + additional costs, free area (balcony/garden) type and size.
- **Lead-gen option** — `includeSellerData` opt-in adds seller identity (name, company, address, coordinates, org ID, type), contact attributes on detail lookups (phone, website, contact person), `enrichSellerProfile` adds profile + **public trust signals** (star rating, rating count, reply time, member since — per unique seller), and `sellerContactFromDetails` pulls contact data into search runs (up to 10 detail fetches).
- **Agents (MCP / Actor-as-tool)** — small input, typed output, budget-stop via pay-per-event; a $5 prepaid budget buys ~5,800 rows.

### GDPR

**Listings data only.** Ad ID, title, description, price, listing attributes, district/postcode location, image URLs, listing URL. No seller names, no phone numbers, no emails, no street addresses, no coordinates, no profile URLs — these fields are stripped at the parser level and can never reach the dataset. Commercial-vs-private is a boolean flag only.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | select | `search` | `search` (keyword/URL), `ids` (ad IDs), `monitor` (diff since last run) |
| `keyword` | string | — | Free-text search, e.g. `fahrrad 28 zoll` |
| `vertical` | select | `marktplatz` | `marktplatz`, `immobilien`, `fahrzeuge` (used cars), `jobs` (web platform with salary/company data) |
| `category` | integer | — | Numeric willhaben category id → API-only numeric search: `3`+`2` = used cars, `2`+`131` = rentals, `1`+`1` = jobs |
| `searchUrl` | string | — | Any `https://www.willhaben.at/iad/...` search URL — fastest path, gets the API upgrade automatically |
| `adIds` | array | — | Mode `ids`: ad IDs (positive integers) |
| `maxItems` | integer | `30` | Max rows (cap 500) |
| `maxPages` | integer | `1` | Max pages (cap 10; 100 rows/page via API, 30 via web fallback) |
| `priceMin` / `priceMax` | integer | — | Price range filter (EUR) |
| `sellerType` | select | `all` | `all`, `private`, `commercial` |
| `sortBy` | select | `relevance` | `relevance`, `newest`, `priceAsc`, `priceDesc` |
| `changesOnly` | boolean | `true` | Monitor: emit only changes |
| `includeSellerData` | boolean | `false` | **GDPR opt-in**: add seller name/company/address/coordinates/orgId/sellerType (see below) |
| `enrichSellerProfile` | boolean | `false` | Needs `includeSellerData`: + profile fields and public trust signals (rating, ratings count, reply time) — 2 req per unique seller, cap 20/run |
| `sellerContactFromDetails` | boolean | `false` | Needs `includeSellerData`: search/monitor runs fetch up to 10 detail pages to extract public contact attrs (phone, website) |
| `apiToken` / `visitorId` | string | built-in | Mobile-API credentials override (only if runs start falling back to the web path) |

### Seller data (opt-in, GDPR)

By default this actor outputs **listings data only** — safe for EU deployments, no personal data beyond the listing itself.

Set `includeSellerData: true` to also receive what the public listing payload carries per ad: `sellerName`, `orgName`, `address`, `coordinates`, `orgId`, `orgUuid`, `sellerType` (`private`/`commercial`); detail/ID lookups on commercial ads add `sellerPhone` and `sellerWebsite` (the seller's own public contact attributes). Add `enrichSellerProfile: true` for `sellerProfileName`, `sellerMemberSince`, `sellerStreet`/`District`/`Location`/`ImageUrl`, `sellerProfileUrl` and the seller's **public trust signals**: `sellerRating`, `sellerRatings`, `sellerReplyTime` (2 extra requests per unique seller, cap 20/run). In search runs, `sellerContactFromDetails: true` additionally fetches up to 10 detail pages to fill phone/website.

This is personal data under GDPR. The actor gives you the technical access; **your use case determines your lawful basis** (legitimate interest for B2B dealer contact data is common; mass-harvesting private sellers' data is not). If you run this for lead generation, document your basis.

Jobs note: the jobs platform (karriere stack) is a separate willhaben product — job searches run on the web path (1 request per 30 listings) and ignore `category`; job IDs don't work in `ids` mode.

Not included: private sellers' phone numbers — willhaben gates those behind a logged-in session (per-ad reveal endpoint), which this actor does not automate. Commercial sellers' public contact attributes (phone/website on their own listing detail page) are included with the opt-in.

### Output (dataset)

```json
{
  "adId": "1257485200",
  "title": "Kalkhoff Damenfahrrad 28 Zoll 24 Gang",
  "body": "Fahrrad von Kalkhoff. Zustand lt Fotos. …",
  "priceAmount": 250,
  "priceDisplay": "€ 250",
  "location": "Wien, 10. Bezirk, Favoriten",
  "district": "Favoriten",
  "state": "Wien",
  "postcode": "1100",
  "country": "Österreich",
  "isPrivateSeller": true,
  "p2pEnabled": true,
  "isBumped": null,
  "vertical": "marktplatz",
  "images": ["https://cache.willhaben.at/mmo/0/125/748/5200_-1589485132_hoved.jpg"],
  "url": "https://www.willhaben.at/kaufen-und-verkaufen/d/kalkhoff-damenfahrrad-…-1257485200/",
  "published": "2026-10-19T20:56:58Z",
  "lastUpdated": "2026-10-19T20:56:58Z",
  "endDate": null,
  "scrapedAt": "2026-10-20T07:12:xx.xxxZ",
  "change": "priceDropped",
  "previousPrice": 280
}
```

`change` / `previousPrice` / `firstSeen` appear in monitor mode only.

All sections: `oldPrice`/`oldPriceDisplay`/`priceReductionDate` (price drops visible in search), `teaser`, `categoryIds`, `adTypeId`, `disposed`, `weeklyBump`, `activeUntil`.

Car rows add: `make`, `model`, `modelSpecification`, `vehicleType`, `condition`, `equipment`, `warranty(+Duration)`, `exteriorColour`, `doors`, `seats`, `wheelDrive`, `engineVolume`/`Cylinders`/`Effect`, `consumption`, `co2`, `emissionStandard`, `batteryCapacity`/`wltpRange` (EV), `leasing*` (rate/duration/residual/advance), `motorPriceTotal`/`motorBonus*`, `conditionReport`, `defectsLiability`.

Job rows add: `company`, `companyId`, `companyLogoUrl`, `position`, `salary` + `salaryTimeFrame`, `employmentModes`, `employmentTime`, `topJob`, `brandNew`, `isExpired`, `internalApplication`.

Immo rows add: `rooms`, `livingAreaSqm`, `usableAreaSqm`, `grossAreaSqm`, `totalAreaSqm`, `pricePerSqm`, `floor`, `floorSurface`, `buildingType(+Condition)`, `constructionYear`, `propertyType`, `ownershipType`, `heatingType`, `energyHwb`/`energyFgee`(+classes)/`energyEeb`, `rentNet`, `rentGross`, `operatingCostsNet`, `additionalCostsNet`, `deposit`, `commissionFee`, `availableFrom`/`Now`, `virtualTourUrl`, `freeAreaSqm`/`Type`, project/unit fields (`projectId`, `unitTitle`, `priceSuggestion`…).

**Attachments (Expose PDFs):** immo rows carry an `attachments` array (name + URL, up to 10) — populated from the `INFOLINK/URL`/`INFOLINK/NAME` attribute pairs that agencies use to attach Expose PDFs (verified live: 7 PDFs on a neubauprojekt; works on search and detail rows). Detail lookups auto-detect the section from the ad's `verticalId`, so immo IDs return the full immo field set. 3D tours populate `virtualTourUrl`.

**Complete coverage:** every attribute we don't map to a typed field still lands in each row's `attributes` object (name → value) — nothing is lost. Seller-sensitive attributes are excluded from `attributes` unless `includeSellerData` is on. Set `includeRawAttributes: false` for lean rows. Coverage audited 2026-10-20 across marktplatz/zu-verschenken/immobilien-kauf/-miete/gebrauchtwagen (100-row attribute unions + sampled detail pages; tool: `tools/attribute-audit.mjs`).

### Usage examples

- LKW driver jobs with salary: `{"mode": "search", "vertical": "jobs", "keyword": "lkw", "maxItems": 50}`
- Cheapest iPhones: `{"mode": "search", "keyword": "iphone", "maxItems": 50}`
- Used cars ≥€3k, cheapest first: `{"mode": "search", "vertical": "fahrzeuge", "category": 2, "keyword": "golf", "priceMin": 3000, "sortBy": "priceAsc", "maxItems": 100}`
- Price-watch a bike model (schedule hourly): `{"mode": "monitor", "keyword": "pucky fahrrad", "maxItems": 100}`
- Cars in a specific category (paste the URL from your browser): `{"mode": "search", "searchUrl": "https://www.willhaben.at/iad/kaufen-und-verkaufen/marktplatz/fahrraeder-radsport/fahrraeder-4552", "maxItems": 200}`
- One ad by ID: `{"mode": "ids", "adIds": [1257485200]}`

### Pricing

Pay per event, one charge per result row.

- **Launch price: $0.60 per 1,000 results** for early users.
- After the launch period the price rises to **$0.85 per 1,000 results**. Apify notifies users 14 days before any price increase.

Even at $0.85 it's the cheapest willhaben scraper on the Store (next: $0.89–1.00/1k).

### One more thing

If this actor saves you money or time, a short Store review genuinely helps a small developer compete with the big shops here. Thank you!

### Legal & ToS note

Public listings data via the mobile app's public search API and the public website, no login, no sold/profile/personal data. Rate-limited politely (500 ms between requests). Scraping may be against willhaben's Terms of Service; use for price research, market analysis and personal deal-watching. Don't resell personal data — this actor doesn't output any (see GDPR above).

# Actor input Schema

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

search: run a willhaben search. ids: fetch specific ads by ID (max $0.03 per run at 30 ids). monitor: same search + diff against the previous run's state, emitting only new/priceDropped/priceIncreased/delisted rows (schedule this mode for deal alerts).

## `keyword` (type: `string`):

Free-text search, e.g. "iphone 15" or "wohnung wien 30 m2". Used with vertical. Ignored when searchUrl is set. Example prefills: "iphone 15", "fahrrad", "wohnung wien".

## `vertical` (type: `string`):

Site section: marktplatz (default), immobilien, fahrzeuge (used cars), jobs (web platform /jobs/suche — own data stack, runs on the web path).

## `searchUrl` (type: `string`):

Full willhaben.at search URL copied from the browser, e.g. https://www.willhaben.at/iad/kaufen-und-verkaufen/marktplatz?keyword=fahrrad\&PRICE_FROM=100\&isUrgentOffer=true — any filters encoded in the URL are honored. Must be a www.willhaben.at URL.

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

willhaben ad IDs to fetch individually, e.g. \["1257485200", "1960751783"]. One request per ID, cap 30 IDs per run (max $0.03/run).

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

Maximum rows to push (and charge). 30 rows = one search page, ≈ $0.026 per run at list price. Keep small for cheap agent calls.

## `maxPages` (type: `integer`):

How many search result pages to walk (30 rows per page). maxPages × 1 request each.

## `changesOnly` (type: `boolean`):

Monitor mode: when true, unchanged ads are not emitted (new / priceDropped / priceIncreased / delisted only). When false, every current ad is emitted with its change label. A monitor run with zero changes exits cleanly with an empty result — schedule it and stay green.

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

Only listings with price >= this value.

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

Only listings with price <= this value.

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

Filter by seller type.

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

Result ordering (relevance = willhaben default).

## `category` (type: `integer`):

Willhaben numeric search id — switches to the API-only numeric form. Known: 3/2 Gebrauchtwagen (used cars), 2/131 Mietwohnung, 2/101 Eigentumswohnung, 5/303 Kaufen, 5/304 Zu verschenken. (Jobs ignore this — separate platform.)

## `includeSellerData` (type: `boolean`):

OFF by default (GDPR-safe listings-only). ON: adds sellerName/orgName/address/coordinates/orgId + sellerType (private/commercial) from the public listing payload. You are responsible for having a lawful basis for processing personal data in your use case.

## `enrichSellerProfile` (type: `boolean`):

Requires includeSellerData. Adds one API request per unique seller (max 20/run): sellerProfileName, sellerMemberSince, sellerActiveAds.

## `sellerContactFromDetails` (type: `boolean`):

Requires includeSellerData. In search/monitor modes, fetches up to 10 detail pages (1 request each) to extract public contact attributes (phone, website, contact person) from listings.

## `includeRawAttributes` (type: `boolean`):

Adds an `attributes` object per row with every listing attribute not mapped to a typed field (complete coverage; disable for lean output).

## `apiToken` (type: `string`):

X-WH-Application-Token for the mobile-app search API. The built-in app-level token usually works — set this only if runs fail and fall back to the slower web path.

## `visitorId` (type: `string`):

X-WH-Visitor-Id for the mobile-app search API. Only needed together with an API token override.

## Actor input object example

```json
{
  "mode": "search",
  "keyword": "fahrrad",
  "vertical": "marktplatz",
  "maxItems": 5,
  "maxPages": 1,
  "changesOnly": true,
  "sellerType": "all",
  "sortBy": "relevance",
  "includeSellerData": false,
  "enrichSellerProfile": false,
  "sellerContactFromDetails": false,
  "includeRawAttributes": true
}
```

# Actor output Schema

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

No description

# 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 = {
    "keyword": "fahrrad",
    "maxItems": 5
};

// Run the Actor and wait for it to finish
const run = await client.actor("shluca/willhaben-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 = {
    "keyword": "fahrrad",
    "maxItems": 5,
}

# Run the Actor and wait for it to finish
run = client.actor("shluca/willhaben-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 '{
  "keyword": "fahrrad",
  "maxItems": 5
}' |
apify call shluca/willhaben-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,shluca/willhaben-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/HlRJ8O8W4kFm4Yl9u/builds/z3o7JKtHbiFPUYp4y/openapi.json
