By the end of this tutorial, you will have a short Python script that turns an Imovirtual search into structured rows: listing title, asking price in euros, price per m², area, rooms, floor, street, parish, council and district, the agency or developer behind the ad, and when it was listed.

Imovirtual (imovirtual.com) is a Portuguese real-estate portal for homes, land and commercial property, for sale or for rent.

In this guide, we use Python and the Apify Actor Imovirtual Scraper (reventadata/imovirtual-scraper). You will configure a search, run the Actor and read the rows. The sample row below is a sample from a capture dated 2026-10-20, parsed with the Actor's own parser.

⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.

Prerequisites

To follow this tutorial, 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 and its API token, which sits on the API & Integrations page in Apify Console.

Install the client:

pip install "apify-client~=3.0" 

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here" 

What you'll achieve

You will turn one Imovirtual search into one row per listing. Each row carries the listing id and url, title, price with currency, pricePerSquareMeter, areaM2, rooms and roomsLabel, floor, the address levels (street, neighborhood, parish, council, district), agencyName and agencyType, isPrivateOwner, createdAt and pushedUpAt, and one imageUrl.

Note: A row carries no phone number or e-mail address. The ad url leads to the full listing page.

Example use case: Suppose you want two-bedroom flats for sale in Lisbon city under 400,000 EUR. You would set propertyType: "apartamento", location: "lisboa/lisboa", typology: "t2" and maxPrice: 400000, and Imovirtual itself applies the filters before a single row comes back.

Step 1: Access Imovirtual Scraper

Go to the Imovirtual Scraper page on Apify Store. The README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.

Step 2: Configure the search

Three inputs define the search:

  • transaction: buy or rent.
  • propertyType: apartamento (apartment), moradia (house), terreno (land), imoveis-comerciais (commercial), armazens (warehouse) or garagem (garage).
  • location: Imovirtual's own location slug, as it appears in a search URL: a district such as lisboa or porto, a council such as lisboa/lisboa or porto/porto, or todo-o-pais for the whole country.

To find a slug, search on imovirtual.com and copy the part of the URL after the property type: lisboa/lisboa from /pt/resultados/comprar/apartamento/lisboa/lisboa.

Each result page holds 36 listings. maxPages caps the pages read, and maxItems (default 1000) caps the rows saved.

Here is the setup. It searches apartments for sale in Lisboa district and reads one result page:

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, } 

💡 Tip: lisboa is the whole district, not the city. In the capture, page 1 of this search spanned 8 councils, from Lisboa itself to Sintra, Loures and Vila Franca de Xira. Use lisboa/lisboa for the city.

No proxy setting is needed: the proxy is off by default, and plain requests with no proxy reached imovirtual.com when the Actor was tested on 2026-10-20.

Step 3: Narrow the search

These inputs go into the search URL, so Imovirtual filters before anything is fetched:

Parameter Type What it does
typology string t0 to t5: the Portuguese bedroom count.
minPrice / maxPrice integer Price band in EUR.
minArea / maxArea integer Area band in m².
ownerType string any, or private for private owners only (no agencies or developers).
sortBy string relevance (the site's default), priceAsc (cheapest first) or newest.

An empty numeric filter is left out of the request, never sent as 0.

Three combinations that work:

  • Lisbon city T2 flats under 400,000 EUR: location: "lisboa/lisboa", typology: "t2", maxPrice: 400000.
  • Rentals in Porto, cheapest first: transaction: "rent", location: "porto/porto", sortBy: "priceAsc".
  • Houses sold by their owners, nationwide: propertyType: "moradia", location: "todo-o-pais", ownerType: "private", sortBy: "newest".

⚠️ A typology, sortBy, ownerType, transaction or propertyType outside its list is refused before the run starts. A location slug Imovirtual does not know stops the run with an error at its first page.

Step 4: Run the Actor

Now start the run with client.actor("reventadata/imovirtual-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:

# 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") 

Behind the scenes:

  • The Actor builds the search URL (/pt/resultados/comprar/apartamento/lisboa here, plus ?page=N from page 2 on).
  • It reads the listing data Imovirtual embeds in its own search page, so no browser is involved, and turns each listing into one row.
  • It stops at maxItems, at maxPages, or at the last page the site reports, whichever comes first. The premium tile Imovirtual repeats across pages is saved once.

Step 5: View and download results

Loop over the rows to print the parts you need:

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) 

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

{ "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" } 

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.

Understanding your results

The fields that matter most:

  • price, currency: the asking price in EUR, or the monthly rent on rent searches. Development listings publish no total price: price is null and priceFromPerSquareMeter holds the site's "from" price per m².
  • pricePerSquareMeter, areaM2: as the site publishes them. In the 37-row capture, prices ran from 355,000 to 9,000,000 EUR and price per m² from 1,446.95 to 6,875.
  • rooms, roomsLabel: rooms, not bedrooms. The sample's title says T3 and its roomsLabel is FOUR: three bedrooms plus the living room. Imovirtual's MORE has no number, so rooms is null there (4 rows in the capture).
  • floor: the site's value (GROUND, SECOND, ...); null on 10 of the 37 captured rows.
  • agencyType: AGENCY or DEVELOPER, null for a private owner (31, 5 and 1 in the capture).
  • createdAt, pushedUpAt: first listed and last bumped. 14 of the 37 captured rows were first listed on the capture day.
  • searchUrl: the page-1 search URL the row came from.

Pro tips

  1. Deduplicate on url. The Actor removes repeated ids, but in the capture one listing also appeared under a second id (9000062240) with the same url, no image and a createdAt of 1999-02-29 00:00:01. The loop above keys on url for that reason.
  2. Parse dates defensively. That same createdAt is not a real date. Wrap date parsing in a try/except instead of letting one row stop a pipeline.
  3. Filter on the site, not in pandas. Price, area, typology and owner type go into the URL, so you read fewer pages for the same rows.
  4. Use council slugs. lisboa/lisboa instead of lisboa keeps the suburbs out.
  5. Keep sale and rent apart. On a rent search price is the monthly rent, so a sale and a rent dataset in one column mix two different numbers. Run them as separate searches.
  6. Start small. Keep maxPages: 1 until your downstream code handles the fields, then raise it or leave it out to walk every page the site reports.

Troubleshooting

  • Zero rows: a run that is blocked, cannot read a page or gets a wrong slug ends FAILED; an over-strict filter ends SUCCEEDED with an empty dataset and a status message saying so. Widen the price or area band, or drop ownerType: "private".
  • Run stops at its first page with a 404 error: the location slug does not exist on Imovirtual. Copy it from a real imovirtual.com search URL.
  • Input refused before the run starts: a value outside a select list, such as typology: "t6".
  • Fewer rows than expected: read the run log. Near the end it logs Skipped N: ... lines and Pushed N row(s).. From Python: client.log(run.id).get().

For help, use the Actor's Store page.

Pricing and next steps

For current pricing and details, see the Store page.

To repeat a search, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). Pair it with sortBy: "newest". To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.

Which part of Portugal are you planning to track on Imovirtual? Drop a comment! 💬