# Otodom Scraper (`rowfeed/otodom-real-estate-scraper`) Actor

Otodom.pl real estate listings as clean JSON: price, area, rooms, floor, location, agency or private-owner flag. Search by URL or transaction/type/location. No login, no persondata.

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

## Pricing

$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

Pull **Otodom.pl real estate listings** as clean JSON: price, price per m², area, rooms, floor, city, district, street, agency or private-owner flag, no login and no browser. Built for **property analysts and proptech tools** tracking the Polish housing market, **investors** screening listings by price/area/location, **market researchers** watching supply and pricing trends across cities, and **AI agents and dashboards** that need a structured "what's for sale/rent in `<city>`" answer.

Plain HTTPS GETs against Otodom's own public search-results page (`otodom.pl/pl/wyniki/...`), the same server-rendered page a browser loads, parsed from its embedded JSON - no API key, no cookies, no headless browser.

### What you get

- **Every listing as one row** - id, URL, title, sale/rent, property type, price, price per m², area, rooms, floor, city, district, street, agency name (or a private-owner flag - never a private seller's name), creation date, image URL.
- **Any search, two ways** - drop in one or more full Otodom search URLs (pagination handled automatically), or let the Actor build one from `transaction` + `estateType` + `location` (+ optional price range).
- **A cost cap that actually caps** - `maxListings` stops the run once that many rows are billed, across however many search URLs you listed.

### No personal data, ever

Otodom's search-results JSON carries an `advertOwner.name` field with the seller's or agent's own personal name for every listing, private or agency. This Actor never reads that field - it is not mapped into any output column. Only `agency.name` (the business name of a real-estate agency, when one is attached to the listing) is ever output, and `is_private_owner` flags a listing with no agency instead of naming the individual behind it.

### Sample row

A real row from a default run (`https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/cala-polska`):

```json
{
  "id": 67913444,
  "url": "https://www.otodom.pl/pl/oferta/gotowe-do-odbioru-3-pokoje-miejsce-w-hali-garazowej-za-50-ID4AXo8",
  "title": "Gotowe do odbioru - 3 pokoje + miejsce w hali garażowej za 50%",
  "transaction_type": "SELL",
  "estate_type": "FLAT",
  "listing_kind": "standard",
  "price": 539550.0,
  "currency": "PLN",
  "price_per_m2": 9900.0,
  "area_m2": 54.5,
  "rooms": 3,
  "floor": 0,
  "city": "Osielsko",
  "district": null,
  "street": "ul. Dzikiej Gęsi",
  "latitude": null,
  "longitude": null,
  "agency_name": null,
  "is_private_owner": true,
  "market": null,
  "build_year": null,
  "created_at": "2026-04-16T12:50:17Z",
  "image_url": "https://ireland.apollo.olxcdn.com/v1/files/.../image;s=1280x1024;q=80",
  "rank_on_page": 1,
  "page": 1,
  "scraped_at": "2026-09-16T08:24:11+00:00"
}
```

`district` is populated when Otodom's own reverse geocoding resolves one for the listing (common for big cities, e.g. `"Śródmieście"` for central Warsaw addresses) and `null` otherwise, as in this rural example. `latitude`, `longitude`, `market` (primary/secondary) and `build_year` are always `null`: Otodom's search-results JSON does not include them - only the individual listing's own detail page would, and this Actor does not fetch detail pages (see Details below).

`estate_type` is Otodom's own value, verbatim - observed values are `FLAT`, `HOUSE`, `TERRAIN`, `COMMERCIAL` (matching the `estateType` filter's mieszkanie/dom/dzialka/lokal) and `INVESTMENT` (a developer's multi-unit project listed as one search result - commonly ~1/3 of rows for `mieszkanie`/`dom` searches; its price/area describe the project's range, not a single unit).

`listing_kind` is `"standard"` for a normal ranked result, or `"recommended"` for a "similar ad" slot Otodom's results page injects mid-list, re-showing an ad that already appears elsewhere on the same page under a decorated id. Otodom sends an impossible placeholder date (`1999-02-29`, which never existed) and no images for these slots; this Actor never emits that fake date - `created_at` and `image_url` are `null` on a `"recommended"` row instead, and `listing_kind` says why. These are still real listings (title, price, agency all populated), so they are kept rather than dropped.

The typed columns above are followed by Otodom's own extra raw fields, kept for completeness: `province`, `is_promoted`, `is_exclusive_offer`, `source`, `tags`, `short_description`, `rooms_raw` (the un-mapped `ONE`..`TEN`/`MORE` enum), `floor_raw` (the un-mapped `GROUND`..`ABOVE_TENTH` enum) and `total_possible_images`.

### Filters

| Input | Default | What it does |
|---|---|---|
| `searchUrls` | `[]` (empty) | Full Otodom search-results URLs. Each is paginated automatically. **Empty by default**, so a default run uses the Transaction/Estate type/Location builder below. If you fill this in, it wins over the builder fields below - even if you also changed one of them - and a warning is logged saying so. |
| `transaction` | `"sprzedaz"` | Used only when `searchUrls` is empty: `"sprzedaz"` (sale) or `"wynajem"` (rent). |
| `estateType` | `"mieszkanie"` | Used only when `searchUrls` is empty: `"mieszkanie"` (flat), `"dom"` (house), `"dzialka"` (plot) or `"lokal"` (commercial unit). |
| `location` | `"cala-polska"` | Used only when `searchUrls` is empty: `"cala-polska"` (whole country) or an Otodom location path, e.g. `"mazowieckie/warszawa/warszawa/warszawa"` (voivodeship/county/commune/city) or `".../srodmiescie"` (+ district). |
| `maxListings` | `200` | Stop after this many rows total, across all search URLs. |
| `priceMin` / `priceMax` | - | **Builder path only** - applied as `priceMin`/`priceMax` query params on the URL built from `transaction`/`estateType`/`location`. Has no effect when `searchUrls` is set: put the price range directly in the URL instead (e.g. `?priceMax=800000`). |

The default run (all fields left as-is) builds `https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/cala-polska` from the builder defaults and returns a full page of results.

Example `searchUrls` (paste into the field to use the URL path instead of the builder):

- `https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/cala-polska` (all flats for sale, whole country)
- `https://www.otodom.pl/pl/wyniki/wynajem/mieszkanie/mazowieckie/warszawa/warszawa/warszawa` (flats for rent, Warsaw)

Otodom's own URL grammar (for hand-built `searchUrls`): `/pl/wyniki/<sprzedaz|wynajem>/<mieszkanie|dom|dzialka|lokal>/<location>?priceMin=<int>&priceMax=<int>`. An unrecognized `location` segment (e.g. `.../nope/xyz`) returns HTTP 404, which this Actor turns into one uncharged error row (`error: "not_found"`) instead of failing the run; a URL that returns HTTP 200 but isn't actually an Otodom results page (no `searchAds` in its embedded JSON) likewise becomes one uncharged error row (`error: "other"`), never a silent zero-row success.

### Pricing

Pay per event, no subscription: **$1.50 per 1,000 listings** (`$0.0015` per row) - undercuts the current Store leader's $2/1,000. A default run (2 search URLs, `maxListings: 200`) costs at most $0.30. An invalid search URL (bad location, bad filters) produces one uncharged error row and the run continues with the next URL; only zero rows *plus* at least one failed request marks the whole run failed.

### Use it from your tools

- **API and SDKs** - call it via the Apify API or the official Python/JavaScript clients: one call to start the run, one to fetch the results as JSON or CSV.
- **Schedules** - run it daily inside Apify and pipe freshly-listed properties into Google Sheets, a webhook or your own database.
- **n8n, Make and Zapier** - trigger runs and feed extracted listings into a workflow through Apify's integration for each.
- **AI agents and MCP** - eligible for agentic use via Apify's MCP server with pay-per-event pricing, so an agent can call it mid-task and pay only for the listings it actually pulls.

### Details

- **Source**: `https://www.otodom.pl/pl/wyniki/...`, Otodom's own public search-results page, parsed from the `__NEXT_DATA__` JSON it embeds server-side (`props.pageProps.data.searchAds`). No authentication, no proxies, no browser.
- **Pagination**: follows Otodom's own `pagination.totalPages`, requesting up to 72 listings per page (Otodom's own maximum), until the budget (`maxListings`) or the last page is reached.
- **Search-list only**: this Actor reads the search-results page, not each listing's own detail page - faster and far less likely to be blocked (no per-listing requests to fail or rate-limit), at the cost of a few fields (coordinates, market, build year) that only the detail page carries. Documented as `null`, never guessed.
- **Reliability**: 429 and 5xx responses are retried with exponential backoff (5 tries); 404 and 401/403 are never retried; a 200 response without the expected embedded JSON counts as a failure, not a silent empty success.
- **Run stats**: the `STATS` record in the run's key-value store holds row/error counts and per-category request-error counts.
- **Politeness**: 0.5 s between requests, 30 s timeout, `Accept-Language: pl,en`, a User-Agent that names the Actor.
- **Not affiliated with Otodom / OLX Group.** Built against Otodom's own public search page; no login, no scraping of member-only content.

# Actor input Schema

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

Full Otodom search-results URLs to scrape, e.g. "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/cala-polska" or "https://www.otodom.pl/pl/wyniki/sprzedaz/dom/cala-polska?priceMax=800000". Each is paginated automatically. Leave empty (the default) to build one URL from Transaction/Estate type/Location below instead. If you set both, searchUrls wins and the builder fields below are ignored (a warning is logged).

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

Sale or rent.

## `estateType` (type: `string`):

Property type.

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

"cala-polska" for the whole country, or an Otodom location-tree path such as "mazowieckie/warszawa/warszawa/warszawa" (voivodeship/county/commune/city) or "mazowieckie/warszawa/warszawa/warszawa/srodmiescie" (+ district). An unrecognized location returns an error row, not a crash.

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

Stop after this many rows total across all search URLs. Each row is one billed listing (see Pricing).

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

Minimum price in PLN, applied when building the search URL from Transaction/Estate type/Location.

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

Maximum price in PLN, applied when building the search URL from Transaction/Estate type/Location.

## Actor input object example

```json
{
  "searchUrls": [
    "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/cala-polska",
    "https://www.otodom.pl/pl/wyniki/wynajem/mieszkanie/mazowieckie/warszawa/warszawa/warszawa"
  ],
  "transaction": "sprzedaz",
  "estateType": "mieszkanie",
  "location": "cala-polska",
  "maxListings": 200
}
```

# Actor output Schema

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

No description

# 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("rowfeed/otodom-real-estate-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 = {}

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,rowfeed/otodom-real-estate-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/AmJtBrHZQ3Elyftcp/builds/vG4xK2zQUiabfm5xQ/openapi.json
