# Milanuncios Scraper API 🇪🇸 Condition & Price Drops (`reventadata/milanuncios-scraper`) Actor

Searches Milanuncios by keyword across 17 categories — motor, property, fashion, pets and more — filtering by price, condition, seller type and province. For phones, also parses storage and battery health from the description. Reads the page's own embedded JSON, no browser.

- **URL**: https://apify.com/reventadata/milanuncios-scraper.md
- **Developed by:** [reventadata](https://apify.com/reventadata) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 ads

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

> **Unofficial.** This Actor is not affiliated with, authorised by, endorsed by, or in any
> way officially connected to Milanuncios. "Milanuncios" is a trademark of its respective
> owner and is used here only to describe what this tool reads. It reads the same public
> pages a browser does.

Milanuncios Scraper searches [Milanuncios](https://www.milanuncios.com), Spain's biggest classifieds
site, and returns priced listings with structured condition tags, storage and battery health
parsed out of the text, and duplicate-safe keyword matching.

### What does it do?

Milanuncios server-renders its result pages, but the listings themselves are not in the markup —
they live in one inline JSON blob. This Actor reads that blob directly with a browser-impersonating
HTTP client, pages at the site's own 100-per-page ceiling, applies price, seller-type, ad-type,
category and province filters server-side where the site supports them, and applies condition,
storage, battery health and damage-word filters client-side. Unlike a peer-to-peer marketplace
where "condition" is whatever the seller typed, Milanuncios attaches a **structured condition
tag** to most ads — `estado del producto` — picked from a form field, not written as free text.

### Why use it?

- **Structured condition, not a seller's prose.** Filter on `estado del producto` directly.
- **100 results per request instead of 41.** Cuts a 1,248-ad sweep from 31 requests to 13.
- **Battery health and storage, parsed from free text**, the same way the sibling Wallapop Actor
  does it, so results are comparable across both sites.
- **Duplicate-safe multi-keyword search.** An ad is only marked "seen" once it is kept, so a
  near-miss under one keyword can still be picked up by a broader one later in the run.

### How to use it

1. Put one or more search terms in `searchQueries`. There is no default.
2. Optionally narrow with `category`, `province`, a price range, `sellerType`, `adType`, `conditions`, `minStorageGb` or `minBatteryHealth`.
3. Run it. Results land in the dataset.

#### Input

| Field | Type | Meaning |
|---|---|---|
| `searchQueries` | array | One search per keyword. Required, no default. |
| `category` | string | Optional category slug (`telefonia`, `informatica-segunda-mano`, `motor`…). Validated; an unknown slug fails the run. Combines with `province`, not exclusive with it. |
| `province` | string | Optional, picked from a dropdown of all 52 provinces (50 + Ceuta + Melilla). Combines with `category`, not exclusive with it. |
| `minPrice` / `maxPrice` | integer | Optional. Omitted from the request entirely when left empty — never defaulted. |
| `sellerType` | string | `any` | `private` | `professional`. |
| `adType` | string | `any` | `offer` | `wanted`. |
| `onlyShippable` | boolean | Only listings the seller will ship. Off by default. |
| `onlyPriceDropped` | boolean | Only listings currently marked down. Off by default. |
| `conditions` | array | Subset of `sin estrenar`, `prácticamente nuevo`, `en buen estado`, `aceptable`. Empty means any. |
| `minStorageGb` | integer | Parsed from title and description; TB is converted, so 1TB counts as 1024. Rejects ads that never state storage at all, not just ads below the floor. |
| `minBatteryHealth` | integer | Parsed from the description. Only meaningful for phones. Rejects ads that never state a battery figure at all, not just ads below the floor. |
| `excludeDamaged` | boolean | Drops listings matching known damage wording. Default on. |
| `requireKeywordMatch` | boolean | Drop items whose title lacks the query. Default on. |
| `sortBy` | string | `relevance` | `date` | `baratos` | `caros`. Order only — changes nothing about which ads exist. |
| `maxItems` | integer | Hard cap on rows pushed. Default 1000. |
| `proxyConfiguration` | object | Optional. Sustained scraping earns the requesting IP a silent block; a rotating proxy is what keeps a large run alive. |

```json
{
    "searchQueries": ["iphone 15"],
    "category": "telefonia",
    "sellerType": "private",
    "conditions": ["prácticamente nuevo", "en buen estado"],
    "minPrice": 200,
    "maxPrice": 400,
    "maxItems": 200
}
```

#### Output

```json
{
    "id": "600000001",
    "title": "Iphone 15",
    "description": "Iphone 15 Negro\nLibre para cualquier compañía",
    "url": "https://www.milanuncios.com/telefonos-inalambricos/iphone-15-600000001.htm",
    "imageUrls": ["https://images.milanuncios.com/api/v1/abc123"],
    "price": 440,
    "currency": "EUR",
    "previousPrice": 470,
    "priceDropPercent": 6,
    "condition": "en buen estado",
    "color": "negro",
    "sellerType": "private",
    "adType": "offer",
    "sellerId": null,
    "sellerName": null,
    "city": "Las Palmas de Gran Canaria",
    "province": "Las Palmas",
    "region": "Canarias",
    "category": "Teléfonos inalámbricos",
    "categoryPath": "telefonia/telefonos-inalambricos",
    "publishDate": "2026-08-15T18:15:55Z",
    "updateDate": "2026-08-21T08:18:09Z",
    "isHighlighted": true,
    "isReserved": false,
    "storageGb": 128,
    "batteryHealth": null,
    "matchedKeyword": "iphone 15"
}
```

| Field | Meaning |
|---|---|
| `price` / `previousPrice` / `priceDropPercent` | Euros and a parsed percentage. `price` is `null`, never `0`, when the ad has no comparable price at all — roughly 45 in 1,248 on a sampled search. |
| `condition` / `color` | The ad's own structured tags — facts, not seller claims. `null` when a tag is absent. |
| `isReserved` | A real boolean — the site's own field is a string enum, derived here into `true`/`false`. |
| `storageGb` / `batteryHealth` | Parsed from free text, since Milanuncios carries neither as a structured field. `null` when not stated. |

### Why this can undercut the competition

Existing Milanuncios Actors charge $1.25 to $4.99 per 1,000 listings. This one reads the
page's own embedded JSON at 100 results per request, so 1,000 listings is **10 requests**,
not 10 browser sessions. Measured platform cost is about $0.02 per 1,000 listings. The price
is **$1 per 1,000**.

### Notes and limits

- **Category and province combine into one path segment**, not two, and four province slugs use
  an underscore instead of a hyphen. An invalid slug silently redirects to the unfiltered
  results rather than erroring, so this Actor validates before requesting.
- **Several query parameters look like they work and do nothing**, including the full-word
  `vendedor=`, `precio_desde`, `envio=si`, `orden=fecha`, `categoryIds` and `categoria` — all
  return a plausible 200 with an unchanged result count, and this Actor never sends them.
- **No API key needed.** Milanuncios answers Apify's datacenter IPs with HTTP 405, so this
  Actor routes its fetches through a commercial unblocking service that the developer pays
  for. Nothing to configure, no second account to open, and no proxy cost added to your run.
- **Seller identity is withheld for private sellers.** `sellerName` and `sellerId` are
  returned only when `sellerType` is `professional` — a shop or dealer. For private
  individuals both come back `null`. `sellerType` itself is always returned, so you can still
  tell dealer stock from private stock.
- **Ad descriptions are reproduced exactly as the seller wrote them** and are not filtered.
  Sellers sometimes put a phone number or messaging handle in that free text. Handle the
  `description` field accordingly.
- Reads the same public page a browser does. See the disclaimer at the top.

# Actor input Schema

## `searchQueries` (type: `array`):

One search per keyword. There is no default: an empty list fails the run rather than searching for something you did not ask for.

## `category` (type: `string`):

Optional. Milanuncios' category is a URL path segment, not a query parameter. Verified top-level slugs: motor, inmobiliaria, ofertas-de-empleo, formacion, servicios, negocios, informatica-segunda-mano, imagen-y-sonido, telefonia, telefonos-moviles, juegos, casa-y-jardin, moda-y-complementos, bebes, aficiones, deportes-nautica, mascotas. Combines freely with province - setting both narrows to their intersection (e.g. category=telefonia + province=madrid -> path /telefonia-en-madrid/, narrower than either alone). Validated against the known slug list before the run starts; an unknown slug fails the run rather than silently searching every category.

## `province` (type: `string`):

Optional. Restricts results to one Spanish province via its own URL path segment. Combines freely with category, not exclusively - setting both narrows the path to their intersection (e.g. /telefonia-en-madrid/ is narrower than either /telefonia/ or /anuncios-en-madrid/ alone). A closed set of 52 (50 provinces plus Ceuta and Melilla), so this is a dropdown rather than free text - a typo is caught here instead of failing the run later.

## `minPrice` (type: `integer`):

Optional. Maps to the site's own "desde" parameter. Left out of the request entirely when unset - there is no default floor, and 0 is not silently substituted.

## `maxPrice` (type: `integer`):

Optional. Maps to the site's own "hasta" parameter. Left out of the request entirely when unset - there is no default ceiling.

## `sellerType` (type: `string`):

Filters by who is selling, using the site's own "vendedor" parameter. Only the truncated values "part"/"prof" work server-side - the full words "particular"/"profesional" are silently ignored, which is why this is an enum rather than free text.

## `adType` (type: `string`):

Filters by whether the ad is an offer to sell or a request to buy, using the site's own "demanda" parameter (n = offer, s = wanted).

## `onlyShippable` (type: `boolean`):

Maps to the site's own "isShippable=true" parameter. Left out of the request entirely when false - there is no explicit isShippable=false.

## `onlyPriceDropped` (type: `boolean`):

Maps to the site's own "isPriceDropped=true" parameter. Left out of the request entirely when false.

## `conditions` (type: `array`):

Milanuncios carries a structured "estado del producto" tag on each ad, so this is closer to a fact than a seller's claim. Filtered client-side against that tag. Leave empty for any condition. The vocabulary below is a sample measured over 100 live ads, not a closed set - an ad with an unseen condition value is never offered here but still passes through unfiltered results untouched.

## `minStorageGb` (type: `integer`):

Parsed from the title and description, since Milanuncios does not carry storage as a structured field. Items that never state it are dropped when this is set. Leave empty to ignore storage. TB values are converted, so 1TB counts as 1024.

## `minBatteryHealth` (type: `integer`):

Parsed from the description text. Only meaningful for phones - non-phone listings simply never match and are dropped if this is set. Leave empty to ignore battery health.

## `excludeDamaged` (type: `boolean`):

Drops listings whose description matches known damage wording (cracked screen, broken, for parts, etc.), using the same vocabulary as the sibling Wallapop Actor. On by default.

## `requireKeywordMatch` (type: `boolean`):

Milanuncios' search is fuzzy - searching "iphone 15" can also return unrelated accessories or other models. This drops any item whose title does not contain the query. Turn it off for broad exploration.

## `sortBy` (type: `string`):

The site's own sort rules, via the "orden" parameter. Order only - it changes nothing about which ads exist, only the order they are paged in.

## `maxItems` (type: `integer`):

Hard cap on rows pushed for the whole run. When this cap or the site's own page cap is hit, it is logged rather than silently truncating the result set.

## `proxyConfiguration` (type: `object`):

Optional. Sustained scraping earns the requesting IP a silent block - a 200 with a plausible page and no data blob at all, with no 429 and no Retry-After to signal it. A small run works fine without a proxy; a rotating one is what keeps a large run alive. Left unset, no proxy is used at all.

## Actor input object example

```json
{
  "searchQueries": [
    "coche"
  ],
  "sellerType": "any",
  "adType": "any",
  "onlyShippable": false,
  "onlyPriceDropped": false,
  "conditions": [],
  "excludeDamaged": true,
  "requireKeywordMatch": true,
  "sortBy": "relevance",
  "maxItems": 1000,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `matches` (type: `string`):

Matching ads: price, condition, seller type, location and a link to the ad.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchQueries": [
        "coche"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("reventadata/milanuncios-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchQueries": ["coche"],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("reventadata/milanuncios-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchQueries": [
    "coche"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call reventadata/milanuncios-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,reventadata/milanuncios-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/lauN4x9OhDWZy50bz/builds/ib7RgYY3wSFd9aMiJ/openapi.json
