# Otodom Scraper & New-Listing Monitor - Poland Real Estate (`neverempty/otodom-listings`) Actor

Turn monitoring on and every later run returns only the listings it has not returned before, so you are never charged for the same property twice. Every Otodom.pl listing as clean JSON: price with its meaning, price per m2, rooms, area, floor, city and district, coordinates, agency. Unofficial.

- **URL**: https://apify.com/neverempty/otodom-listings.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Real estate, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.46 / 1,000 property returneds

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/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

## Otodom Scraper - Real Estate Data for Poland, Price & Area

Pull property listings from **Otodom.pl** — Poland's largest property portal — as clean JSON: the price as a number, what that number means, the area in m², rooms, floor, the city and district, the features and the agency.

*Unofficial. This Actor is not affiliated with, endorsed by, or sponsored by Otodom. All product names are trademarks of their respective owners.*

No API key. Reads the same public pages a visitor sees.

***

### Run it tomorrow and you only get what is new

Schedule this Actor with `monitoringMode: true` and each run returns **only the listings it has not returned before**. Not "here is everything again, please de-duplicate it yourself" — the Actor remembers what it already gave you, so a daily feed costs you the handful of new homes, not the whole board every morning. **You are never charged for the same property twice.**

Listings your filters removed are remembered too, so widening a filter later does not resurrect them as false "new" listings. Listings that did not fit under your limit are **not** remembered, so they are still waiting on the next run instead of being silently lost.

***

### Why this one is different

#### The monthly service charge is not sold to you as rent

This is the trap in Otodom's data. Its listing objects carry a field literally called `rentPrice` — and **62 of the 104 sale listings measured have one**. It is not a rent. It is the monthly administrative charge (*czynsz*): median **731 PLN**, against a sale price whose median is **586,200 PLN**.

Passing it through as "rent" would put a 731 next to a 586,200 in the same column, so this Actor splits them:

- `price` + `priceType` — `sale` for a total, `monthly-rent` for a rent, taken from the listing's own transaction type
- `monthlyAdminCharge` — the *czynsz*, in its own column, on sale and rent listings alike
- `pricePerSqm` — Otodom's own figure, on the listings that publish it

A property whose price is hidden comes back with `price: null` and `isPriceHidden: true` — never a zero.

#### Rooms and floors are words, and they are only turned into numbers where that is safe

Otodom writes `roomsNumber: "FOUR"` and `floorNumber: "THIRD"`. Both come back twice: the raw word (`roomsRaw`, `floorRaw`) and a number (`rooms`, `floor`).

**`ABOVE_TENTH` is left as a word with `floor: null`** — "above the tenth" is not a floor number, and inventing 11 would be a guess. Any value not seen in the sample is treated the same way: the word is kept, the number stays `null`.

#### No agent names, no phone numbers, no private sellers' names

Otodom's page data carries the assigned agent's first name, last name and **mobile number on 33 of the 141 listings measured**, and **31 listings are posted by private individuals under their own names**.

This Actor returns **the agency name only**. Agent names, agent mobiles and private sellers' names are not collected, and email addresses and Polish phone numbers are removed from the description text as well. A private listing comes back as `advertiserType: "private"` with no name attached. Property data, not a contact list.

#### One request, 30 to 37 complete properties

Otodom ships the full listing — price, area, rooms, floor, location, features, agency — inside the search page itself. This Actor reads all of them from that single page and **never opens individual property pages**.

Promoted listings repeat across pages; those repeats are dropped before delivery, so **you are never charged twice for the same property** (4 of the 141 read were repeats).

#### Filters run here, not on Otodom

Every filter — keyword, price, price per m², rooms, floor, area, agency, advertiser type, date — is applied to rows that were **already fetched**. Nothing extra is requested from Otodom and nothing extra is charged. A property is never dropped for a value Otodom did not publish: an unknown price is not a low one, an unreadable date is not an old one.

#### An empty search is an answer, not a silent fallback

A search Otodom has nothing for returns **one `no-results` row saying so, and it is not charged**. A page whose embedded data is missing — **or which states a total above zero while yielding no properties** — is reported as `unreadable`, so a redesign on Otodom's side shows up as an error rather than as a quiet, permanent "there is nothing for sale". Filters that removed everything are a third kind of row (`no-filter-match`) which states how many properties were actually read.

***

### Input

Either paste a search URL, or build one from three fields.

| Field | Type | Default | Description |
|---|---|---|---|
| `searchUrl` | string | — | e.g. `https://www.otodom.pl/pl/wyniki/sprzedaz/dom/mazowieckie`. Query parameters are stripped |
| `transaction` | string | `sprzedaz` | `sprzedaz` (for sale) or `wynajem` (to rent) |
| `estate` | string | `mieszkanie` | `mieszkanie`, `dom`, `dzialka`, `lokal`, `haleimagazyny`, `garaz`, `pokoj` |
| `location` | string | `cala-polska` | The whole country, a region (`mazowieckie`), or a city as its full path (`mazowieckie/warszawa/warszawa/warszawa`) |
| `maxListings` | integer | 20 | 1-1000. One property = one row = one charged event |
| `maxPages` | integer | 0 | Hard cap on requests; 0 = decide from `maxListings` |
| `includeDescription` | boolean | `true` | The teaser text. The keyword filter searches it either way |
| `useProxy` | boolean | `false` | Off by default — Otodom answers Apify's network directly. Switches on by itself if blocked |
| `keywords` / `keywordMatch` / `excludeKeywords` | array / string / array | — | Match on title, description, location, features and agency |
| `agencyNames` | array | — | Keep only these agencies (partial match) |
| `minPrice` / `maxPrice` | integer | 0 | PLN. **A property with no published price is kept** |
| `maxPricePerSqm` | integer | 0 | PLN per m² |
| `requirePrice` | boolean | `false` | Drop properties whose price is hidden |
| `minRooms` / `maxRooms` | integer | -1 | -1 = off |
| `minFloor` / `maxFloor` | integer | -1 | 0 is the ground floor. -1 = off |
| `minAreaSqm` / `maxAreaSqm` | integer | 0 | 0 = off |
| `advertiserType` | string | `any` | `agency` or `private` |
| `exclusiveOnly` | boolean | `false` | Listings an agency holds exclusively |
| `listedWithinDays` | integer | 0 | 0 = no limit. **An unreadable date is kept** |
| `monitoringMode` | boolean | `false` | Return only properties not returned on a previous run |
| `resetMonitoringState` | boolean | `false` | One-shot: forget what was already returned |

```json
{ "transaction": "sprzedaz", "estate": "mieszkanie", "location": "warszawa", "maxListings": 100,
  "minRooms": 2, "maxPrice": 900000, "maxPricePerSqm": 16000, "advertiserType": "private" }
```

#### Monitoring mode turns this into a daily feed

Schedule it and set `monitoringMode: true`: each run returns **only the properties it has not returned before**. Properties removed by your filters are remembered too, so changing a filter later does not resurrect them as false "new" listings — and properties that simply **did not fit under `maxListings` are not remembered**, so they are still waiting for you on the next run rather than being silently lost.

Measured on 2026-09-04: **149,738** flats for sale across Poland.

***

### Output

One row per property:

```json
{
  "source": "otodom.pl",
  "status": "ok",
  "listingId": "68004190",
  "title": "Balkon na lato. Wakacje każdego dnia.",
  "url": "https://www.otodom.pl/pl/oferta/balkon-na-lato-wakacje-kazdego-dnia-ID4BkZM",
  "transactionRaw": "SELL",
  "transactionType": "sale",
  "estateType": "FLAT",
  "price": 675318,
  "priceCurrency": "PLN",
  "priceType": "sale",
  "isPriceHidden": false,
  "monthlyAdminCharge": null,
  "monthlyAdminChargeCurrency": null,
  "pricePerSqm": 9800,
  "pricePerSqmCurrency": "PLN",
  "areaSqm": 68.91,
  "roomsRaw": "FOUR",
  "rooms": 4,
  "floorRaw": "THIRD",
  "floor": 3,
  "city": "Bielsko-Biała",
  "province": "śląskie",
  "street": "ul. Muszlowa",
  "locationFullName": "Stare Bielsko, Bielsko-Biała, śląskie",
  "features": ["AIR_CONDITIONING", "TERRACE", "STORAGE_ROOM", "TOP_FLOOR", "PARKING_SPOT", "SECURE_BUILDING"],
  "advertiserType": "agency",
  "agencyName": "Eko Develop",
  "agencyUrl": "https://www.otodom.pl/pl/firma/eko-develop-ID433594",
  "isExclusiveOffer": false,
  "isPromoted": false,
  "imagesCount": 15,
  "listedAt": "2026-05-14T12:30:32Z",
  "updatedAt": "2026-06-21T19:34:16+02:00",
  "shortDescription": "…",
  "scrapedAt": "2026-09-04T22:40:05.658Z"
}
```

Measured across 141 live listings on 2026-09-04: title, url, transaction type, property type, city, province and the first-listed date on **141 of 141**; area on **137**; rooms on **137**; floor on **133**; a published price on **129**.

#### Rows that are never charged

| `status` | when |
|---|---|
| `no-results` | Otodom has no listings for this search. That is its answer, not a failure |
| `no-such-search` | Otodom has no page at that address - a mistyped location or property type. **Not** a claim that there is nothing there: `warszawa` on its own is a 404, while its full path holds 7,113 flats to rent |
| `no-filter-match` | Listings were read, but your filters removed all of them. The row says how many were read |
| `no-new-listings` | Monitoring mode: nothing new since the previous run |
| `unreadable` | The page could not be read, its embedded data was missing, or it claimed a total above zero while yielding nothing |

***

### Pricing

Pay per property returned. Rows that report an empty search, a filter that matched nothing, or a page that could not be read are **not** charged. A property that appears twice across pages is dropped before delivery, so it is never charged twice.

***

### Notes

- `robots.txt` was read in full on 2026-09-04. The `User-agent: *` section disallows `/ajax/`, `/api/query` (except `?crawl=true`), advertising-slot paths, `/uk/*` and `/*?*map=1`. This Actor requests only the public search paths plus `?page=N`.
- Only public pages are read. No login, no API key.

# Actor input Schema

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

Paste an Otodom listing page, for example https://www.otodom.pl/pl/wyniki/sprzedaz/dom/mazowieckie, and the three settings below are ignored. Any query string you paste is dropped and filtering happens here instead, on the rows already fetched.

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

sprzedaz = for sale, wynajem = to rent. These are the words Otodom itself uses in the address.

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

mieszkanie (flat), dom (house), dzialka (plot), lokal (commercial unit), haleimagazyny (warehouse), garaz (garage), pokoj (room). It has to be the word Otodom uses in the address.

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

cala-polska for the whole country, a region such as mazowieckie, or a city as its full path - mazowieckie/warszawa/warszawa/warszawa, malopolskie/krakow/krakow/krakow. A bare city name such as warszawa is a 404 on Otodom and comes back as a no-such-search row, not as an empty result.

## `maxListings` (type: `integer`):

How many properties to return. You are charged for the rows you actually receive.

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

A hard stop on how many pages are requested, whatever the filters do. 0 lets the run work it out.

## `includeDescription` (type: `boolean`):

The teaser text Otodom shows on the card. Turn it off for a smaller dataset - the keyword filter still searches it either way.

## `useProxy` (type: `boolean`):

Off by default because Otodom answers Apify's own network directly. If Otodom ever starts blocking, the run switches to a proxy on its own.

## `keywords` (type: `array`):

Keep only properties whose title, description, location, features or agency contain these words. No extra requests are made.

## `keywordMatch` (type: `string`):

any: at least one keyword. all: every keyword.

## `excludeKeywords` (type: `array`):

Drop properties containing any of these words.

## `agencyNames` (type: `array`):

Keep only properties listed by these agencies. Partial names work.

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

A sale price is a total; a rent is per month, the way Otodom publishes it. The monthly administrative charge is a separate column and is not included here.

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

0 means no maximum.

## `maxPricePerSqm` (type: `integer`):

0 means no maximum. Otodom publishes this on most sale listings.

## `requirePrice` (type: `boolean`):

Off by default: a property whose price is hidden is not treated as a cheap one, it is simply kept.

## `minRooms` (type: `integer`):

-1 turns the filter off.

## `maxRooms` (type: `integer`):

-1 turns the filter off.

## `minFloor` (type: `integer`):

0 is the ground floor. -1 turns the filter off.

## `maxFloor` (type: `integer`):

-1 turns the filter off.

## `minAreaSqm` (type: `integer`):

0 means no minimum.

## `maxAreaSqm` (type: `integer`):

0 means no maximum.

## `advertiserType` (type: `string`):

Agency listings, or listings from private owners. Private owners' names are never returned.

## `exclusiveOnly` (type: `boolean`):

Otodom marks listings an agency holds exclusively.

## `listedWithinDays` (type: `integer`):

0 means no date filter. A property whose date cannot be read is kept, not dropped.

## `monitoringMode` (type: `boolean`):

Turn this on and schedule the Actor: each run returns only the properties it has never returned before, so you are charged for new stock rather than for the same pages again. Properties that did not fit under the limit are still waiting for you on the next run.

## `resetMonitoringState` (type: `boolean`):

Clears the memory for this search, so the next monitoring run starts from scratch.

## Actor input object example

```json
{
  "transaction": "sprzedaz",
  "estate": "mieszkanie",
  "location": "cala-polska",
  "maxListings": 20,
  "maxPages": 0,
  "includeDescription": true,
  "useProxy": false,
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "agencyNames": [],
  "minPrice": 0,
  "maxPrice": 0,
  "maxPricePerSqm": 0,
  "requirePrice": false,
  "minRooms": -1,
  "maxRooms": -1,
  "minFloor": -1,
  "maxFloor": -1,
  "minAreaSqm": 0,
  "maxAreaSqm": 0,
  "advertiserType": "any",
  "exclusiveOnly": false,
  "listedWithinDays": 0,
  "monitoringMode": false,
  "resetMonitoringState": false
}
```

# Actor output Schema

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

One row per property: title, price with its currency and whether it is a sale total or a monthly rent, the monthly administrative charge kept in its own column, price per sqm, area, rooms, floor, city, province and district, features, the agency, and the dates it was first listed and last pushed up. Searches with no results, filters that matched nothing, and pages that could not be read come back as their own rows and are not charged. Agent names and phone numbers are not collected.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/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 = {}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/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 '{}' |
apify call neverempty/otodom-listings --silent --output-dataset

```

## MCP server setup

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