By the end of this tutorial, you will have a short Python script that turns a Zap Imóveis search into structured rows: asking price in reais, monthly condominium fee, IPTU, bedrooms, bathrooms, area in m², street, neighbourhood and amenities.
Zap Imóveis (zapimoveis.com.br) is a Brazilian property portal. It sits behind a Cloudflare managed challenge: in testing on 2026-10-17, direct, datacenter and residential requests got HTTP 403, while Apify Unblocker returned the full page.
In this guide, we use Python and the Apify Actor Zap Imóveis Scraper (reventadata/zapimoveis-scraper). You will pick a city, a deal type and a property type, run the Actor and read the rows. The sample row below is a sample from a capture dated 2026-10-24, 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, version 3.
- An Apify account and its API token, which sits on the API & Integrations page in Apify Console.
Install the client:
pip install -U "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 Zap Imóveis search into one row per listing. Each row carries the listing id, url and title, price and currency (BRL), condoFee and iptu, bedrooms, bathrooms, parkingSpaces, areaM2, the address (street, neighborhood, city, state), advertiser, amenities and photo links in images (five on most captured rows). propertyType is read from the title, for example apartamento.
Example use case: Suppose you want two-bedroom apartments for sale in São Paulo under R$ 600,000. You would set location: "sp+sao-paulo", operation: "venda", propertyType: "apartamentos", minBedrooms: 2 and maxPrice: 600000. The Actor adds those bounds to the search URL and checks every row against them again before saving it.
Step 1: Access Zap Imóveis Scraper
Go to the Zap Imóveis 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 are required and together they pick the Zap Imóveis listing page:
-
location: a{state}+{city}slug such assp+sao-paulo,rj+rio-de-janeiro,pr+curitibaormg+belo-horizonte. The Actor lowercases it and turnssp-sao-pauloorsp sao paulointosp+sao-paulo. -
operation:venda(for sale) oraluguel(for rent). -
propertyType:apartamentos,casas,imoveis(all),comerciaisorterrenos.
maxItems (default 100) caps the rows saved. Here is the setup. It asks for apartments for sale in São Paulo and saves at most 10 rows:
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 São Paulo, at most 10 rows
run_input = {
"location": "sp+sao-paulo",
"operation": "venda",
"propertyType": "apartamentos",
"maxItems": 10,
}
💡 Tip: operation and propertyType only take the values listed above. Anything else is refused before the run starts, so a typo never starts a run.
No proxy setting is needed in run_input: when none is given, the Actor uses Apify Unblocker, the transport that returned real pages in testing on 2026-10-17.
Step 3: Add optional filters
| Parameter | Type | What it does |
|---|---|---|
minPrice / maxPrice
|
integer | Price band in reais. Sent to the site as precoMinimo / precoMaximo and checked on each row; a row with no price passes. |
minBedrooms |
integer | Sent as quartos and checked on each row; a row with no bedroom count is dropped. |
minArea |
integer | Sent as areaMinima and checked on each row; a row with no area is dropped. |
searchQueries |
array | Not a site search: a filter on the rows. A row is kept when every word of at least one entry appears in its title or description, ignoring case and accents. |
Three combinations that work:
-
Rentals in Rio:
location: "rj+rio-de-janeiro",operation: "aluguel",propertyType: "apartamentos". -
Family houses in Curitiba:
location: "pr+curitiba",propertyType: "casas",minBedrooms: 3,minArea: 120. -
Apartments with a pool:
searchQueries: ["piscina"]. It reads the title and description only: on the captured page,piscinawas in no title and in 3 descriptions, whilePoolwas in the amenities of 18 rows.
Step 4: Run the Actor
Now start the run with client.actor("reventadata/zapimoveis-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 Zap Imóveis extraction...")
run = client.actor("reventadata/zapimoveis-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 (
/venda/apartamentos/sp+sao-paulo/here, plus?pagina=Nfrom page 2 on) and fetches it through Apify Unblocker. - It reads the page's structured listing data, adds the condo fee, IPTU and parking spaces shown on each listing card, applies your filters, and saves the rows. A listing seen on an earlier page is not saved twice.
- It stops at
maxItemsor after two empty pages in a row.
Step 5: View and download results
Loop over the rows to print the parts you need:
for item in items:
print(item["title"])
price = item.get("price")
print(f" Price: R$ {price:,.0f}" if price is not None else " Price: not shown")
if item.get("condoFee") is not None:
print(f" Condo fee: R$ {item['condoFee']:,.0f} per month")
print(f" {item.get('bedrooms')} bedrooms, {item.get('areaM2')} m²")
print(f" Where: {item.get('neighborhood') or '?'}, {item.get('city')} ({item.get('state')})")
print("-" * 40)
Here is one row, a sample from a capture dated 2026-10-24 (page 1 of apartments for sale in São Paulo) parsed with the Actor's own parser. A saved row also carries scrapedAt, the time the page was parsed:
{
"id": "2865131669",
"title": "Apartamento para comprar com 78 m², 2 quartos, 1 banheiro, 1 vaga em Vila Paiva, São Paulo",
"url": "https://www.zapimoveis.com.br/imovel/venda-apartamento-2-quartos-vila-paiva-zona-norte-sao-paulo-sp-78m2-id-2865131669/",
"operation": "venda",
"propertyType": "apartamento",
"price": 535000.0,
"currency": "BRL",
"condoFee": 1200.0,
"iptu": 129.0,
"bedrooms": 2,
"bathrooms": 1,
"parkingSpaces": 1,
"areaM2": 78.0,
"street": "Rua Santo Anselmo",
"neighborhood": "Vila Paiva",
"city": "São Paulo",
"state": "SP",
"advertiser": "Vmx - Administração Profissional De Imóveis Ltda",
"amenities": [
"Electronic Gate",
"Bathroom Cabinets",
"Pets Allowed",
"Service Area",
"Elevator",
"Builtin Wardrobe",
"Lavabo",
"Blindex Box",
"Kitchen Cabinets",
"Gated Community",
"Planned Furniture",
"Balcony",
"Bedroom Wardrobe"
],
"images": [
"https://resizedimgs.zapimoveis.com.br/img/vr-listing/f872a3cbf962cad17599d6ce754a7458/apartamento-com-2-quartos-a-venda-78m-no-vila-paiva-sao-paulo.webp?action=fit-in&dimension=614x297",
"https://resizedimgs.zapimoveis.com.br/img/vr-listing/cde256c0ba81ddef1a7919f88f185fd2/apartamento-com-2-quartos-a-venda-78m-no-vila-paiva-sao-paulo.webp?action=fit-in&dimension=614x297&seo=false",
"https://resizedimgs.zapimoveis.com.br/img/vr-listing/5b3f304d7a809815511e553d49662984/apartamento-com-2-quartos-a-venda-78m-no-vila-paiva-sao-paulo.webp?action=fit-in&dimension=614x297&seo=false",
"https://resizedimgs.zapimoveis.com.br/img/vr-listing/2be919917f874b23a74a9fde73ba143a/apartamento-com-2-quartos-a-venda-78m-no-vila-paiva-sao-paulo.webp?action=fit-in&dimension=614x297&seo=false",
"https://resizedimgs.zapimoveis.com.br/img/vr-listing/5945376ad9289b5c91fb55a5d79a2e66/apartamento-com-2-quartos-a-venda-78m-no-vila-paiva-sao-paulo.webp?action=fit-in&dimension=614x297&seo=false"
],
"description": null
}
Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Understanding your results
The captured page gave 30 rows, all apartments in São Paulo, priced from R$ 230,000 to R$ 3,100,000. Read the fields like this:
-
price,currency: the asking price in reais (BRL). All 30 rows had one. -
condoFee,iptu: monthly condominium fee and IPTU, the municipal property tax, as the listing publishes them. 23 rows had a condo fee and 9 an IPTU value. -
bedrooms,bathrooms,areaM2: filled on all 30. 17 rows had two bedrooms, 8 three, 4 one and 1 four. When the title shows an area range, as on a new development (32 - 79 m²),areaM2holds one figure from the page data, 32 in that case. 10 of the 30 titles showed a range. -
parkingSpaces: read from the listing card; filled on 13 rows, though 23 titles mention avaga. -
neighborhood: taken from the title'sem <bairro>, <city>ending; null on 7 rows. -
advertiser: an agency name when the card shows one; filled on 5 rows. -
amenities: English names from the page data.Pets Allowedappeared on 19 rows,GymandPoolon 18 each.
Pro tips
-
Sanity-check the fees. Values come as the listing publishes them. In the capture, one row's condo fee and IPTU were both
1.0, and another's condo fee was980000.0, as the page's own data stated. Drop outliers before you average a column. -
Read the title when a field is null.
parkingSpacesandneighborhoodare often empty while the title still says1 vagaor names the bairro. -
Use the price and size inputs, not just keywords.
minPrice,maxPrice,minBedroomsandminAreanarrow the URL the site serves;searchQueriesonly drops rows after the page is read. -
Watch for repeat listings. Two rows in the capture had different ids but the same street, price, area and condo fee. Deduplicating by
idkeeps both, so group on those fields if you count homes. -
Start small. Keep
maxItems: 10until your downstream code handles the fields, then raise it.
Troubleshooting
-
Run FAILED with zero rows: a blocked page, an unreadable page or an empty first result page ends the run
FAILED; an over-strict filter on a page that loaded with listings endsSUCCEEDEDwith an empty dataset and a status message saying so. Loosen the filters and check thelocationslug. - Run stops early: a page that keeps returning a challenge is tried up to three times; after that the run stops reading pages and keeps what it saved.
-
Input refused before the run starts:
operationorpropertyTypeis not one of the listed values, or a required field is missing. -
Fewer rows than expected: read the summary line,
Scrape run completed: pushed N listing(s) (rejected during filtering: {...}); it counts drops per filter (keyword_mismatch,price_bounds,bedrooms_below_min,area_below_min). 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). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.
Which Brazilian city's listings are you planning to track? Drop a comment! 💬
Top comments (0)