# Complete Guide: How to Scrape Property Data Using Imovirtual Scraper

This complete guide to Imovirtual Scraper shows how to scrape Portuguese property listings from Imovirtual with Python through the Apify API, and get back one typed row per listing: title, asking price or monthly rent in euros, price per m², area, rooms, floor, the address down to parish and street, the agency or developer, and when the listing was created and last bumped.

Imovirtual ([imovirtual.com](http://imovirtual.com)) is a Portuguese real-estate portal covering apartments, houses, land, commercial property, warehouses and garages, for sale or for rent. Its search pages are built for people browsing cards. For a spreadsheet, a price-per-m² comparison or a feed of new listings, you want the same data as rows.

You will configure the **Imovirtual Scraper** Apify Actor (`reventadata/imovirtual-scraper`), run it from Python, read its rows and use its filters. Prerequisites: Python 3.11+, the `apify-client` package and an Apify API token. Expect one sitting. The sample row in this guide is a sample from a capture dated 2026-10-20, parsed with the Actor's own parser; this guide quotes no run of its own.

---

## TL;DR

In this tutorial, you'll learn how to scrape Imovirtual with the Imovirtual Scraper Apify Actor from Python. Key steps: install `apify-client`, export `APIFY_API_TOKEN`, choose `transaction`, `propertyType` and a `location` slug, optionally narrow by typology, price, area or private owners, call `reventadata/imovirtual-scraper`, and read the rows from the run's default dataset. Every filter goes into Imovirtual's own search URL.

Expected outcome: a JSON list of listings with `price`, `pricePerSquareMeter`, `areaM2` and the parish, council and district of each, ready for a spreadsheet or database.

The Actor: [apify.com/reventadata/imovirtual-scraper](https://apify.com/reventadata/imovirtual-scraper).

---

## Prerequisites and setup

You will need:

1. Python 3.11+ (`apify-client` 3.3.0 requires it).
2. The Apify API client for Python.
3. An Apify account. Apify Console is Apify's web interface; your API token is on its **API & Integrations** page. The platform documentation starts at [docs.apify.com](https://docs.apify.com/).

Install the client:

```bash
pip install "apify-client~=3.0"
```

The token is a secret. Apify's docs advise giving each token a description and never using one token for several services. The code in this guide reads it from the environment, so it never sits in a script or a commit.

Set the token in your environment:

```bash
export APIFY_API_TOKEN="your_apify_token_here"
```

---

## Understanding Imovirtual Scraper

### What it does and how it works

1. **URL building.** From your input the Actor builds the search URL Imovirtual's own interface produces: `/pt/resultados/{comprar|arrendar}/{type}[,t{n}]/{location}`, with `priceMin`, `priceMax`, `areaMin`, `areaMax`, owner type and sort order as query parameters, and `?page=N` from page 2 on. An empty numeric filter is left out, never sent as 0.
2. **Transport.** Plain requests, no proxy by default. Requests with no proxy reached [imovirtual.com](http://imovirtual.com) when the Actor was tested on 2026-10-20.
3. **Parsing.** Imovirtual embeds the search results as JSON in its own page. The Actor reads that data, so no browser is involved. Each page holds 36 listings; page 1 adds a premium tile, repeated on later pages, which the Actor saves once.
4. **Stopping.** The walk ends at `maxItems`, at `maxPages`, or at the last page the site reports for the search.

### Limitations

- Typology goes up to `t5`; `sortBy` offers relevance, cheapest first and newest, not most expensive first.
- A row carries no phone number or e-mail address; the listing `url` leads to the ad page.
- The Actor reads search pages only, never the individual ad pages, so it returns what the search result carries.

---

## Step-by-step walkthrough

### Step 1: Initialize the client and define inputs

Three inputs define the search. `transaction` is `buy` or `rent`. `propertyType` is one of `apartamento`, `moradia`, `terreno`, `imoveis-comerciais`, `armazens` or `garagem`. `location` is Imovirtual's slug from a search URL: a district (`lisboa`, `porto`), a council (`lisboa/lisboa`, `porto/porto`) or `todo-o-pais` for the whole country. `maxItems` (default 1000) caps saved rows and `maxPages` caps result pages.

This setup searches apartments for sale in Lisboa district and reads one result page:

```python
import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Define the search: apartments for sale in Lisboa district, one result page
run_input = {
    "transaction": "buy",
    "propertyType": "apartamento",
    "location": "lisboa",
    "maxPages": 1,
}
```

Two more configurations, as JSON input:

- **Lisbon city T2 flats under 400,000 EUR:** `{"transaction": "buy", "propertyType": "apartamento", "location": "lisboa/lisboa", "typology": "t2", "maxPrice": 400000, "maxItems": 200}` (the README's sample input).
- **Porto rentals, cheapest first:** `{"transaction": "rent", "propertyType": "apartamento", "location": "porto/porto", "sortBy": "priceAsc", "maxPages": 2}`.

To find a slug, search on [imovirtual.com](http://imovirtual.com) and copy the URL part after the property type.

### Step 2: Configure filters

Every filter is sent to Imovirtual in the URL:

- `typology`: `t0` to `t5`, the Portuguese bedroom count.
- `minPrice` / `maxPrice`: price band in EUR (monthly rent on rent searches).
- `minArea` / `maxArea`: area band in m².
- `ownerType`: `any`, or `private` for private owners only.
- `sortBy`: `relevance`, `priceAsc` or `newest`.

Measured on 2026-10-20 for apartments for sale in Lisboa district: `maxPrice: 300000` cut the site's count to 2,158 listings, and private owners only to 174, against 17,210 with no filter.

A `typology`, `sortBy`, `ownerType`, `transaction` or `propertyType` outside its list is refused before the run starts.

### Step 3: Choose the scope

`location` sets the scope, and a district is wider than it sounds. In the capture, page 1 of the `lisboa` search spanned 8 councils: 18 rows in Lisboa itself, 6 in Loures, 5 in Vila Franca de Xira, and the rest in Sintra, Odivelas, Oeiras, Mafra and Amadora. Use `lisboa/lisboa` for the city only.

### Step 4: Run the Actor

`.call()` starts the run and waits for it to finish:

```python
# 3. Start the Actor run and wait for it to finish
print("Starting Imovirtual extraction...")
run = client.actor("reventadata/imovirtual-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the saved listings
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} listings.\n")
```

### Step 5: Process the rows

```python
seen_urls = set()
for item in items:
    # Skip a second id that points to a listing already printed
    if item["url"] in seen_urls:
        continue
    seen_urls.add(item["url"])
    print(item["title"])
    price = item.get("price")
    print(f"  Price: {price:,.0f} EUR" if price is not None else "  Price: not published")
    print(f"  Per m2: {item.get('pricePerSquareMeter')} EUR, area {item.get('areaM2')} m2")
    print(f"  Rooms: {item.get('roomsLabel')}, floor {item.get('floor')}")
    print(f"  Where: {item.get('parish')}, {item.get('council')}")
    print(f"  Seller: {item.get('agencyName') or 'private owner'}")
    print("-" * 40)
```

### Step 6: Inspect a real row

A sample from a capture dated 2026-10-20 (page 1 of the search above), parsed with the Actor's own parser:

```json
{
    "id": "19316271",
    "url": "https://www.imovirtual.com/pt/anuncio/apartamento-t3-a-venda-na-praca-das-novas-nacoes-arroios-ID1j32L",
    "title": "Apartamento T3 à venda na Praça das Novas Nações, Arroios",
    "propertyType": "FLAT",
    "transaction": "SELL",
    "price": 555000.0,
    "currency": "EUR",
    "pricePerSquareMeter": 6686.75,
    "priceFromPerSquareMeter": null,
    "areaM2": 83.0,
    "rooms": 4,
    "roomsLabel": "FOUR",
    "floor": "SECOND",
    "street": "Praça das Novas Nações",
    "neighborhood": "Anjos",
    "parish": "Arroios",
    "council": "Lisboa",
    "district": "Lisboa",
    "agencyName": "Century 21 CasaBela",
    "agencyType": "AGENCY",
    "advertiserName": "Century 21 CasaBela",
    "contactNames": [
        "Sofia Dias"
    ],
    "isPrivateOwner": false,
    "isPromoted": false,
    "isExclusiveOffer": false,
    "createdAt": "2026-10-20T18:14:18+00:00",
    "pushedUpAt": null,
    "imageUrl": "https://ireland.apollo.olxcdn.com/v1/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmbiI6Ijd0a3lyMXJxdTdnYjMtRUNPU1lTVEVNIiwidyI6W3siZm4iOiI2OW5scGE3ZWNxbTcxLUFQVCIsInMiOiIxNiIsImEiOiIwIiwicCI6IjEwLC0xMCJ9XX0.YzJy2krk122zcGgyU5_lDEsJbUD59FTBaXjmIhte5Qk/image;s=1280x1024;q=80",
    "searchUrl": "https://www.imovirtual.com/pt/resultados/comprar/apartamento/lisboa"
}
```

### Step 7: Export

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL, from Console or through the API's `format` parameter.

---

## Working with the API

The Python client gives two ways to start this Apify Actor. `client.actor("reventadata/imovirtual-scraper").call(run_input=run_input)` starts the run and waits for it to finish before returning the run object, or `None`; that is what the code above uses. For long runs or a job queue, `.start(run_input=run_input)` returns the run object at once, and you read it back later with its ID.

Authentication is the API token in `APIFY_API_TOKEN`; keep it out of source control. Once a run finishes, `client.dataset(run.default_dataset_id).list_items().items` returns the rows in one call, and `iterate_items()` pages through them for larger datasets, with a `fields` argument to fetch only the columns you need.

For event-driven pipelines, attach a webhook for the `ACTOR.RUN.SUCCEEDED` event. Its payload's `resource` is the response of the Get Actor run API endpoint, so it carries `defaultDatasetId`, and your handler can fetch the rows without polling.

Error handling: check `run.status` as the code does. The status is `SUCCEEDED`, `FAILED`, `TIMED-OUT` or `ABORTED`. A run that saves no rows ends `FAILED` when it is blocked or cannot read a page, and a run with bad input ends `FAILED` too; a search that loads normally but matches nothing ends `SUCCEEDED` with an empty dataset and a status message saying so. When a run fails, read its log from Python with `client.log(run.id).get()`. A slug Imovirtual does not know ends the run with a 404 error that says so.

Limits: the account's memory and concurrent-run limits apply to every start. `call()` accepts `wait_for_resources`, which keeps retrying the start while the API rejects it for either limit.

---

## Advanced usage patterns

### Several searches

One run covers one search. To compare places or property types, start one run per search, for example one per council slug, and tag each dataset by its `searchUrl`, which every row carries.

### Scheduled runs

Save the input as a task and add an Apify schedule. Schedules run an Actor or task on a cron expression; `0 8 * * *` is every day at 8 AM. With `sortBy: "newest"` and a small `maxPages`, each run reads the most recently listed properties first.

### Monitoring

Combine the schedule with a webhook on `ACTOR.RUN.SUCCEEDED` to load each new dataset into your database, and treat a `FAILED` run as a signal: with this Actor it means a wrong slug, or that no rows were saved because of a block or a page the parser no longer recognises. An over-strict filter ends `SUCCEEDED` with an empty dataset, so check the item count or the status message for that. The run log says which.

### Volume control

`maxItems` and `maxPages` are the two bounds. Without `maxPages` the walk runs to the last page the site reports: 479 pages for apartments for sale in Lisboa district on 2026-10-20. Because filters go into the URL, narrowing by price, area or typology shrinks the page count itself rather than discarding rows after the fetch.

---

## Understanding your data

Key fields and how to read them:

- `price`, `currency`: EUR on every captured row; the monthly rent on rent searches. Development listings carry `price: null` and a "from" `priceFromPerSquareMeter`; none of the 37 captured rows did.
- `pricePerSquareMeter`, `areaM2`: from 1,446.95 to 6,875 EUR per m² and 82 to 1,866 m² in the capture.
- `rooms`, `roomsLabel`: rooms, not bedrooms. The sample is a T3 with `roomsLabel` `FOUR`. `MORE` has no number, so `rooms` is null there; 4 captured rows.
- `agencyType`, `isPrivateOwner`: 31 `AGENCY`, 5 `DEVELOPER` and 1 private owner in the capture. `agencyName` is null for a private owner; `advertiserName` then holds the name the listing shows.
- `createdAt`, `pushedUpAt`: first listed and last bumped; 22 captured rows had been bumped.
- Duplicates: one listing appeared under a second id (`9000062240`) with the same `url`, no image and a `createdAt` of `1999-02-29 00:00:01`, not a real date. Deduplicate on `url` and parse dates defensively.

---

## Troubleshooting guide

- **Zero rows:** `FAILED` means a wrong slug, a block or an unreadable page; `SUCCEEDED` with an empty dataset means the pages loaded but nothing matched. Widen the price or area band, drop `typology`, or set `ownerType` back to `any`.
- **Run ends at once with a 404 error:** the `location` slug does not exist on Imovirtual. Copy it from a real [imovirtual.com](http://imovirtual.com) search URL.
- **Run fails before any fetch:** the slug is not slug-shaped (spaces, `?` or other characters). Use lowercase words joined by hyphens and slashes, as in `porto/porto`.
- **Input refused before the run starts:** a value outside a select list, such as `typology: "t6"` or `sortBy: "priceDesc"`.
- **Suburbs in a city search:** `lisboa` is a district; use `lisboa/lisboa`.
- **Rent prices look tiny:** on rent searches `price` is the monthly rent.
- **Reading the log:** `client.log(run.id).get()`; it reports `Page 1 returned N items of N pages.`, `Skipped N: ...` per reason, and a summary line near the end, `Pushed N row(s).`. A page that keeps failing is logged and skipped, and the run goes on.

For help, use the Actor's [Store page](https://apify.com/reventadata/imovirtual-scraper).

---

## Best practices

1. **Filter in the URL.** Price, area, typology and owner type are applied by Imovirtual, so they shrink the number of pages read instead of dropping rows after the fetch.
2. **Use council slugs for cities.** `lisboa/lisboa`, `porto/porto`.
3. **Always set a bound.** `maxPages` or a lower `maxItems`; the default `maxItems` is 1000, and without `maxPages` the walk goes to the site's last page.
4. **Start small.** `maxPages: 1` until your code handles the fields.
5. **Deduplicate on** `url`**, not only** `id`**.**
6. **Mind freshness.** Listings change and ads get bumped; schedule reruns instead of reusing old datasets, and compare `pushedUpAt` between runs.
7. **Handle failure explicitly.** Check `run.status` before reading rows.

---

## Pricing

For current pricing and details, see the [Store page](https://apify.com/reventadata/imovirtual-scraper).

---

## Conclusion and next steps

You configured Imovirtual Scraper, ran it from Python and read typed listing rows with prices, areas and addresses, filtered by Imovirtual itself. Next: schedule a `newest` search, attach a webhook, or run one search per council and compare price per m².

Try Imovirtual Scraper yourself and let me know what you build!

*About the author: reventadata builds and maintains marketplace and property scrapers on the Apify Store.*

---

## Resources

- [Imovirtual Scraper on Apify Store](https://apify.com/reventadata/imovirtual-scraper)
- [Apify Python client documentation](https://docs.apify.com/api/client/python/docs)
- [Apify schedules](https://docs.apify.com/actors/running/schedules)
- [Webhook actions](https://docs.apify.com/integrations/webhooks/actions)
