# Otodom Property Scraper (Poland) (`scrapyx/otodom-properties-scraper`) Actor

Scrapes apartments, houses, land and commercial property from Otodom — Poland's #1 real-estate portal, with 150,000+ apartments for sale. Filter by city, price, area and rooms; returns price, area, floor, full description, features, photos and agency. No pagination ceiling.

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

## Pricing

from $0.0007 / actor start

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

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#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

## Otodom Property Scraper (Poland)

Scrapes apartments, houses, land and commercial property from
**[Otodom](https://www.otodom.pl)** — Poland's #1 real-estate portal (OLX
Group), with **over 150,000 apartments listed for sale** nationwide.

Public data only. No login, no cookies, no browser.

### What you get

Two record types share one dataset, told apart by `recordType`.

#### `PROPERTY` — one row per listing

Search rows carry price, price per m², area, rooms, floor, location, agency
and photos. With **Fetch full property details** on (the default), each row
also gets a `propertyDetails` object:

- the full description
- `featuresByCategory` — every amenity grouped as Otodom groups them
- floor plans and images
- exact location with the full administrative hierarchy
- `characteristics` — building year, market (primary/secondary), ownership
  form, heating, condition
- agency, owner and developer details
- additional costs (rent, utilities) where the advertiser supplied them

#### `SEARCH_SUMMARY` — one row per location searched

Upstream's match total and page count, how deep the run paged, the filters you
requested, and **Otodom's own echo of the filters it actually parsed**.

### Input

| Field | What it does |
| --- | --- |
| **For sale / for rent** | `sprzedaz` or `wynajem` |
| **Property type** | apartment, house, land, commercial premises, halls & warehouses, garage, room, new development |
| **Locations** | one search per entry: `cala-polska`, `krakow`, or a full path like `mazowieckie/warszawa/warszawa/warszawa` |
| **Price / area** | min–max ranges (PLN, m²) |
| **Number of rooms** | multi-select, unioned |
| **Sort by** | newest, price, area, price per m² — with direction |
| **Offer URLs** | scrape specific listings directly, skipping search |
| **Results per request** | 24 / 36 / 48 / **72** (default — fewest requests) |
| **Max properties per location** | `0` = unlimited, and here that really means everything |
| **Fetch full property details** | off = fast listing-only crawl |

#### Example

```json
{
  "transaction": "sprzedaz",
  "estate": "mieszkanie",
  "locations": ["malopolskie/krakow/krakow/krakow"],
  "minPrice": 400000,
  "maxPrice": 900000,
  "rooms": ["TWO", "THREE"],
  "sortField": "LATEST",
  "maxItems": 500,
  "includePropertyDetails": true
}
```

### What makes this one different

**No pagination ceiling.** Most property portals cap how deep you can page —
Immoweb stops at 9,990 rows, OnTheMarket at 1,020. Otodom does not: page 4,191
of 4,191 really does serve its final rows. `maxItems: 0` genuinely means the
whole result set, so set a bound unless you mean it.

**It reports what Otodom actually understood.** Otodom silently ignores
filters it does not parse — `roomsNumber=THREE` is discarded (the right syntax
is `[THREE]`), and an unknown room value just widens your search without
saying so. Every run puts Otodom's own parse on the summary row:

```json
"filtersRequested":   {"priceMin": 400000, "priceMax": 900000, "roomsNumber": "[TWO,THREE]"},
"upstreamFilterEcho": {"priceMin": 400000, "priceMax": 900000,
                       "roomsNumber": ["TWO", "THREE"],
                       "locations": [{"name": "Kraków", "fullName": "Kraków, małopolskie"}]}
```

Property types, room values, sort fields and page sizes are all validated
**before** the first request. That matters because an unrecognised property
type is not an error on Otodom — it answers HTTP 200 with all 150,000
apartments, which would look like a successful run.

### Notes on reliability

- **DataDome tag present, no challenge served.** Otodom loads DataDome, but
  every cold request returned full data. The actor watches for genuine
  challenge markers and rotates IP + fingerprint if one ever appears.
- **Residential proxy by default** on cloud runs — the likeliest place for a
  challenge to show up.
- **Runs unattended.** No human-captured session, no expiring token.
- **Unknown locations fail cleanly** as `not_found`.
- **Failures never vanish.** An offer withdrawn mid-crawl degrades that one
  row (`_detailError`); every input maps to at least one output row.

### Output envelope

Every record carries `_input`, `_source` and `_scrapedAt`. Upstream field names
pass through **verbatim** — no renaming. `_source` is `S1-nextdata-search` for
listing-only rows and `S1-nextdata-search+S2-nextdata-ad` once details are
attached.

Note `location` on a property row is upstream's own object; the location you
searched is `locationQueried`.

See [`CRAWLING_METHOD.md`](CRAWLING_METHOD.md) for the full reverse-engineering
trail, including the `obiekt` baseline-echo trap and the DataDome marker that
would have false-positived on every healthy page.

# Actor input Schema

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

Which side of the market to search.

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

Only these values are real Otodom path segments. An unrecognised one is answered with HTTP 200 and silently returns the unfiltered apartment results, so anything else is refused rather than run. Note 'Room' has no for-sale listings — use it with For rent.

## `locations` (type: `array`):

Otodom location slugs — one search per entry, each with its own SEARCH\_SUMMARY row. Use 'cala-polska' for the whole country, a city name like 'krakow', or a full administrative path such as 'mazowieckie/warszawa/warszawa/warszawa'. A human spelling is slugified automatically. An unknown location returns a clean 404 reported as not\_found, never silently-wrong nationwide results.

## `minPrice` (type: `integer`):

Leave empty or 0 for no minimum.

## `maxPrice` (type: `integer`):

Leave empty or 0 for no maximum.

## `minArea` (type: `integer`):

Leave empty or 0 for any.

## `maxArea` (type: `integer`):

Leave empty or 0 for any.

## `rooms` (type: `array`):

Select one or more room counts. Values are unioned (choosing TWO and THREE returns both). Any value outside this list is silently ignored by Otodom — widening your search without saying so — so unknown values are refused here instead.

## `sortField` (type: `string`):

Leave as default unless you need a specific order. Only these values work — Otodom silently ignores an unrecognised sort, returning results in a different order than you asked for, so anything else is refused before the run starts.

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

Only applies when a sort field is chosen. Defaults to descending.

## `offerUrls` (type: `array`):

Scrape specific listings directly, skipping search. Accepts a full URL (https://www.otodom.pl/pl/oferta/...-ID4wn7p) or a bare offer slug. Can be combined with a location search in the same run.

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

How many listings to pull per request. Must be 24, 36, 48 or 72 — those are the only values Otodom honours, and 100 or 200 silently fall back to 36. Anything else is refused before the run starts. 72 is the default because it halves the request count versus Otodom's own 36.

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

Stop paginating a location after this many properties. Set to 0 for unlimited — unlike most portals Otodom has NO pagination ceiling, so unlimited really does mean the whole result set (over 150,000 apartments for sale nationwide). Set a bound unless you mean it.

## `includePropertyDetails` (type: `boolean`):

Fetch the complete listing (full description, all features by category, floor plans, agency and owner details, exact location, characteristics) for each result via one extra request per property. Turn this off for a fast listing-only crawl — search rows already carry price, area, rooms, floor, location and photos.

## `maxConcurrency` (type: `integer`):

Upper bound on requests in flight at once, across location pagination and detail fetches.

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

Residential is the default and is recommended here: Otodom ships a DataDome tag. No challenge was served on any direct test, but datacenter egress is the likeliest place for one to appear.

## Actor input object example

```json
{
  "transaction": "sprzedaz",
  "estate": "mieszkanie",
  "locations": [
    "cala-polska"
  ],
  "minPrice": 0,
  "maxPrice": 0,
  "minArea": 0,
  "maxArea": 0,
  "rooms": [],
  "sortField": "",
  "sortDirection": "",
  "offerUrls": [],
  "pageSize": 72,
  "maxItems": 100,
  "includePropertyDetails": true,
  "maxConcurrency": 4,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# 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 = {
    "locations": [
        "cala-polska"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/otodom-properties-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 = { "locations": ["cala-polska"] }

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/otodom-properties-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 '{
  "locations": [
    "cala-polska"
  ]
}' |
apify call scrapyx/otodom-properties-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/otodom-properties-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/9A0QaLQ0B0NHYT5mC/builds/LKlEQM1nKRElDdO31/openapi.json
