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:
- Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- 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
urlleads 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:buyorrent. -
propertyType:apartamento(apartment),moradia(house),terreno(land),imoveis-comerciais(commercial),armazens(warehouse) orgaragem(garage). -
location: Imovirtual's own location slug, as it appears in a search URL: a district such aslisboaorporto, a council such aslisboa/lisboaorporto/porto, ortodo-o-paisfor 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,transactionorpropertyTypeoutside its list is refused before the run starts. Alocationslug 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/lisboahere, plus?page=Nfrom 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, atmaxPages, 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:priceis null andpriceFromPerSquareMeterholds 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 itsroomsLabelisFOUR: three bedrooms plus the living room. Imovirtual'sMOREhas no number, soroomsis null there (4 rows in the capture). -
floor: the site's value (GROUND,SECOND, ...); null on 10 of the 37 captured rows. -
agencyType:AGENCYorDEVELOPER, 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
- Deduplicate on
url. The Actor removes repeated ids, but in the capture one listing also appeared under a second id (9000062240) with the sameurl, no image and acreatedAtof1999-02-29 00:00:01. The loop above keys onurlfor that reason. - Parse dates defensively. That same
createdAtis not a real date. Wrap date parsing in a try/except instead of letting one row stop a pipeline. - 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.
- Use council slugs.
lisboa/lisboainstead oflisboakeeps the suburbs out. - Keep sale and rent apart. On a rent search
priceis the monthly rent, so a sale and a rent dataset in one column mix two different numbers. Run them as separate searches. - Start small. Keep
maxPages: 1until 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
locationslug 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 andPushed 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! 💬
