# Serbia Real Estate Scraper — 4zida & Halooglasi (`smolevich90/serbia-real-estate-scraper`) Actor

Scrape Serbian property listings from 4zida.rs and Halooglasi with cross-portal duplicates collapsed into one row per unique flat.

- **URL**: https://apify.com/smolevich90/serbia-real-estate-scraper.md
- **Developed by:** [Stanislav Shupilkin](https://apify.com/smolevich90) (community)
- **Categories:** Real estate
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 unique 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?

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

## Serbia Real Estate Scraper — 4zida & Halooglasi

**Pay for unique listings, not duplicate rows.** Serbia's two big property portals are scraped in one run, and the same flat advertised on both of them — by the owner, by three agencies, and once more with a different photo — comes back as **one row**, with every other posting attached to it. In a live Vračar run 47 raw listings collapsed to 37 unique flats: 21% of what a normal scraper would have billed you for, and then made you deduplicate yourself. Collapsed duplicates are charged at $0.0002 instead of $0.0015, so the cleanup is not just free, it's most of your bill removed.

No browser, no API keys. Give it a city and press start. Halooglasi is fetched through the Apify Residential proxy by default — that is the one moving part, and it is explained under [Proxy and Halooglasi](#proxy-and-halooglasi) below.

### What you get

- **Both portals in one dataset** — [4zida.rs](https://www.4zida.rs) and [Halooglasi.com](https://www.halooglasi.com), the same fields, the same shape, already merged.
- **Cross-portal deduplication** — the hard part. Matching is done on metadata (price, area, rooms, floor, distance, street) *and* on the perceptual hash of the cover photo, so a repost with a rewritten title and a different price still lands in the same row.
- **Every duplicate's URL kept** — the `duplicates` array shows where else the flat is listed and at what price, which is exactly where you see one agency quoting €5,000 more than the owner.
- **Price per m² computed** for you, plus coordinates, municipality, street, floor, seller type (owner or agency) and photo URLs.

### Example output

One row is one flat, no matter how many portals were advertising it:

```json
{
  "portal": "halooglasi",
  "url": "https://www.halooglasi.com/nekretnine/prodaja-stanova/cubura-21m2-renoviran-ta/5425647609842",
  "title": "Čubura, 21m2, renoviran, ta",
  "price_eur": 84000,
  "area_m2": 21.0,
  "price_per_m2": 4000,
  "rooms": 0.5,
  "floor": null,
  "floor_num": null,
  "floor_total": null,
  "municipality": "Vračar",
  "address": "Daničareva",
  "lat": 44.7982,
  "lon": 20.4861,
  "deal": "sale",
  "property_type": "apartment",
  "seller": "agency",
  "photos": [
    "https://img.halooglasi.com/slike/oglasi/Thumbs/260831/l/cubura-21m2-st-renoviran-ta-5425647609842-71816870839.jpg"
  ],
  "posted_at": "2026-08-31T18:22:52+00:00",
  "duplicate_count": 1,
  "duplicates": [
    {
      "portal": "4zida",
      "url": "https://www.4zida.rs/prodaja-stanova/crveni-krst-vracar-vracar-beograd/garsonjera/6a959e7a7bfd40f29403ed46",
      "price_eur": 84000
    }
  ],
  "is_duplicate": false,
  "duplicate_of": null
}
```

That row is from a real run: the same 21 m² studio at €84,000 was on Halooglasi as "Čubura, renoviran" and on 4zida as a "garsonjera" in Crveni krst — two names, two portals, one flat, one row.

Export it as JSON, CSV, Excel or XML, or pull it from the Apify API into your own pipeline.

### How to run it

1. Put a city or a Belgrade municipality into **City or municipality** — `Beograd`, `Novi Sad`, `Niš`, `Vračar`, `Zemun`, `Novi Beograd`. Serbian diacritics are optional: `vracar` finds Vračar.
2. Leave both portals selected. That is where cross-portal duplicates come from — one portal alone still deduplicates agency reposts, but you lose half the point.
3. Choose sale or rent, the property type, and optional price and area ranges.
4. Set **Max unique listings** and run it.

### Input

| Field | What it does | Default |
|---|---|---|
| `location` | City or Belgrade municipality to search | `Beograd` |
| `portals` | `4zida`, `halooglasi` | both |
| `dealType` | `sale` or `rent` | `sale` |
| `propertyType` | `apartment`, `house`, `garage`, `commercial` | `apartment` |
| `maxItems` | How many unique objects to output | 100 |
| `minPrice` / `maxPrice` | Price range in EUR (both portals quote in EUR) | no limit |
| `minArea` / `maxArea` | Area range in m² | no limit |
| `includeDuplicates` | Also emit the collapsed duplicates as their own rows | false |
| `maxPhotos` | How many photo URLs to keep per row | 5 |
| `proxyConfiguration` | Proxy for Halooglasi only (see below) | Apify Residential |

`includeDuplicates` changes the shape of the output, never the price: with it off you get one row per flat with a `duplicates` array; with it on the duplicates get their own rows flagged `is_duplicate` and pointing at the winner through `duplicate_of`. Either way you pay per object.

### Pricing: pay per event

| Event | Price |
|---|---|
| `unique-listing` — one unique flat in your dataset | $0.0015 |
| `duplicate-collapsed` — one duplicate found and merged away | $0.0002 |

100 unique flats with 30 duplicates collapsed costs $0.156. The same 130 rows billed as unique listings would have been $0.195 — and you would still have to deduplicate them yourself.

Set a maximum spend on the run and the Actor stops the moment your limit is reached, finishing successfully with everything collected up to that point.

### Proxy and Halooglasi

Halooglasi sits behind Cloudflare, and from Apify's datacenter IPs Cloudflare returns a challenge instead of the page — the block is on IP reputation, so a correct browser TLS handshake alone does not get through. The Actor therefore fetches Halooglasi through the **Apify Residential** proxy, and only Halooglasi: 4zida and every photo (from both portals' CDNs) go direct, because they are never challenged. That keeps paid proxy traffic to the Halooglasi HTML alone.

The trade-off is cost. Residential proxy is billed at **$8/GB**, and that traffic is paid by the Actor, not added to your per-event bill — it comes out of the developer's margin, so a Halooglasi-heavy run is deliberately kept lean (cover photo only, one request per listing). Your options in `proxyConfiguration`:

- **Apify Residential (default)** — Halooglasi works, cross-portal deduplication works. Recommended.
- **Turn Apify Proxy off** — the run drops to 4zida only. No Halooglasi, so nothing to deduplicate *across* portals; you still get 4zida's own agency-repost dedup. Cheapest, and honest about it.
- **Datacenter group** — will almost certainly hit the same Cloudflare block as no proxy at all; not recommended for Halooglasi.

### How the deduplication actually works

Two listings become one object when the portals agree on the flat, not on the wording:

- **Geo edge** — within 70 m of each other, same floor, same street (where both state one), price within 35%, area within 4 m². On a coordinate where many *different* flats sit — a shared building entrance or a district centroid the portal falls back to — the area tolerance tightens to 1 m², because proximity there proves nothing.
- **Photo edge** — the cover photos have a difference hash within 10 bits of each other and the metadata does not forbid the match. This is what catches listings that carry no coordinates at all, roughly a third of one portal's inventory. A photo shared by more than eight listings is treated as a building facade or a floor plan and ignored.
- **Sanity check** — a merged group has to look like one flat: at most two distinct streets, area spread under 10 m², price spread under 1.5×. A group that fails is rebuilt on stricter rules, and if it still fails it is broken apart. Showing one flat twice is better than hiding two.
- **The winner** is the most complete card — coordinates, street, area, price, date, most photos — not whoever posted first.

Thresholds come from a deduplication engine tuned on 18,404 live Belgrade listings, not from guesswork.

### Limits worth knowing before you run it

- **Only the cover photo is hashed.** A repost whose first photo is its own but whose third and seventh match will not be linked by photo — it can still be linked by metadata.
- **Deduplication is per run.** The Actor does not remember yesterday's dataset; two separate runs are deduplicated separately.
- **A listing with no price is never merged by metadata alone** — there is nothing left to check it against.
- **Halooglasi filters by city, not by municipality.** Asking for Vračar fetches Belgrade pages and drops what is not Vračar, so a narrow municipality search costs more requests than a city-wide one.
- **Coordinates are the portal's**, and portals sometimes drop a listing on the district centroid instead of the building. That is exactly what the hub rule above exists for.

# Actor input Schema

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

Where to search: a city (Beograd, Novi Sad, Niš, Kragujevac, Subotica…) or a Belgrade municipality (Vračar, Savski Venac, Novi Beograd, Zemun…). Spelling with or without Serbian diacritics both work.

## `portals` (type: `array`):

Which portals to scrape. Keeping both is the point — that is where cross-portal duplicates come from.

## `dealType` (type: `string`):

Listings offered for sale or for rent.

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

Apartments (stan), houses (kuća), garages and parking (garaža) or commercial space (poslovni prostor).

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

How many unique objects to output. Each portal is scraped up to this many raw listings before duplicates are collapsed, so the dataset can come back shorter than the limit — that is the deduplication doing its job.

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

Leave empty for no lower bound. Both portals price in EUR.

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

Leave empty for no upper bound.

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

Leave empty for no lower bound.

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

Leave empty for no upper bound.

## `includeDuplicates` (type: `boolean`):

Off: one row per unique object, with every duplicate listed in its `duplicates` array. On: the duplicate listings get their own rows too, flagged with `is_duplicate` and `duplicate_of`. The price is the same either way — you are charged per object, not per row.

## `maxPhotos` (type: `integer`):

How many photo URLs to keep in each row. Photos are linked from the portal's own CDN, never rehosted.

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

Halooglasi sits behind Cloudflare, which blocks Apify's datacenter IPs — so Halooglasi is fetched through this proxy, and only Halooglasi (4zida and photos go direct, to keep proxy traffic minimal). Residential is the default because datacenter proxies get the same block; it costs $8/GB of Halooglasi HTML, which the Actor developer pays. Turn Apify Proxy off here to skip Halooglasi's anti-bot entirely and run on 4zida alone — cross-portal deduplication then has nothing to cross to.

## Actor input object example

```json
{
  "location": "Beograd",
  "portals": [
    "4zida",
    "halooglasi"
  ],
  "dealType": "sale",
  "propertyType": "apartment",
  "maxItems": 100,
  "includeDuplicates": false,
  "maxPhotos": 5,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

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

One row per unique flat across 4zida.rs and Halooglasi.

# 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": "Beograd",
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("smolevich90/serbia-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 = {
    "location": "Beograd",
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("smolevich90/serbia-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 '{
  "location": "Beograd",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call smolevich90/serbia-real-estate-scraper --silent --output-dataset

```

## MCP server setup

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