# Willhaben.at Scraper — Austrian Property Listings, Photos, Geo (`skubadev/willhaben-scraper`) Actor

Scrape Willhaben.at property listings across Austria - Vienna, Graz, Linz, Salzburg, Innsbruck and every other city. 32 fields per listing: price, price per m2, area, rooms, floor, full geo with coordinates, agency, and up to 3 preview photo URLs.

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

## Pricing

from $1.49 / 1,000 listings

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Willhaben.at Scraper — Austrian Property Listings, Photos, Geo

Scrape property listings from **Willhaben.at**, the largest marketplace in
**Austria** — apartments, houses and plots, **for sale** and **for rent**, in
Vienna, Graz, Linz, Salzburg, Innsbruck and every other Austrian town.

32 fields per listing, including **latitude and longitude**, price per m²,
floor, agency and up to three preview photo URLs. No login, no cookies,
no browser needed.

### Empty results, and why you will not get them here

A scraper built on CSS selectors fails quietly. When Willhaben changes its markup,
or answers an automated request with a block page, you do not get an error — you
get an empty table, or a column of empty values, and you find out later.

This Actor is built the other way round:

- it reads the data **Willhaben already renders into the page**, so a redesign of
  the visual layout does not change what it returns;
- it goes through **residential proxies** with polite delays and retries every page;
- at the end of every run it prints **how many pages failed and how many rows it
  skipped**, and warns you when a promised field came back mostly empty. You never
  have to guess whether the file is complete;
- you pay **per listing returned**. A run that finds nothing costs you the Actor
  start fee of $0.00005 and nothing else.

### Quick Start

1. Open [willhaben.at](https://www.willhaben.at/iad/immobilien), set your filters
2. Copy the address of the **results page** (not a single listing)
3. Paste it into `searchUrls` and press **Start**

The default input works out of the box: apartments for sale across Austria.

```json
{
  "searchUrls": ["https://www.willhaben.at/iad/immobilien/eigentumswohnung/eigentumswohnung-angebote"],
  "maxItems": 1000
}
```

### Why this one does not break

Most scrapers read the rendered page with CSS selectors. When the site changes
its markup, they silently return nothing.

This Actor reads the **data the server already renders into the page** as
structured JSON. There are no CSS selectors anywhere in the code. The listing
array is located **by its shape** — the set of fields every record must have —
rather than by a fixed path, so a framework upgrade that moves the data does
not break extraction.

Two more consequences:

- **No browser.** Plain HTTP requests, so runs are fast and compute is cheap
- **Typed fields.** Numbers arrive as numbers, dates as ISO timestamps,
  coordinates as two separate numbers

A failed page is retried three times with backoff and never aborts the run.
A malformed listing is skipped, not the page around it. Every run writes a
`RUN_STATS` record with the real failure rate — you can audit it yourself.

### Output

One row per listing:

| Field | Notes |
|---|---|
| `id`, `url` | Stable listing id and direct link |
| `type` | `Wohnung`, `Haus`, `Grundstück`, … as the portal labels it |
| `title` | Listing headline |
| `price`, `currency`, `marketingType` | EUR. `marketingType` is `BUY` or `RENT` — rental search URLs work too, and there `price` is the monthly rent |
| `priceForDisplay` | Price exactly as the portal shows it |
| **`pricePerSqm`** | Taken from the portal, or calculated when missing |
| `livingSpace`, `terrainArea` | m² |
| `rooms`, `floor` | Numbers, not strings |
| `street`, `postcode`, `city`, `district`, `province` | Full Austrian geo. Vienna is split properly: `city` is `Wien`, `district` is `13. Bezirk, Hietzing` |
| **`latitude`, `longitude`** | WGS84, ready for a map or a spatial join |
| `country` | ISO 3166-1 alpha-2 — `AT` for Austrian listings, and the real code for the foreign ones Willhaben carries |
| `realtorCompany`, `realtorId` | Agency — see below |
| `isPrivateOwner`, `sellerLabel` | Whether the seller is a private person |
| `status` | Advert status as the portal reports it |
| **`imageUrls`**, `imagesCount` | Full-size photo URLs — **up to 3 per listing**, see below |
| `publishedAt` | The timestamp the portal shows — **not the first publication date**, see below |
| `daysOnMarket` | Days since `publishedAt`, with the same caveat |
| `sourceUrl`, `scrapedAt` | |

### Two limits I would rather you knew before you buy

**Photos: up to three per listing.** This Actor reads the structured data the
search page already carries, which is why it is fast and cheap and does not need
a browser. That page carries at most three preview images per advert. Measured on
1,500 listings on 1 September 2026: 1,471 had exactly 3. If you need every photo
of a flat, you need a scraper that opens each advert page, and it will cost you
roughly one request per listing instead of one per thirty.

**`publishedAt` is the portal's timestamp, not the first publication date.**
Sellers on Willhaben can push an advert back to the top of the list, and the
portal then shows the new time. In that same 1,500-listing sample every timestamp
fell inside one 5½-hour window, every one landed exactly on a quarter hour, and
`daysOnMarket` was `0` in all 1,500 rows. Fifteen hundred genuinely new Austrian
flats in five and a half hours is not plausible. So read this field as *"last
time the seller pushed it up"* and do not build a time-on-market analysis on it.

I would rather lose a sale here than have you find this out after paying.

### The three things that go wrong with property scrapers

These are the complaints you will find under other property scrapers in this Store.

**"It stopped working."** This Actor reads the structured data the server already
puts inside the page, not CSS selectors, so a visual redesign does not break
extraction. A health check runs every morning, so a real break is found before a
customer runs into it.

**"Not fetching all records."** Every page is retried, and the retry covers
parsing, not just the network. The run reports its own failure rate in
`RUN_STATS`: if any page was lost you will see it, instead of quietly getting a
shorter file.

**"Unexpected billing."** You are charged per listing written to the dataset, not
per request. `maxItems` is a hard stop. Turn on `skipWithoutPrice` and listings
with an empty price are dropped **before** billing.

### No personal data

This Actor does **not** return the seller's personal contact — no name, no phone
number, no email, no private address. For private sellers the agency field stays
empty and only a flag is set. Agency names and the portal's advertiser id are
company data, not personal data, and are returned.

### Limits — what this Actor does not do

- It reads **search result pages**, not individual listing pages. Fields that
  only exist on the detail page (full description, floor plans, energy
  certificate) are not returned
- It does not log in and does not see anything a logged-out visitor cannot see
- `terrainArea` is only present where the portal publishes it

### Pricing

**$1.49 per 1,000 listings** — $0.00149 per listing — plus $0.00005 per Actor start.
Platform usage (compute and proxy) is included in that price and is not billed
to you separately.

A run that returns nothing costs nothing beyond the Actor start.

#### Cost control

Set **Max listings** before you press Start and the run's cost is fixed in advance:
1,000 listings is $1.49, 250 listings is $0.37. The Actor stops exactly at that
number, on every plan, and writes a `RUN_STATS` record with what it actually did.
Nothing here depends on your plan tier — the behaviour on the Free plan is the
same as on any other, just bounded by the credit the plan gives you.

### Development

Offline tests, no network needed:

```bash
npm test
```

# Actor input Schema

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

Open willhaben.at, set your filters, then copy the address of the RESULTS page. It must start with /iad/immobilien/ - a single listing page (/iad/immobilien/d/...) will not work.

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

Stop after this many listings across all URLs.

## `maxPagesPerUrl` (type: `integer`):

0 means no limit - follow pagination to the end.

## `politeDelayMs` (type: `integer`):

Keeps request rate polite. Lower it only if you know what you are doing.

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

Austrian residential proxy is recommended.

## `skipWithoutPrice` (type: `boolean`):

Some listings hide the price or are projects without one. They arrive with an empty price. Turn this on to drop them - they are filtered out before the dataset is written, so you are not charged for them.

## `probeUrls` (type: `array`):

Paste any URL here and the Actor only checks whether it is reachable and whether the page carries the structured data this Actor reads - then stops without scraping and without charging per listing. The full result is written to the key-value store under PROBE\_RESULTS. Leave empty for normal runs.

## Actor input object example

```json
{
  "searchUrls": [
    "https://www.willhaben.at/iad/immobilien/eigentumswohnung/eigentumswohnung-angebote"
  ],
  "maxItems": 1000,
  "maxPagesPerUrl": 0,
  "politeDelayMs": 700,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "AT"
  },
  "skipWithoutPrice": false,
  "probeUrls": []
}
```

# Actor output Schema

## `listings` (type: `string`):

All listings returned by this run, one object per listing.

## `listingsCsv` (type: `string`):

The same listings as a CSV file.

# 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": [
        "https://www.willhaben.at/iad/immobilien/eigentumswohnung/eigentumswohnung-angebote"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("skubadev/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 = { "searchUrls": ["https://www.willhaben.at/iad/immobilien/eigentumswohnung/eigentumswohnung-angebote"] }

# Run the Actor and wait for it to finish
run = client.actor("skubadev/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 '{
  "searchUrls": [
    "https://www.willhaben.at/iad/immobilien/eigentumswohnung/eigentumswohnung-angebote"
  ]
}' |
apify call skubadev/willhaben-scraper --silent --output-dataset

```

## MCP server setup

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