# Portugal Real Estate Scraper (Imovirtual, OLX, Idealista) (`imoleadaipro/portugal-real-estate-scraper`) Actor

One normalized dataset from Imovirtual, OLX, CustoJusto and Idealista: price, EUR/m2, T0-T5 typology, parish/municipality, private seller vs agency (native portal flags), phone, AMI licence, no-agencies flag, price history and days on market. Built for Portuguese agents, CRMs and market analysts.

- **URL**: https://apify.com/imoleadaipro/portugal-real-estate-scraper.md
- **Developed by:** [Shalon Soluções Tecnologicas](https://apify.com/imoleadaipro) (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 $5.00 / 1,000 listing with contact details

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## Portugal Real Estate Scraper — Imovirtual, OLX, CustoJusto, Idealista (one schema)

Scrape Portuguese property portals into **one normalized dataset**: price, €/m², typology (T0–T5), parish / municipality / district, **private seller vs. agency**, phone, AMI licence, "no agencies" flag, price history and days on market. Built by a Portuguese proptech founder for real-estate agents, CRMs and market analysts who need Portugal data that is actually usable — not four portals with four different field names.

### Why this scraper

| | This actor | Typical single-portal scrapers |
|---|---|---|
| Portals | Imovirtual, OLX, CustoJusto, Idealista (via orchestration), SuperCasa (soon) | one |
| Schema | one `ImovelListing` for every portal | per-portal fields |
| Private vs. agency | native portal flags (`isPrivateOwner`, `business`, `companyAd`) — never guessed | usually missing |
| Phone | Imovirtual (agencies + private), OLX (via portal API), Idealista | varies |
| "Não aceito agências" | `noAgenciesFlag` — from the portal's own flag on CustoJusto, text detection elsewhere | — |
| Same property on 4 portals | **recognized and marked** (`duplicateOf`), or not delivered at all — and not charged | delivered 4 times, you clean it up |
| Phone numbers | **all of them** (`phones[]`) — agencies commonly publish two: office landline and agent mobile | the first one, if any |
| Price history & days on market | persistent across runs (`priceHistory`, `priceChangePct`, `daysOnMarket`) | — |
| Location | Portuguese admin hierarchy: `district` / `municipality` / `parish`, plus GPS when the portal has it | free text |

### Input

```json
{
  "portals": ["imovirtual", "olx", "custojusto"],
  "operation": "sale",
  "propertyType": "apartment",
  "locations": ["lisboa", "cascais", "porto"],
  "sellerType": "private",
  "maxItemsPerPortal": 300,
  "fetchDetails": true,
  "priceHistory": true,
  "deduplicate": "drop"
}
```

- `locations`: district or municipality names, lowercase, no accents (`lisboa`, `setubal`, `vila nova de gaia`). Municipalities are resolved to each portal's own geography.
- `startUrls`: alternatively paste search-result URLs from any supported portal; the portal is detected from the domain.
- `sellerType: "private"` filters on the portal where possible and always on the output.
- `fetchDetails: true` opens the listing page (phone, full description, energy certificate, year built). Doubles the requests — it is a separate, more expensive event.
- `idealistaMode: "actor"` (default) runs Idealista through a Store actor that uses the mobile API — far cheaper than a browser behind DataDome. Billed to your account by that actor. `"browser"` uses the built-in Playwright adapter (needs residential proxy).

### Output — one real item (phone masked)

```json
{
  "id": "imovirtual:19268265",
  "portal": "imovirtual",
  "url": "https://www.imovirtual.com/pt/anuncio/apartamento-t3-com-estacionamento-e-vista-desafogada-sobre-o-rio-sob-ID1iQyt",
  "operation": "sale",
  "propertyType": "apartment",
  "title": "Apartamento T3 com estacionamento e vista desafogada sobre o rio",
  "price": 525000,
  "currency": "EUR",
  "pricePerM2": 3454,
  "typology": "T3",
  "bedrooms": 3,
  "bathrooms": 2,
  "areaM2": 152,
  "floor": "floor_3",
  "yearBuilt": 2013,
  "energyCertificate": "A",
  "condition": "used",
  "district": "Lisboa",
  "municipality": "Vila Franca de Xira",
  "parish": "Alverca do Ribatejo e Sobralinho",
  "locationRaw": "Rua do Bom Jesus, Sobralinho, Alverca do Ribatejo e Sobralinho, Vila Franca de Xira, Lisboa",
  "latitude": 38.920174,
  "longitude": -9.024194,
  "sellerType": "agency",
  "sellerName": "Century21 Nações",
  "phone": "+3512189xxxxx",
  "phones": ["+3512189xxxxx", "+3519123xxxxx"],
  "agencyLicense": "AMI 10786",
  "noAgenciesFlag": false,
  "publishedAt": "2026-09-07",
  "firstSeenAt": "2026-09-08T08:51:41.849Z",
  "daysOnMarket": 1,
  "priceHistory": [{ "seenAt": "2026-09-08T08:51:41.849Z", "price": 525000 }],
  "priceChangePct": null,
  "duplicateOf": null,
  "duplicateOfPortal": null,
  "duplicateOfUrl": null,
  "images": ["https://ireland.apollo.olxcdn.com/v1/files/…/image;s=1280x1024;q=80"],
  "scrapedAt": "2026-09-08T08:51:41.849Z"
}
```

Every field exists for every portal; what a portal does not publish comes back as `null`, never as a missing key. `raw` keeps the portal's original fields for anything not mapped.

### Portal coverage (validated 2026-09-15)

| Portal | Source | Private/agency | Phone | Notes |
|---|---|---|---|---|
| Imovirtual | embedded JSON | native flag | agencies + private sellers | AMI licence, GPS, year built, energy |
| OLX | portal's public JSON API | native flag | yes, via the portal's phone endpoint | OLX limits phone reveals per session; the actor rotates sessions |
| CustoJusto | embedded JSON | native flag | no (portal requires login) | native `noSalesmen` ("no agencies") and previous price |
| Idealista | Store actor (orchestrated) | mapped | yes | 0,55 $/1 000 charged by that actor to your account |
| SuperCasa | — | — | — | behind a Cloudflare challenge; on the roadmap |

#### How often is there a phone

Measured on 2026-09-15, Lisbon, real runs — not an estimate:

| Portal | Listings with at least one phone | Notes |
|---|---|---|
| Imovirtual | ~100% of agency listings, and 4 in 6 published **two** numbers | the strongest source |
| OLX | ~15–30% | OLX allows roughly two phone reveals per IP before returning `Disallowed for this user`; the actor rotates sessions, so coverage rises with residential proxy and falls without |
| CustoJusto | 0% | the portal requires a login to show any number |

If phone coverage is what you are buying, run Imovirtual and Idealista, and treat OLX as a bonus.
We would rather tell you this here than have you find out after paying.

### Watch mode — only what is new

New listings are worth more than old ones. A private seller who posts today takes ten calls from
agencies within 48 hours; whoever reaches them first wins. So `deliver` lets you ask for the
delta instead of the whole market:

| Value | What you get |
|---|---|
| `all` (default) | every listing found |
| `new` | only listings this actor has never seen before |
| `new-or-changed` | new ones, plus listings whose price moved since the last run |

Listings you already know are **not delivered and not charged**. Measured on 2026-09-15, OLX,
Lisbon, private sellers only: a second pass examined 40 listings, delivered **8**, and skipped
32 as already seen — the whole run cost $0.0035. Schedule it hourly and you get a handful of
genuinely new ads per pass instead of re-importing the same market every time.

In watch mode `maxItemsPerPortal` caps listings **examined**, not delivered — otherwise the
actor would page through an entire portal hunting for new ads that may not exist yet.

Needs `priceHistory: true`; that is where the state between runs lives.

#### How many new listings are there, really

Measured the same day, OLX Lisbon, private sellers, for sale — the full set, not a sample:

| Window | New listings |
|---|---|
| last 24 hours | 6 |
| last 3 days | 21 |
| last 7 days | 48 |
| last 30 days | 163 |

Between 4 and 9 per day, steady across two weeks. That is the honest size of this feed for one
district on one portal: small enough that one person can work every single one — which is the
point.

### The same property, four times

Portugal's portals overlap heavily. Run four single-portal scrapers over Lisbon and the same
apartment arrives four times, with four ids and often four slightly different prices. Cleaning
that up is the job you inherit.

This actor does it for you. `deduplicate` takes three values:

| Value | What happens |
|---|---|
| `mark` (default) | every listing is delivered; repeats carry `duplicateOf`, `duplicateOfPortal` and `duplicateOfUrl` pointing at the first one |
| `drop` | each property is delivered **once** — and the repeats are not charged |
| `off` | no comparison at all |

Two listings are only merged when **two independent pieces of evidence** agree: same phone *and*
same area (±2%); or same GPS to four decimals (~11 m) *and* same typology; or same municipality,
typology, area (±2%) *and* price (±3%). A shared phone alone is not enough — one seller can own
two flats. When in doubt it does not merge: an extra row is noise, a collapsed property is
information you cannot get back.

### Use cases

- **Private-seller leads**: `sellerType: "private"` + `fetchDetails: true` → name, phone, and whether the ad says "no agencies". Respect that flag.
- **Price-drop radar**: schedule daily runs; `priceChangePct` and `priceHistory` tell you who lowered the price this week.
- **Market studies**: `pricePerM2` by parish, average `daysOnMarket`, share of private vs. agency listings.
- **CRM feed**: run on a schedule, receive a webhook, or read the dataset by API (JSON / CSV / Excel).

### Pricing

Pay per event — you pay per listing delivered, nothing else:

| Event | Price | When |
|---|---|---|
| `listing` | $0.002 | one listing from search results (`fetchDetails: false`) |
| `listing-with-details` | $0.005 | one listing with the detail page opened — phone, description, energy, year |
| Actor start | $0.005 / GB | per run |

Set a maximum spend per run in the Console; the actor stops cleanly when it is reached.

### Proxies

Imovirtual, OLX and CustoJusto work with datacenter proxies (included). Idealista in `browser` mode needs a residential proxy with country `PT`; the actor enforces it.

### Legal

Data comes from public listing pages. Seller names and phone numbers are personal data under the GDPR — you are responsible for the lawful basis of what you do with them, and for Portugal's rules on unsolicited commercial contact (Lei 41/2004). Honour `noAgenciesFlag`. Do not republish descriptions or photos.

### Local development

```bash
npm install && npx playwright install chromium
npm test              # 12 tests, no browser needed
npm run start:dev     # reads storage/key_value_stores/default/INPUT.json
apify push            # deploy
```

# Actor input Schema

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

Portais a pesquisar. Recomendado começar por Imovirtual + OLX + CustoJusto (sem anti-bot pesado) e activar Idealista só com proxy residencial.

## `operation` (type: `string`):

Comprar ou arrendar. Aplica-se a todos os portais escolhidos.

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

Restringe o tipo de imóvel. «Qualquer» percorre todos os tipos que o portal listar.

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

Concelhos ou distritos em português, sem acentos e em minúsculas (ex.: lisboa, porto, cascais, setubal). O actor traduz para o URL de cada portal.

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

Em alternativa a locations: cole URLs de resultados de pesquisa de qualquer portal suportado. O portal é detectado pelo domínio.

## `sellerType` (type: `string`):

Filtro aplicado no URL quando o portal o suporta e, em qualquer caso, na saída.

## `deduplicate` (type: `string`):

O mesmo apartamento é anunciado no OLX, no Imovirtual e no CasaSapo. "Marcar" entrega-o na mesma com o campo duplicateOf a apontar ao primeiro; "Não entregar" devolve cada imóvel uma só vez — e as repetições não são cobradas.

## `deliver` (type: `string`):

"Novos" e "novos ou com preco alterado" so entregam o que mudou desde a ultima passagem — e so isso e cobrado. Use-os com um agendamento (de hora a hora, por exemplo) para falar com um anunciante antes da concorrencia. Precisam do historico de precos ligado.

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

Ignora anúncios abaixo deste valor. Deixar vazio para não filtrar.

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

Ignora anúncios acima deste valor. Deixar vazio para não filtrar.

## `maxItemsPerPortal` (type: `integer`):

Trava de custo e de tempo: pára cada portal ao atingir este número. Com os 3 portais por omissão são até 60 resultados em ~4 minutos. Medido a 2026-09-16: 50 por portal com detalhe levava 10 a 12 minutos e o teste automático diário da Apify não esperava tanto.

## `fetchDetails` (type: `boolean`):

Necessário para telefone, descrição completa, certificado energético e ano de construção. Duplica o número de pedidos.

## `priceHistory` (type: `boolean`):

Guarda o último preço visto de cada anúncio numa key-value store nomeada e devolve priceHistory + daysOnMarket em runs seguintes.

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

Para Idealista e SuperCasa use proxy residencial com countryCode PT. Para Imovirtual/OLX/CustoJusto datacenter costuma chegar.

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

Manter baixo (2–4) para portais com DataDome.

## `idealistaMode` (type: `string`):

'actor' chama dz\_omar/idealista-scraper-api e normaliza o resultado (0,55 $/1.000, cobrado à sua conta). 'browser' usa o adaptador Playwright interno.

## `debug` (type: `boolean`):

Escreve no log cada pedido, selector e descarte. Usar só a investigar um portal que mudou de HTML.

## Actor input object example

```json
{
  "portals": [
    "imovirtual",
    "olx",
    "custojusto"
  ],
  "operation": "sale",
  "propertyType": "any",
  "locations": [
    "lisboa"
  ],
  "sellerType": "any",
  "deduplicate": "mark",
  "deliver": "all",
  "maxItemsPerPortal": 20,
  "fetchDetails": true,
  "priceHistory": true,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "PT"
  },
  "maxConcurrency": 3,
  "idealistaMode": "actor",
  "debug": false
}
```

# Actor output Schema

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

Every listing found, normalized: price, EUR/m2, typology, area, parish/municipality/district, private seller vs agency, all published phone numbers, AMI licence, price history, days on market, and duplicateOf when the same property was already delivered from another portal.

# 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 = {
    "portals": [
        "imovirtual",
        "olx",
        "custojusto"
    ],
    "locations": [
        "lisboa"
    ],
    "maxItemsPerPortal": 20,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "PT"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("imoleadaipro/portugal-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 = {
    "portals": [
        "imovirtual",
        "olx",
        "custojusto",
    ],
    "locations": ["lisboa"],
    "maxItemsPerPortal": 20,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "PT",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("imoleadaipro/portugal-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 '{
  "portals": [
    "imovirtual",
    "olx",
    "custojusto"
  ],
  "locations": [
    "lisboa"
  ],
  "maxItemsPerPortal": 20,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "PT"
  }
}' |
apify call imoleadaipro/portugal-real-estate-scraper --silent --output-dataset

```

## MCP server setup

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