Skip to main content

Complete Guide: How to Scrape Property Data Using Imovirtual Scraper

Run the Imovirtual Scraper Apify Actor from Python and get Portuguese property listings with price, price per m², area and parish as JSON rows.

Updated 12 min readMarkdown
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) 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.


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.

Install the client:

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:

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 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:

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 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:

# 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

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:

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


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.


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

About the author

  • Hi, I’m the engineer behind ReventaData. I build automated web scrapers, reverse-engineer undocumented APIs, and design data pipelines on Apify that turn complex, unstructured websites into clean, reliable feeds. Here on Hashnode, I share practical tutorials on modern web scraping with Python, bypassing aggressive anti-bot protections (Cloudflare, Akamai, PerimeterX), and data pipeline automation, reverse-engineering hidden endpoints, and automating data extraction. Explore our turnkey scrapers: https://apify.com/reventadata

No comments yet. Be the first to comment.

More from this blog