By the end of this tutorial, you will have a working Python script that extracts Milanuncios classifieds into structured records: listing title, price in EUR, previous price, price drop percentage, structured condition tag, parsed storage capacity in GB, parsed battery health percentage, seller classification, and location.
Milanuncios is an online classifieds marketplace serving Spain. Collecting listings manually is impractical at scale, while building a custom scraper requires handling anti-bot responses and extracting embedded data payloads.
In this guide, we use Python and the Apify Actor Milanuncios Scraper (reventadata/milanuncios-scraper) to extract marketplace listings. Our benchmark run (RxXzPPfgx9WBDWMTf, configured with searchQueries: ["iphone"], category: "telefonia", onlyPriceDropped: True, and maxItems: 200) took 31.5 seconds to extract 200 listings into a clean dataset.
⏱️ Time estimate: About 30 minutes.
Prerequisites
To follow this tutorial, you will need:
-
Python 3.11+ installed locally (
apify-client3.x requires it). - The Apify API client for Python.
- An Apify account and API token.
An account includes free monthly platform usage. You can find your personal API token under API & Integrations in Apify Console. We load it from an environment variable rather than hardcoding credentials into source code.
pip install apify-client
Set your API token in your shell environment:
export APIFY_API_TOKEN="your_apify_token_here"
What you'll achieve
By completing this guide, you will extract live Milanuncios listings into a dataset of typed records: price, previous price, price drop percentage, condition, color, seller type, city, province, storage capacity, and battery health. The section "Understanding your results" below explains the main fields.
Note on Data Boundaries: Milanuncios server-renders its search result pages as minimal HTML skeletons; each results page embeds up to 100 listings within an inline
window.__INITIAL_PROPS__JSON payload. The Actor fetches page by page through Bright Data's Web Unlocker; it needs no browser.
Example use case: Suppose you want to monitor second-hand electronics deals or track price drops for phones across Spain. By providing search keywords and narrowing by category or province, you receive an automated feed detailing markdown percentages, seller types, and storage and battery figures parsed from listing text.
Step 1: Access Milanuncios Scraper
Navigate to the Milanuncios Scraper page on Apify Store.
You can inspect the input parameters on the Store page. Clicking Try for free opens Apify Console. In this tutorial, however, we use Python to automate the process programmatically.
Step 2: Configure search keywords and categories
The category is a free-text slug, and the input schema's description lists 17 top-level ones (such as telefonia, motor, informatica-segunda-mano, inmobiliaria, and casa-y-jardin).
Milanuncios URL paths follow specific structural patterns:
- Category and province combine into a single path segment rather than separate query parameters (for example,
/telefonia-en-madrid/). - Setting both
categoryandprovincenarrows results to their exact intersection. - Query parameters that look plausible on the surface—such as
vendedor=particularorcategoria=telefonia—are silently ignored by the website; the Actor builds the URL in the formats that work.
In our Python script, we initialize the Apify client and pass our parameters in run_input:
import os
from apify_client import ApifyClient
# 1. Initialize ApifyClient with token from environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])
# 2. Define the extraction parameters
run_input = {
"searchQueries": ["iphone"],
"category": "telefonia",
"onlyPriceDropped": True,
"maxItems": 20,
}
(Note: This demonstration snippet uses maxItems: 20 for a fast initial test. The benchmark run RxXzPPfgx9WBDWMTf cited in this guide ran with searchQueries: ["iphone"], category: "telefonia", onlyPriceDropped: True, and maxItems: 200.)
Step 3: Configure optional settings and filters
The Actor filters rows before saving them to your dataset. It charges per saved item, so dropped rows are not charged:
| Parameter | Type | Default | Description |
|---|---|---|---|
searchQueries |
array | Required | Search keywords (e.g. ["iphone"]). An empty list halts execution. |
category |
string | None |
Category slug (e.g. telefonia, motor, informatica-segunda-mano). |
province |
string | None |
Restricts results to one of 52 province slugs (50 provinces plus Ceuta and Melilla); must match the exact slug with underscores (e.g. la_coruna, madrid). |
minPrice & maxPrice
|
integer | None |
Price boundaries in EUR. Omitted from request entirely when unset. |
sellerType |
string | "any" |
Filter by seller classification: "any", "private", or "professional". |
adType |
string | "any" |
Listing intent: "any", "offer" (supply), or "wanted" (demand). |
onlyShippable |
boolean | false |
When true, keeps only listings offering shipping. |
onlyPriceDropped |
boolean | false |
When true, sends the site's own isPriceDropped=true parameter. |
conditions |
array | [] |
Filter by condition tag (accepts sin estrenar, prácticamente nuevo, en buen estado, aceptable). |
minStorageGb |
integer | None |
Minimum storage parsed from text (converts TB to 1024 GB). Drops unstated items. |
minBatteryHealth |
integer | None |
Minimum battery health percentage parsed from description. Drops unstated items. |
excludeDamaged |
boolean | true |
Drops listings matching damage phrases (e.g. pantalla rota, no funciona, para piezas, icloud bloqueado). |
requireKeywordMatch |
boolean | true |
Drops items whose title does not contain all query words. |
sortBy |
string | "relevance" |
Order by "relevance", "date", "baratos", or "caros". |
maxItems |
integer | 1000 |
Hard cap on total listings collected before terminating. |
Practical filtering recommendations:
-
maxItemsacts as a budget ceiling: If you setmaxItems: 50, the scraper terminates as soon as 50 items are written to your dataset. -
Isolate price drops with
onlyPriceDropped: When looking for deals, settingonlyPriceDropped: trueextracts ads Milanuncios flags as price-dropped (the Actor sends isPriceDropped). -
Target condition directly: Milanuncios listings often include an
estado del productotag (such asen buen estadoorprácticamente nuevo). Filtering onconditionsevaluates this tag rather than guessing from free-form text.
Step 4: Run the Actor
With configuration ready, trigger execution using client.actor("reventadata/milanuncios-scraper").call(run_input=run_input). This command starts the Actor in Apify's cloud environment, streams log events, and waits until execution completes:
# 3. Start the Actor run and wait for it to complete
print("Starting Milanuncios extraction...")
run = client.actor("reventadata/milanuncios-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 resulting dataset items
dataset = client.dataset(run.default_dataset_id).list_items().items
print(f"Extraction complete! Retrieved {len(dataset)} listings.\n")
Behind the scenes:
- The Actor launches in a dedicated container. Requests from Apify's datacenter range were refused (HTTP 405) when measured in August 2026; the Actor routes fetches through Bright Data's Web Unlocker.
- It extracts the structured
window.__INITIAL_PROPS__payload, parses nested prices and discount percentages, extracts storage and battery metrics, and pushes matching records to the Apify dataset. - In our live benchmark run (
RxXzPPfgx9WBDWMTf), configured withsearchQueries: ["iphone"],category: "telefonia",onlyPriceDropped: True, andmaxItems: 200, the Actor collected 200 listings.
Step 5: View and download results
Once the run completes, iterate over dataset in Python, or export via the Apify Console in JSON, CSV, or Excel format:
for item in dataset:
title = item.get("title")
price = item.get("price")
previous_price = item.get("previousPrice")
drop = item.get("priceDropPercent")
condition = item.get("condition")
storage = item.get("storageGb")
battery = item.get("batteryHealth")
city = item.get("city")
seller_type = item.get("sellerType")
print(f"Listing: {title}")
if previous_price and drop:
print(f" Price: €{price} (was €{previous_price}, -{drop}%)")
else:
print(f" Price: €{price}")
print(f" Condition: {condition or 'not specified'}")
if storage:
print(f" Storage: {storage} GB")
if battery:
print(f" Battery Health: {battery}%")
print(f" Location: {city} ({seller_type})")
print("-" * 50)
Here is a verbatim sample record extracted during our benchmark run:
{
"id": "575075470",
"title": "Iphone X",
"description": "Se vende Iphone X en muy buen estado, con su caja y accesorios y dos fundas nuevas sin usar. El estado de la bateria es 75%. Memoria 64gb.",
"url": "https://www.milanuncios.com/iphone/iphone-x-575075470.htm",
"imageUrls": [
"https://images.milanuncios.com/api/v1/ma-ad-media-pro/images/86f22102-6da3-44df-9375-68f332e3150d",
"https://images.milanuncios.com/api/v1/ma-ad-media-pro/images/9195d341-33b1-49d9-afb5-31f598262fab"
],
"price": 125,
"currency": "EUR",
"previousPrice": 150,
"priceDropPercent": 17,
"condition": "en buen estado",
"color": "blanco",
"sellerType": "private",
"adType": "offer",
"sellerId": null,
"sellerName": null,
"city": "Santa Cruz de Tenerife",
"province": "Tenerife",
"region": "Canarias",
"category": "iPhone",
"categoryPath": "telefonia/moviles/telefonos-moviles/iphone",
"publishDate": "2026-01-08T11:33:28Z",
"updateDate": "2026-10-23T12:18:18Z",
"isHighlighted": false,
"isReserved": false,
"storageGb": 64,
"batteryHealth": 75,
"matchedKeyword": "iphone"
}
Understanding your results
Each dataset record provides parsed marketplace attributes:
-
id,title,description,url: Listing identifier, title, description text, and direct listing URL. -
price,currency: Current price in euros (currencyis"EUR"). -
previousPrice,priceDropPercent: Pre-drop price and integer discount percentage. In our 200-row benchmark run, all 200 rows havepreviousPrice; 197 havepriceDropPercent(the other 3 carry no percentage). -
condition: Listing condition tag (the output field can carry other values: this run returneden buen estado92,prácticamente nuevo62,sin estrenar12,aceptable3,mejorable1, and 30 with no condition). -
color: Structured color tag, ornullwhen omitted. -
sellerType,adType:sellerTypeis"private"or"professional".adTypeis"offer"or"wanted". -
sellerId,sellerName: Merchant ID and name for professional sellers;nullfor private sellers (dataset: 164 private rows, all null). -
city,province,region: Location metadata matching Spanish provinces. -
category,categoryPath: Hierarchical category classification. -
storageGb,batteryHealth: Parsed hardware specifications. Storage is normalized to gigabytes (1TB becomes 1024); battery health accepts 40–100 (this run observed 70–100). -
publishDate,updateDate: ISO 8601 publication and modification timestamps. -
isHighlighted,isReserved:isHighlighted: whether the ad is a paid, promoted listing;isReservedflags reserved items.
Pro tips & edge cases
-
Condition tags: Listings can display an
estado del productotag. Theconditionsfilter accepts four values (sin estrenar,prácticamente nuevo,en buen estado,aceptable), while the ad carries a condition tag that can include other values in output (this run returned onemejorable), or no condition tag. - Hardware specification extraction: Storage and battery health are extracted via regex from the listing title and description. Storage values normalize units (such as 1 TB to 1024 GB), but reflect what the seller typed.
-
Combined path navigation: Milanuncios combines category and province into a single URL path segment (
/<category>-en-<province>/). A category slug is not validated; a wrong slug can return the unfiltered search. Province slugs must match the exact spelling with underscores (e.g.la_coruna); for any other spelling the platform refuses the input before the run starts. - 100-result page limit: Milanuncios caps search results at 100 listings per page. The Actor always requests 100 per page.
-
Seller classification: Professional sellers include
sellerNameandsellerId, while for private sellers both arenull(dataset: 164 private rows, all null). UsesellerTypeto separate business listings from private sales.
Troubleshooting
- Empty dataset on zero matches: When no listings match your keywords, province, or filters, the Actor completes normally and finishes with an empty dataset rather than failing. If your dataset contains zero items, broaden your query terms or relax your price boundaries.
- Unvalidated category slugs: A category slug is not validated; a wrong slug can return the unfiltered search. If results appear unrelated to your intended category, copy the slug from a Milanuncios category URL.
-
Unstated hardware attributes: Attributes such as
storageGbandbatteryHealthare parsed from listing text. When a seller omits capacity or battery percentages from their description, the Actor returnsnullfor those fields. -
Reading the log and getting help: the run log contains a
done: pushed N row(s)line, then arejected:tally if any; read it from Python withclient.log(run.id).get(). For help, see the Store page (https://apify.com/reventadata/milanuncios-scraper).
Pricing and Next Steps
The Milanuncios Scraper is pay-per-event: $1.00 per 1,000 results plus $0.00005 per run start at the default 512 MB of memory; see the Store page for current pricing. Listings dropped by client-side filters before writing to storage are not charged.
You can try the Actor right now in your browser or run it programmatically in your data pipelines:
What categories or items are you planning to track on Milanuncios? Let us know in the comments below!
Top comments (0)