# Otodom.pl Scraper — Polish Real Estate Listings + GPS (`diopside/otodom-listings`) Actor

Drop-in compatible with trev0n/otodom-scraper — cheaper, reliable, no residential proxy. Otodom.pl listings as JSON: price, price/m2, area, rooms, floor, market, full location + GPS, building type/year, heating, ownership, features, images, agency. Sale and rent; flats, houses, plots.

- **URL**: https://apify.com/diopside/otodom-listings.md
- **Developed by:** [DIOPSIDE AI](https://apify.com/diopside) (community)
- **Categories:** Real estate, Business, 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.pl Scraper — Polish Real Estate Listings + GPS

**Drop-in compatible with `trev0n/otodom-scraper` — cheaper, reliable, and no residential proxy required.** Same input field names, same output field names. Switching is a one-line change of the actor id.

Scrapes property listings from [Otodom.pl](https://www.otodom.pl/), Poland's largest real estate portal: sale and rent, apartments, houses, plots, commercial units, warehouses and garages. Price, price/m², area, rooms, floor, market, full location hierarchy with GPS coordinates, building type/material/year, heating, ownership, condition, amenities, every photo, and the listing agency.

### Why this one

The Otodom cluster's leading Actor fails about **a third of its runs** (33.4% of 1 220 runs in the last 30 days, Apify Store stats). Three things cause that class of failure on this site, and this Actor handles each one explicitly:

1. **Otodom canonicalises filters into the URL path.** Ask for `market=SECONDARY` or a single room count and Otodom answers **HTTP 200** with a body containing `__N_REDIRECT`, not the results — the real listing lives at `/wyniki/sprzedaz/mieszkanie,rynek-wtorny/...`. Nothing at the HTTP layer notices. This Actor follows those redirects; a scraper that doesn't sees an empty result set and reports success.
2. **Search cards carry a lossy slug.** A seller who puts `!` in the title gets a card slug with the punctuation stripped, while the canonical offer page spells it `exclamation`. Requesting the card's slug returns another silent 200 redirect. Unhandled, that means *no coordinates and no description for every listing whose title contains punctuation* — a systematic hole that never shows up as an error. We follow it: detail coverage is 12/12, not 11/12.
3. **The Next.js `buildId` rotates on every Otodom deploy.** A hardcoded one makes every data route return an empty body. We read the current one off the landing page at the start of each run, and re-read it if a request starts coming back empty mid-run.

On top of that:

- **Filters are verified, not assumed.** Otodom echoes the filters it actually applied in `filteringQueryParams`. Every search response is checked against what was requested, and the run **fails loudly rather than pushing unfiltered data**. A CDN edge answering page 1 from cache is a real failure mode on sites like this, and the result — a dataset of the wrong properties — looks completely valid until you check the prices by hand.
- **No proxy at all, by default.** Otodom's CloudFront/AWS WAF gate keys on the **TLS fingerprint**, not on IP reputation: plain `curl`/`httpx` get a 403 from any IP, while a Chrome-fingerprinted client gets the real page from a direct connection. Measured over four runs each: **direct 4/4 in ~3 s, Apify datacenter proxy 2/4 in ~40 s** — Otodom has soured on much of that shared datacenter pool, so routing through it is slower *and* less reliable. The incumbent defaults to residential proxy in Poland; you pay for that bandwidth and get a worse result. We only escalate to residential if the direct connection is ever actually blocked.
- **Deep pagination actually works.** 72 listings per request (Otodom's own maximum), verified to the last page of a 19 924-listing, 277-page Warsaw search. `maxItems: 0` means everything.
- **Partial failures are reported, never hidden.** A missing value is `null`; a failed offer page costs that record its detail fields, not the run; and the run's status message names every target that returned nothing.

### Input

Field names are `trev0n/otodom-scraper`'s, so an input written for that Actor runs here unchanged.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `startUrls` | array | `[]` | Otodom search (`/pl/wyniki/...`) or offer (`/pl/oferta/...`) URLs. Filters in the URL are preserved. Overrides the three fields below. |
| `searchType` | string | `sprzedaz` | `sprzedaz` (sale) or `wynajem` (rent) |
| `propertyType` | string | `mieszkanie` | `mieszkanie`, `dom`, `dzialka`, `lokal`, `haleimagazyny`, `garaz` |
| `location` | string | `cala-polska` | Path from an Otodom URL, e.g. `mazowieckie/warszawa/warszawa/warszawa`, `malopolskie/krakow/krakow/krakow`, `mazowieckie`, `cala-polska` |
| `priceMin` / `priceMax` | integer | – | Price in PLN (monthly rent for rentals) |
| `areaMin` / `areaMax` | integer | – | Floor area in m² |
| `roomsMin` / `roomsMax` | integer | – | Room count range |
| `buildYear` | integer | – | Minimum construction year |
| `market` | string | `ALL` | `ALL`, `PRIMARY` (developer), `SECONDARY` (resale) |
| `postedDate` | string | `""` | `1`, `3`, `7`, `14` — listings created in the last N days |
| `sortBy` | string | `DEFAULT` | **New.** `LATEST`, `PRICE_ASC/DESC`, `AREA_ASC/DESC`, `PRICE_PER_M_ASC/DESC` |
| `maxItems` | integer | `50` | Per start URL. `0` = every listing Otodom returns |
| `extractDetails` | boolean | `true` | Visit each offer page for GPS, description, building facts, all images |
| `maxConcurrency` | integer | `8` | Parallel requests |
| `maxRequestRetries` | integer | `3` | Retries per request; each rotates to a fresh proxy session |
| `maxRequestsPerMinute` | integer | `120` | Request rate ceiling |
| `proxyConfiguration` | object | none | No proxy needed — see "Why this one" |
| `residentialFallback` | boolean | `true` | **New.** Retry once on RESIDENTIAL/PL if the connection is ever blocked |

#### Example: apartments for sale in Warsaw, 2–3 rooms, 500–900k PLN

```json
{
  "searchType": "sprzedaz",
  "propertyType": "mieszkanie",
  "location": "mazowieckie/warszawa/warszawa/warszawa",
  "priceMin": 500000,
  "priceMax": 900000,
  "roomsMin": 2,
  "roomsMax": 3,
  "maxItems": 200,
  "extractDetails": true
}
```

#### Example: poll for new Kraków rentals every morning

```json
{
  "searchType": "wynajem",
  "propertyType": "mieszkanie",
  "location": "malopolskie/krakow/krakow/krakow",
  "priceMax": 4000,
  "postedDate": "1",
  "sortBy": "LATEST",
  "maxItems": 0
}
```

#### Example: whole-country sweep, listing data only

```json
{
  "location": "cala-polska",
  "maxItems": 0,
  "extractDetails": false,
  "maxConcurrency": 12
}
```

### Output

One record per listing.

```json
{
  "id": "68439042",
  "title": "2 pokoje na Bemowie",
  "price": 750000,
  "priceCurrency": "PLN",
  "priceFormatted": "750 000 PLN",
  "pricePerM2": 14705.88,
  "rentPrice": 1000,
  "area": 51.0,
  "rooms": 2,
  "floor": "1",
  "totalFloors": 4,
  "market": "SECONDARY",
  "location": "Jelonki Południowe, Bemowo, Warszawa, mazowieckie",
  "city": "Warszawa",
  "province": "mazowieckie",
  "district": "Bemowo",
  "subdistrict": "Jelonki Południowe",
  "street": "Rozłogi",
  "latitude": 52.224556,
  "longitude": 20.900364,
  "description": "Na sprzedaż funkcjonalne 2-pokojowe mieszkanie...",
  "buildingType": "block",
  "buildingMaterial": "brick",
  "buildYear": 1980,
  "heating": "urban",
  "ownershipType": "full_ownership",
  "condition": "ready_to_use",
  "windowsType": "plastic",
  "parkingType": "garage",
  "elevator": "no",
  "features": ["balcony", "usable_room"],
  "images": ["https://ireland.apollo.olxcdn.com/v1/files/.../image;s=1280x1024;q=80"],
  "imageCount": 5,
  "sellerType": "agency",
  "agencyName": "Royalton Premium Real Estate",
  "agencyUrl": "https://www.otodom.pl/pl/firmy/biura-nieruchomosci/royalton-premium-real-estate-ID11018171",
  "dateCreated": "2026-09-20T00:50:29Z",
  "dateModified": "2026-09-20T00:50:36Z",
  "propertyUrl": "https://www.otodom.pl/pl/oferta/2-pokoje-na-bemowie-ID4Da7v",
  "totalResults": 8505,
  "searchPage": 1,
  "position": 2,
  "hasDetails": true
}
```

Fields the incumbent does not return: `totalResults` (how many listings matched, so you know what fraction you took), `searchUrl` / `searchPage` / `position` (reproduce any record), `dateModified`, `pushedUpAt`, `deposit`, `freeFrom`, `status`, `referenceId`, `estate`, `transaction`, `advertType`, `terrainArea`, `developmentTitle`/`developmentUrl`, `shortDescription`, `tags`, `floorPlans`, `videoUrl`, `view3dUrl`, `isPromoted`, `isExclusiveOffer`, `isPrivateOwner`, `hidePrice`, `hasDetails`, and `characteristics` — Otodom's raw attribute map, so anything it starts publishing reaches you before we ship a schema change.

#### Two deliberate differences from the incumbent

- **Attribute values are Otodom's own enum keys** (`block`, `brick`, `full_ownership`, `ready_to_use`) rather than the Polish labels the website renders (`blok`, `cegła`). The keys are stable across Otodom's language switch and across UI copy changes; the labels are not. If you need the Polish label, map it once on your side.
- **`sellerPhone` is only ever an agency's or developer's business line.** For a private seller, that number is an individual's personal mobile, and this Actor does not collect it — `sellerPhone`, `agencyName` and `agencyId` stay `null` and `sellerType` is `private`. Everything published about a property itself is returned in full.

### Switching from `trev0n/otodom-scraper`

Change the actor id. That's it — every input field name and output field name above is the same.

Then, optionally:

- Drop `proxyConfiguration` from your input entirely. Residential bandwidth buys you nothing on this site, and the datacenter pool measured *worse* than no proxy at all.
- `market` is `null` in listing-only mode unless you set the `market` filter — Otodom genuinely does not state PRIMARY/SECONDARY on a search card, so we leave it null rather than guess. It is always populated when `extractDetails` is on.

### Run sizes and cost

| Run | Requests | Data transferred | Measured |
| --- | --- | --- | --- |
| 20 listings, details on | 1 search + 20 offer pages | ~0.5 MB | 14 s |
| 50 listings, details on | 1 search + 50 offer pages | ~1 MB | ~30 s |
| 500 listings, details on | 7 searches + 500 offer pages | ~11 MB | ~5 min |
| 500 listings, details off | 7 searches | ~0.3 MB | ~20 s |
| 5 000 listings, details off | 70 searches | ~3 MB | ~3 min |

`extractDetails: false` is roughly 30x less traffic per listing. Turn it off for price monitoring and market sizing; leave it on when you need coordinates, descriptions or building attributes.

### Use cases

- **Market analysis** — price and price/m² distributions by city, district and subdistrict, with `totalResults` giving the true denominator.
- **Lead generation for agencies** — new listings from private sellers (`isPrivateOwner: true`, `postedDate: "1"`), which is where a listing agreement is still available.
- **Investment screening** — filter on `pricePerM2`, `buildYear`, `market` and `condition`, then map the results by GPS.
- **Rental yield modelling** — pair a `sprzedaz` run and a `wynajem` run over the same district and compare price per m² against rent per m².
- **Price-change monitoring** — schedule daily with `sortBy: LATEST` and diff on `id` + `price` + `dateModified`.

### Notes

- Public listing data only. Otodom's `robots.txt` `Allow`s `/pl/wyniki/` and `/pl/oferta/`; this Actor uses the same JSON data routes the site's own pager calls and does not touch the `/api/query` endpoint that `robots.txt` disallows.
- No login and no CAPTCHA solver.
- If the run produces nothing at all it fails with a status message naming the cause, rather than succeeding with an empty dataset.

# Actor input Schema

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

Otodom.pl URLs — search result pages (`/pl/wyniki/...`) or single offers (`/pl/oferta/...`). Filters already in the URL are preserved. When set, `searchType` / `propertyType` / `location` are ignored.

## `searchType` (type: `string`):

Sale or rent.

## `propertyType` (type: `string`):

Type of property to search for.

## `location` (type: `string`):

The location path from an Otodom search URL — everything after `/wyniki/<searchType>/<propertyType>/`. Examples: `mazowieckie/warszawa/warszawa/warszawa` (Warsaw), `malopolskie/krakow/krakow/krakow` (Kraków), `mazowieckie` (whole voivodeship), `cala-polska` (all of Poland). A bare city name such as `warszawa` is not a valid path — if unsure, run the search on otodom.pl and paste the whole URL into `startUrls` instead.

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

Minimum price in PLN. For rentals this is the monthly rent.

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

Maximum price in PLN. For rentals this is the monthly rent.

## `areaMin` (type: `integer`):

Minimum floor area in square metres.

## `areaMax` (type: `integer`):

Maximum floor area in square metres.

## `roomsMin` (type: `integer`):

Minimum number of rooms.

## `roomsMax` (type: `integer`):

Maximum number of rooms.

## `buildYear` (type: `integer`):

Minimum construction year.

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

Primary (new-build, sold by a developer) or secondary (resale).

## `postedDate` (type: `string`):

Only listings created within the last N days. Combine with `sortBy: LATEST` to poll for new listings on a schedule.

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

Result order. `LATEST` puts the newest listings first, which is what you want for a scheduled run with a small `maxItems`. Not offered by the incumbent.

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

Maximum listings per start URL. `0` = every listing Otodom returns (deep pagination is verified — a Warsaw apartment search is ~20 000 listings over ~277 pages).

## `extractDetails` (type: `boolean`):

Visit each offer page for GPS coordinates, full description, building type/material/year, heating, ownership, features and all image URLs. Off is ~30x less traffic and gives listing-level fields only.

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

Parallel requests.

## `maxRequestRetries` (type: `integer`):

Retries per request. Each retry rotates to a fresh proxy session.

## `maxRequestsPerMinute` (type: `integer`):

Request rate ceiling, to stay a polite guest on Otodom.

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

Otodom blocks on the TLS fingerprint, not on IP reputation, so no proxy is needed — and measured over four runs each, the direct connection passed 4/4 in ~3s while the Apify datacenter pool passed 2/4 in ~40s. Leave this empty unless you have a reason not to; `residentialFallback` covers the case where the direct IP is ever blocked.

## `residentialFallback` (type: `boolean`):

If Otodom ever blocks the connection this run is using, retry once on Apify RESIDENTIAL proxy in Poland rather than failing. Costs nothing unless it fires. Ignored when you already selected residential or supplied your own proxy URLs.

## Actor input object example

```json
{
  "searchType": "sprzedaz",
  "propertyType": "mieszkanie",
  "location": "mazowieckie/warszawa/warszawa/warszawa",
  "market": "ALL",
  "postedDate": "",
  "sortBy": "DEFAULT",
  "maxItems": 50,
  "extractDetails": true,
  "maxConcurrency": 8,
  "maxRequestRetries": 3,
  "maxRequestsPerMinute": 120,
  "proxyConfiguration": {
    "useApifyProxy": false
  },
  "residentialFallback": true
}
```

# Actor output Schema

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

All listing records. Append ?format=csv for CSV.

## `datasetUrl` (type: `string`):

The default dataset.

# 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 = {
    "location": "mazowieckie/warszawa/warszawa/warszawa",
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("diopside/otodom-listings").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 = {
    "location": "mazowieckie/warszawa/warszawa/warszawa",
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("diopside/otodom-listings").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 '{
  "location": "mazowieckie/warszawa/warszawa/warszawa",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call diopside/otodom-listings --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,diopside/otodom-listings"
        }
    }
}
```

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/VoRs3hAYPbFBjt4zI/builds/nI8ER7GTVwPC6Awoq/openapi.json
