# Otodom Scraper - Polish Real Estate Listings (`ziomixshot/otodom-pl-scraper`) Actor

Scrape verified Otodom.pl listings for sale or rent using structured filters. Get compact prices, areas, rooms and locations, plus details and raw data.

- **URL**: https://apify.com/ziomixshot/otodom-pl-scraper.md
- **Developed by:** [Amadeusz](https://apify.com/ziomixshot) (community)
- **Categories:** Real estate, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 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?

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

## Otodom Scraper - Polish Real Estate Listings

Collect verified live Otodom.pl listings for market research, price monitoring and lead lists. Use structured location and filter fields from Console, API or MCP; copied result URLs remain an advanced option. Results are compact, deduplicated and ready for JSON, CSV, Excel or AI-agent workflows.

[Run Otodom Scraper](https://console.apify.com/actors/B10hxIdFTLKVrBa3Z?addFromActorId=B10hxIdFTLKVrBa3Z) · [Input schema](https://apify.com/ziomixshot/otodom-pl-scraper/input-schema) · [Output schema](https://apify.com/ziomixshot/otodom-pl-scraper/output-schema)

### Quick start

1. Set **Location**, **Estate type** and **Transaction**.
2. Add price, area, room, market, building, owner, date or sorting filters.
3. Set **Max items**. Enable **Enrich with offer details** only when you need full descriptions and attributes.
4. Click **Start**.
5. Export the **Overview** or **Enriched details** table, or read it through the API.

[![Otodom Scraper input form with a structured Kraków search and a 100-item limit](https://api.apify.com/v2/key-value-stores/sYB5vyPRK1sN7qKeo/records/otodom-input-form.png?v=3)](https://console.apify.com/actors/B10hxIdFTLKVrBa3Z?addFromActorId=B10hxIdFTLKVrBa3Z)

The default demo collects up to 100 Kraków apartment-sale listings. An API run with no target uses the same bounded demo instead of starting an unlimited nationwide crawl.

[![Otodom Scraper output table with normalized listing columns](https://api.apify.com/v2/key-value-stores/sYB5vyPRK1sN7qKeo/records/otodom-output-table.png?v=3)](https://apify.com/ziomixshot/otodom-pl-scraper/output-schema)

### Ready-to-use recipes

#### 1. Build a search from structured input

This is the primary path for Console, API and MCP clients.

```json
{
  "locationQuery": "Kraków",
  "estate": "FLAT",
  "transaction": "SELL",
  "priceMax": 900000,
  "roomsNumber": ["TWO", "THREE"],
  "maxItems": 100
}
```

Common cached locations work without live autocomplete.

#### 2. Use one browser search URL

As an advanced alternative, copy a complete Otodom results URL. Its path and all query parameters take full priority over structured search fields.

```json
{
  "startUrls": [
    "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/malopolskie/krakow/krakow/krakow?priceMax=900000"
  ],
  "maxItems": 100
}
```

If an uncommon location cannot be resolved from structured input, use this path or a direct `searchingCriteria`.

#### 3. Collect several searches in one run

```json
{
  "startUrls": [
    "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/malopolskie/krakow/krakow/krakow",
    "https://www.otodom.pl/pl/wyniki/wynajem/mieszkanie/mazowieckie/warszawa/warszawa/warszawa"
  ],
  "maxItems": 500
}
```

URLs run sequentially. Listing IDs are deduplicated across the whole run.

#### 4. Estimate result count before collecting

```json
{
  "locationQuery": "Kraków",
  "estate": "FLAT",
  "transaction": "SELL",
  "countOnly": true
}
```

The result is a free `recordType: "estimate"` record in both the default dataset and named `status` output. The live SSR result-page total is authoritative; CountAds is only an optional control measurement.

#### 5. Monitor only listing IDs not seen before

```json
{
  "startUrls": [
    "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/malopolskie/krakow/krakow/krakow"
  ],
  "mode": "monitor",
  "seenStoreName": "otodom-krakow-monitor",
  "seenIdsKey": "OTODOM_SEEN_IDS",
  "maxItems": 500
}
```

Save this input as an Actor Task and attach an [Apify Schedule](https://docs.apify.com/actors/running/schedules). Every scheduled run must use the same named key-value store and key. Run 1 publishes unseen IDs; later runs publish only IDs not stored before.

Monitoring currently detects **new IDs only**. It does not report price changes, updated descriptions or expired listings.

Use a separate named store for each monitored search. Leaving `seenStoreName` empty uses the run's default store, so independent runs do not share history. The ID list grows without automatic retention. By default, monitoring stops after the first page containing only seen IDs; increase `stopMonitorOnAllSeenPages` when the search order does not reliably put new listings first.

#### 6. Call the synchronous API

```bash
curl -X POST \
  "https://api.apify.com/v2/acts/ziomixshot~otodom-pl-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "locationQuery": "Kraków",
    "estate": "FLAT",
    "transaction": "SELL",
    "roomsNumber": ["TWO", "THREE"],
    "maxItems": 100
  }'
```

The Actor also works through the [Apify MCP server](https://docs.apify.com/integrations/mcp), so agents can inspect the input and output schemas before running it.

### Input reference

| Field | Purpose |
|---|---|
| `locationQuery` | Primary target: a Polish city, district or region. |
| `estate`, `transaction` | Property and sale/rent selection. |
| `maxItems` | Hard stop for verified unique listing rows and listing charges. |
| `enrichDetails` | Adds normalized offer-page description, features, status and images. |
| `includeRawData` | Opt-in `raw` and `detailRaw` diagnostics; disabled by default. |
| `countOnly` | Returns an estimate instead of collecting listing rows. |
| `mode` | `scrape` or `monitor`. |
| `seenStoreName`, `seenIdsKey` | Persistent state shared by monitor runs. |
| `startUrls` | Advanced array of copied `/pl/wyniki/` URLs; each URL fully replaces structured search fields. |

Filters for price, area, rooms, market, building type, owner type, date and sorting are available in the [full input schema](https://apify.com/ziomixshot/otodom-pl-scraper/input-schema).

### Output contract

Every dataset item has a `recordType` discriminator:

- `listing` - normalized Otodom listing;
- `estimate` - `countOnly` result;
- `status` - actionable soft-fail status.

Normal scrape and monitor runs also write a run summary to the named `status` output (`OUTPUT` key-value record). Use the **Overview** view for core columns and **Enriched details** for offer-page fields.

**BREAKING CHANGE in v2:** listing rows contain only normalized public fields. Keys without values and empty arrays are omitted. Legacy aliases and source-shaped `detail*` fields are removed.

```json
{
  "recordType": "listing",
  "id": 68458846,
  "url": "https://www.otodom.pl/pl/oferta/fort-bema-3-pokoje-mozliwe-4-do-wejscia-garaz-ID4DfgV",
  "title": "Fort Bema | 3 pokoje | możliwe 4 | garaż",
  "price": 1329000,
  "priceCurrency": "PLN",
  "administrativeRent": 1940,
  "pricePerSquareMeter": 18985.71,
  "area": 70,
  "rooms": 3,
  "floor": "FOURTH",
  "estate": "FLAT",
  "transaction": "SELL",
  "market": "SECONDARY",
  "buildingType": "APARTMENT",
  "location": "Fort Bema, Bemowo, Warszawa, mazowieckie",
  "city": "Warszawa",
  "province": "mazowieckie",
  "isPrivateOwner": false,
  "dateCreated": "2026-09-25T03:00:27Z",
  "createdAtFirst": "2026-09-25T03:00:21Z"
}
```

For `SELL`, `price` is the sale price. `administrativeRent` is a separate monthly administrative rent in PLN. Estimate and fail-closed status records are free and use their own `recordType`.

`raw` and `detailRaw` appear only with `includeRawData: true`. They are unstable diagnostics; build integrations from normalized fields.

### Pricing

Pay per event: empty results, duplicates, count estimates and soft-fail records are not charged.

| Event | Price |
|---|---:|
| Listing | $1.50 per 1,000 unique listings |
| Listing details | $2.25 per 1,000 successfully enriched listings |
| Listing plus details | $3.75 per 1,000 full rows |

`maxItems` caps listing rows. Apify's maximum-cost-per-run setting can add an account-level spending limit.

### Verified performance snapshot

Observed on **2026-09-26** for a whole-Poland apartment-sale search using build `1.2.10`, Polish residential proxies, `limit: 72`, `maxItems: 20000` and `maxConcurrency: 16`:

- **20,000 / 20,000** requested listing rows were pushed from 344 result pages;
- runtime was approximately **73 seconds** and compute usage approximately **0.08 CU**;
- `failedPages` was empty; the run saw no HTTP 403/429 and did not engage adaptive throttling.

Listing pages have up to five attempts with a new proxy session and 1/2/4/8-second backoff before a page is recorded in `failedPages`. A separate transient-connection test exercised that recovery path; the 73-second benchmark itself did not need retries.

In a 2,000-row Kraków staging run on **2026-09-25**, 236 repeated listing appearances were skipped and the final dataset contained no duplicate IDs. Billing happens only after this deduplication. Empty pages, duplicate IDs, count estimates and soft-fail records do not emit a chargeable `listing` event.

These are dated measurements of those inputs and platform conditions, not a guarantee for every location, result volume or future Otodom version.

### Reliability and limitations

- The Actor paginates until the last page, a configured limit or repeated listing IDs. Otodom has no observed 1,000-result cap, but a moving search index can still change during a long crawl.
- Search pages use Polish residential proxies. The proxy choice is locked because datacenter routes were unreliable in project tests.
- Listing pages retry transient failures up to five times. Any page still skipped is listed in `failedPages` in the named `status` output.
- Deduplication is by Otodom listing ID within a run. Monitor mode extends that ID set across runs.
- Live SSR must confirm every requested filter. An unconfirmed filter returns `FILTER_VERIFICATION_FAILED` without paid listing rows.
- Investment/HPR cards and individual records that fail verifiable predicates are excluded before charging.
- Phone-related keys and phone contact entries are removed from retained raw search and detail objects.
- Otodom can change page or API structures. A successful run proves the observed result, not future availability.

### Development

Runtime and CI use Node.js 20.

```bash
npm ci
npm test
apify validate-schema
```

Run `npm run quality:audit` with `APIFY_TOKEN` and an optional `ACTOR_BUILD` before promoting a release. Pushes to `main` test and deploy through `.github/workflows/deploy-apify.yml`, then synchronize the public example input. The workflow requires the `APIFY_TOKEN` GitHub Actions secret.

Reverse-engineering artifacts are in `docs/openapi/`; current product decisions and verified evidence are tracked in `docs/backlog.md` and `docs/strategia.md`.

### FAQ

#### How fresh are the results?

Listings are fetched from Otodom during each run. Timestamps identify both the source publication time and `scrapedAt`.

#### Why is the price field missing?

Otodom can hide a listing price. Output v2 preserves that absence by omitting the field instead of emitting `null` or inventing a value.

#### Does the Actor collect seller phone numbers?

No. Phone-related keys and phone contact entries are stripped from both normalized and retained raw output.

#### How do I report a failure?

Open the Actor's [Issues tab](https://apify.com/ziomixshot/otodom-pl-scraper/issues) and include the run URL. Shared run logs are the fastest way to reproduce proxy or source-site failures.

# Actor input Schema

## `locationQuery` (type: `string`):

Polish city, district or region, for example Warszawa or Kraków. Leave empty only for a bounded demo run.

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

Stop after this many unique listings. Use it to cap both the result size and listing charges.

## `enrichDetails` (type: `boolean`):

Fetch each offer page and add full details. This adds a separate listing-details charge; phone numbers are never collected.

## `includeRawData` (type: `boolean`):

Add unstable raw and detailRaw source objects. Keep disabled for the compact normalized v2 output.

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

Scrape returns matching listings. Monitor returns only listing IDs not seen in earlier runs that use the same key-value store.

## `searchingCriteria` (type: `string`):

Paste the path after /pl/wyniki/ when you already know it, for example sprzedaz/mieszkanie/malopolskie/krakow/krakow/krakow. Takes priority over Location query.

## `estate` (type: `string`):

Property category (used when building path from locationQuery / defaults).

## `transaction` (type: `string`):

SELL (sprzedaż) or RENT (wynajem).

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

Minimum listing price in PLN.

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

Maximum price filter in PLN (passed to wyniki query).

## `areaMin` (type: `number`):

Minimum area in square meters.

## `areaMax` (type: `number`):

Maximum area in square meters.

## `roomsNumber` (type: `array`):

Otodom roomsNumber enum values.

## `market` (type: `string`):

ALL / PRIMARY (primary market / developer) / SECONDARY.

## `buildingType` (type: `array`):

Depends on estate (flat: BLOCK, TENEMENT, …; house: DETACHED, …).

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

Sort field for wyniki listing.

## `sortDirection` (type: `string`):

Sort direction (ASC or DESC).

## `ownerTypeSingleSelect` (type: `string`):

Filter by owner type: ALL, PRIVATE, AGENCY, or DEVELOPER.

## `daysSinceCreated` (type: `integer`):

Only ads created within the last N days.

## `seenStoreName` (type: `string`):

Named Key-Value store shared by monitor runs. Use a separate name per search. Empty uses the run's default store and does not persist history across independent runs.

## `seenIdsKey` (type: `string`):

Key inside the store holding { ids: string\[], updatedAt, … }.

## `stopMonitorOnAllSeenPages` (type: `integer`):

In monitor mode, stop when this many consecutive pages contain only already-seen ad IDs (default 1).

## `startUrls` (type: `array`):

Optional copied Otodom result URLs. A provided URL owns its complete search and takes priority over all structured search fields.

## `countOnly` (type: `boolean`):

Estimate how many listings match the search without collecting or charging for listing rows.

## `limit` (type: `integer`):

Number of listings requested per Otodom page. The Actor accepts 36, 48 or 72 and uses 72 by default.

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

Stop each search after this many Otodom result pages.

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

Hard-locked to Apify RESIDENTIAL proxies with country PL. Datacenter proxies (~47% success) are not allowed — the Actor always uses residential PL. This field is hidden in the UI; overrides in API input are ignored.

## Actor input object example

```json
{
  "locationQuery": "Warszawa",
  "maxItems": 100,
  "enrichDetails": false,
  "includeRawData": false,
  "mode": "scrape",
  "searchingCriteria": "sprzedaz/mieszkanie/cala-polska",
  "estate": "FLAT",
  "transaction": "SELL",
  "market": "ALL",
  "sortBy": "DEFAULT",
  "sortDirection": "DESC",
  "ownerTypeSingleSelect": "ALL",
  "seenIdsKey": "OTODOM_SEEN_IDS",
  "stopMonitorOnAllSeenPages": 1,
  "startUrls": [
    "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/malopolskie/krakow/krakow/krakow?limit=72"
  ],
  "countOnly": false,
  "limit": 72,
  "maxPages": 1,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "PL"
  }
}
```

# Actor output Schema

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

Default dataset with normalized listings. Missing values and empty arrays are omitted; raw objects require includeRawData=true.

## `status` (type: `string`):

Run summary, count estimate or soft-fail details stored in the OUTPUT key-value record.

# 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 = {
    "maxItems": 100,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "PL"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("ziomixshot/otodom-pl-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 = {
    "maxItems": 100,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "PL",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("ziomixshot/otodom-pl-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 '{
  "maxItems": 100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "PL"
  }
}' |
apify call ziomixshot/otodom-pl-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ziomixshot/otodom-pl-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/B10hxIdFTLKVrBa3Z/builds/TdLWGlqmE4rhJ0Zno/openapi.json
