# Complete Guide: How to Scrape Milanuncios Classifieds Using Python

This complete guide to Milanuncios Scraper demonstrates how to run a Milanuncios web scraper from Python using the Apify API and retrieve structured, typed marketplace records: product titles, prices in euros, previous prices, price drop percentages, structured condition tags, parsed storage capacities, parsed battery health figures, and seller classifications. The extraction behavior and technical specifications described below are based on empirical runs and dataset inspections.

Milanuncios is an online classifieds marketplace serving Spain. However, developing a custom scraper for the platform presents technical hurdles: requests from Apify's datacenter range were refused (HTTP 405) when measured in August 2026, and the rendered HTML pages contain only skeleton markup.

In this tutorial, you will learn how to automate data collection from Milanuncios using the **Milanuncios Scraper** Apify Actor (`reventadata/milanuncios-scraper`). Prerequisites are minimal: Python 3.11+, the official `apify-client` library, and an Apify API token. Estimated time to complete: about 30 minutes. The live benchmark run supporting this guide (configured with `searchQueries: ["iphone"]`, `category: "telefonia"`, `onlyPriceDropped: True`, and `maxItems: 200`) collected 200 structured listings in 31.5 seconds.

---

## TL;DR

In this tutorial, you will learn to configure and trigger the Milanuncios Scraper Apify Actor from Python, inspect run execution states, extract clean dataset items, and handle structured condition tags and price drops. Key steps: install `apify-client`, export your `APIFY_API_TOKEN`, define target search parameters in `run_input`, call the Actor, and iterate over items in `run.default_dataset_id`.

Expected outcome: a JSON dataset of Milanuncios listings with current price, previous price, price drop percentage, structured condition, storage, battery health, and location where the ad states them, ready for immediate ingestion into databases or spreadsheets.

The Actor is available at [apify.com/reventadata/milanuncios-scraper](https://apify.com/reventadata/milanuncios-scraper).

---

## Prerequisites and setup

Before running our extraction script, verify the following prerequisites:

To follow this tutorial, you will need:

1. Python 3.11+ installed locally (`apify-client` 3.x requires it).
2. The Apify API client for Python (`pip install "apify-client~=3.0"`).
3. An Apify account and API token.

An Apify account. Your personal API token is shown on the **API & Integrations** page in Apify Console. Platform documentation is indexed at [docs.apify.com](https://docs.apify.com/).

Install the Apify client library via pip:

```bash
pip install "apify-client~=3.0"
```

Set your API token in your environment:

```bash
export APIFY_API_TOKEN="your_apify_token_here"
```

---

## Understanding Milanuncios Scraper

The **Milanuncios Scraper** extracts marketplace data by targeting Milanuncios's internal data structures:

- **Embedded data per page:** Rather than parsing rendered HTML markup with a browser, each results page embeds up to 100 listings inside an inline JavaScript state blob (`window.__INITIAL_PROPS__`). The Actor extracts this JSON payload and fetches page by page.
- **Proxy unblocking:** Requests from Apify's datacenter range were refused (HTTP 405) when measured in August 2026. Live runs fetch through Bright Data's Web Unlocker to retrieve pages.

### Limitations to be aware of

When configuring the Actor, note three specific platform constraints:

1. **Four-value condition filter:** The `conditions` filter accepts only four values: `sin estrenar`, `prácticamente nuevo`, `en buen estado`, and `aceptable`. For other values the platform refuses the input before the run starts.
2. **Unvalidated category slugs:** A category slug is not validated; a wrong slug can return the unfiltered search.
3. **Exact province slugs:** Province identifiers must be typed exactly as listed, underscores included (such as `la_coruna`); for a typo the platform refuses the input before the run starts.

---

## Deep Dive: Structured Condition Tags and Parsed Hardware Attributes

Milanuncios features distinct data structures that require careful handling:

1. **Condition Tags (**`estado del producto`**)**:
   - The `conditions` input filter accepts four enum values, but the output `condition` field can carry other values when the ad carries a condition tag (this run: one `mejorable`).
   - In our 200-row benchmark run, the distribution covered all six buckets: en buen estado 92, prácticamente nuevo 62, sin estrenar 12, aceptable 3, mejorable 1, and no condition 30.
   - The Actor extracts this attribute into the `condition` field.
2. **Hardware Attribute Parsing (**`storageGb` **and** `batteryHealth`**)**:
   - Milanuncios does not provide a structured storage field; storage and battery health are parsed from the title and description.
   - The Actor parses free-text titles and descriptions using regular expressions. Storage values are normalized to integer gigabytes (converting TB values to 1024 GB, though storageGb holds three odd values as the sellers wrote them: 126, 250, and 265 on individual rows); the battery health parser accepts integers from 40 to 100, and in this run observed values spanned 70 to 100.
   - In our benchmark run of 200 items, 148 records yielded a parsed `storageGb` value, and 105 records contained a parsed `batteryHealth` percentage.
3. **Price Drop Detection (**`previousPrice` **and** `priceDropPercent`**)**:
   - When sellers reduce their asking price, Milanuncios records both the original price and a markdown percentage.
   - In our benchmark run, all 200 rows have `previousPrice`, and 197 have `priceDropPercent` (the other 3 carry no percentage). `priceDropPercent` reached 100 on five €0 replica listings.

---

## Handling Spanish Regional and Category Navigation

Milanuncios handles categories and provinces through URL path structures rather than standard query parameters:

- **Combined Path Structure**: Category and province combine into a single URL segment: `/<category>-en-<province>/` (for instance, `/telefonia-en-madrid/`). Supplying the category as a URL query parameter (`?categoria=telefonia`) is silently ignored by the server.
- **Category Slugs**: The category is a free-text slug (the input schema's description lists 17 top-level ones). A category slug is not validated; a wrong slug can return the unfiltered search, so copy the slug from a Milanuncios URL.
- **52 Provincial Jurisdictions**: Spain comprises 50 provinces plus the autonomous cities of Ceuta and Melilla. The slug must be typed exactly as listed, underscores included (four use underscores: `la_coruna`, `la_rioja`, `las_palmas`, and `ciudad_real`). The province list is fixed, so for a hyphen spelling the platform refuses the input before the run starts.

---

## Step-by-step walkthrough

### Step 1: Navigating to the Actor

Visit the [Milanuncios Scraper Store page](https://apify.com/reventadata/milanuncios-scraper). The page lists the input parameters. Clicking **Try for free** opens Apify Console.

### Step 2: Defining inputs and target categories

In your Python script, configure the target search keywords, category slug, and filter constraints:

```python
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: While this introductory snippet specifies* `maxItems: 20` *for a quick demonstration, our live benchmark run ran with* `searchQueries: ["iphone"]`*,* `category: "telefonia"`*,* `onlyPriceDropped: True`*, and* `maxItems: 200`*.)*

### Step 3: Executing via Apify Python Client

Trigger cloud execution using `client.actor("reventadata/milanuncios-scraper").call(run_input=run_input)`:

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

Execution characteristics:

- The Actor launches in an isolated container with Bright Data's Web Unlocker.
- It fetches search-result pages one after another up to `maxItems`.
- In our test run, the Actor extracted 200 listings.

### Step 4: Iterating and processing dataset items

After completion, iterate across the dataset records in Python:

```python
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)
```

---

## Verbatim dataset sample

Below is a complete, unedited record extracted during the benchmark run:

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

---

## Detailed schema breakdown

Each dataset field:

- `id`: Milanuncios ad identifier string (e.g. "575075470").
- `title`: Listing title.
- `description`: Free-text description, newlines included.
- `url`: Absolute link to the ad.
- `imageUrls`: List of absolute photo URLs.
- `price`: Asking price in EUR (numeric, or null if unstated).
- `currency`: Always "EUR".
- `previousPrice`: Stated pre-drop price in EUR, or null.
- `priceDropPercent`: Integer from the site's own drop label; null if none.
- `condition`: Condition tag (e.g. "en buen estado"), or null.
- `sellerType`: "private" or "professional".
- `adType`: "offer" (supply) or "wanted" (demand).
- `sellerId`: Identifier for professional sellers, or null for private sellers.
- `sellerName`: Name for professional sellers, or null for private sellers.
- `city`: Locality or town name.
- `province`: Spanish province name.
- `category`: Leaf category name.
- `categoryPath`: Hierarchical category path.
- `publishDate`: ISO 8601 time the ad was first published.
- `updateDate`: ISO 8601 update timestamp.
- `isHighlighted`: Whether the ad is a paid, promoted listing.
- `isReserved`: Normalized boolean for reservation status.
- `storageGb`: Storage in GB parsed from text, or null.
- `batteryHealth`: Battery percentage parsed from text, or null.
- `matchedKeyword`: Search keyword that returned this listing.

---

## Working with the API

You can automate Milanuncios data extraction programmatically by integrating the Apify API into backend services, ETL pipelines, or scheduled workflows.

When initiating extractions from code, the Apify Python client provides two primary execution methods: `.call()` and `.start()`. Calling `client.actor("reventadata/milanuncios-scraper").call(run_input=run_input)` triggers the container and blocks until execution finishes, returning the settled run record. For asynchronous or distributed workflows where holding open an HTTP connection is impractical, invoke `client.actor("reventadata/milanuncios-scraper").start(run_input=run_input)` instead. This initiates the container on Apify infrastructure and immediately returns a run object containing the run ID and execution status.

To read the extracted data once execution finishes, query the default dataset through `client.dataset(run.default_dataset_id).list_items().items` or stream records using `iterate_items()`. The dataset retains clean, structured JSON items matching the schema detailed above.

For event-driven architectures, configure an Apify webhook in Apify Console or pass webhook definitions in the API call. You can configure the webhook to fire on the `ACTOR.RUN.SUCCEEDED` event. The webhook payload's `resource` is the response of the Get Actor run API endpoint, which carries `defaultDatasetId` (see [docs.apify.com/platform/integrations/webhooks/actions](https://docs.apify.com/platform/integrations/webhooks/actions)). This allows ingestion workers to fetch records directly without polling.

For scheduled executions, configure recurring schedules (for example, every morning at 08:00; a schedule has a `timezone` field, see [docs.apify.com/api/v2/schedules-post](https://docs.apify.com/api/v2/schedules-post)) to rerun the same search, then compare the datasets yourself.

---

## Advanced filtering and price drops

1. **Filtering by Markdown Status (**`onlyPriceDropped`**)**: Setting `onlyPriceDropped: true` instructs the scraper to send `isPriceDropped=true` in the Milanuncios search URL. This isolates ads Milanuncios flags as price-dropped.
2. **Differentiating Private Individuals from Commercial Dealers**: Setting `sellerType: "private"` restricts results to private individual sellers, while `sellerType: "professional"` collects listings from commercial dealers. Filtering by seller classification allows market analysts to separate consumer peer-to-peer pricing from commercial dealer inventory.
3. **Condition Filtering (**`conditions`**)**: Passing a list of strings such as `conditions: ["prácticamente nuevo", "en buen estado"]` restricts output to ads with matching condition tags. The `conditions` filter accepts exactly four values: `sin estrenar`, `prácticamente nuevo`, `en buen estado`, and `aceptable`. For any other value the platform refuses the input before the run starts.
4. **Damage and Brick Filtering (**`excludeDamaged`**)**: Setting `excludeDamaged: true` evaluates listing titles and descriptions against the Actor's list of damage wording. This discards units advertising broken parts (such as "pantalla rota", "agrietada", or "cristal roto"), non-functional hardware ("no enciende", "no funciona", "para piezas", "despiece", "averiado", "defectuoso"), or locked status ("icloud bloqueado", "lista negra"). Note that the list does not cover water damage and misses the masculine form like "pantalla roto".
5. **Sorting Modes (**`sortBy`**)**: You can order results by relevance (`"relevance"`), lowest price (`"baratos"`), or newest listings (`"date"`). Using `"baratos"` lists the cheapest ads first.

---

## Troubleshooting

When operating the Milanuncios Scraper in production, keep these operational considerations in mind:

- **Empty dataset returned:** The Actor does not fail when zero listings match your search criteria. Instead, it completes normally and returns an empty dataset. If you receive zero results, verify that your keywords are not overly restrictive, or check whether filters like `minPrice`, `maxPrice`, and `conditions` eliminated all matching inventory.
- **Category typos:** A category slug is not validated; a wrong slug can return the unfiltered search. Always copy the category slug from a live Milanuncios search URL.
- **Province validation:** Province slugs must match exact identifiers, underscores included. For any other spelling, a hyphen spelling such as `la-coruna` included, the platform refuses the input before the run starts.
- **IP-scoped block stubs (HTTP 200):** When Milanuncios rate-limits requests, it responds with HTTP 200 and an HTML stub missing the embedded `__INITIAL_PROPS__` state payload. A stub gets a retry.
- **Where to get help:** For support, bug reports, and questions, visit the Actor's Store page at [apify.com/reventadata/milanuncios-scraper](https://apify.com/reventadata/milanuncios-scraper).

---

## Best practices

Follow these operational guidelines to optimize extraction reliability and expense:

1. **Use** `maxItems` **as a budget cap:** Set `maxItems` to impose a strict ceiling on total records collected per execution, preventing unintended usage on broad keywords.
2. **Filter before storage to avoid per-result fees:** Apply `minPrice`, `maxPrice`, `conditions`, and `onlyPriceDropped` in your input parameters. Records filtered out before dataset storage incur no per-result event fees.
3. **Sort strategically:** Use `sortBy: "baratos"` when targeting entry-level price floors, or `sortBy: "date"` for newly published listings.
4. **Handle nullable attributes defensively:** Fields such as `sellerId`, `sellerName`, `batteryHealth`, and `storageGb` can be `null` (private sellers always have null seller identifiers). Ensure downstream data ingestion pipelines account for optional attributes.

---

## Pricing model and cost transparency

For current pricing and details, see the [Store page](https://apify.com/reventadata/milanuncios-scraper).

---

## Conclusion and resources

With the **Milanuncios Scraper** and a few lines of Python, you can collect structured marketplace data, price-drop data, and hardware specifications across Spain.

Try Milanuncios Scraper yourself and let me know what you build!

*I build and maintain marketplace scrapers under the reventadata name on the Apify Store.*

- 🚀 **Actor on Apify Store**: [Milanuncios Scraper](https://apify.com/reventadata/milanuncios-scraper)
- 📚 **API Client Documentation**: [Apify Python Client Documentation](https://docs.apify.com/api/client/python/)
- 💬 **Support & Custom Inquiries**: Use the Actor's Apify Store page for feature requests or bug reports.
