# Milanuncios (Segunda Mano) Scraper - Ads, Prices, Province (`lanternlab318/milanuncios`) Actor

Collect public Milanuncios ads from any category, province or keyword: ad ID, title, price in EUR, province, city, category path, publish time and seller type. No login, no cookies, no personal seller data. Rows are deduplicated; empty and partial inputs are never charged.

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

## Pricing

$1.00 / 1,000 listings

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Milanuncios Scraper — public Spanish classified ads, by keyword, category or province

Collect public ads from **Milanuncios**, Spain's largest classifieds site, from any
category, filter, province or keyword you can open in a browser. Every row carries the
ad's own ID, title, price in EUR, province, city, full category path, publish time,
seller type and the ad URL.

**What you get per ad:** `listingId` · `title` · `price` (EUR) · `previousPrice` and the
price-drop badge · `province` · `city` · `region` · `category` and the whole
`categoryPath` · `publishedAt` and `updatedAt` (the platform's own times) · `specs` the
seller filled in · `imageCount` · `url` · `sellerType` (`private` / `professional`) ·
`sellType` (`supply` / `demand`) · `collectedAt` (when we read it).

**Price:** pay per listing — you are charged for listing rows only. A search that
returns nothing, a search that was cut short, and the run summary are all free rows, so
you never pay for a result you did not get.

No login, no cookies, no account. Only data a logged-out visitor sees on the result page.

### Input

```json
{
  "searchQueries": ["bicicleta eléctrica"],
  "province": "madrid",
  "maxListings": 200
}
```

Or point it at a result page you already have — any category, filter or sort:

```json
{
  "searchUrls": [
    "https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/",
    "https://www.milanuncios.com/motos-de-segunda-mano/?precio_hasta=3000"
  ],
  "maxListings": 500,
  "maxListingsPerInput": 100
}
```

| Field | What it does |
| --- | --- |
| `searchQueries` | Spanish keywords, one per line. Each is searched separately and its rows are labelled with it. |
| `searchUrls` | Milanuncios result-list URLs copied from the address bar. Every filter in the URL is kept. A URL copied from page 3 starts at page 3. |
| `province` | Province slug as Milanuncios spells it in its own `/anuncios-en-<slug>/` links: `madrid`, `barcelona`, `valencia`, `baleares`, `guipuzcoa`, `vizcaya`, `tenerife`. Narrows `searchQueries`. |
| `maxListings` | Upper bound on listing rows for the whole run. Default 100. |
| `maxListingsPerInput` | Optional share of that budget per keyword or URL, so one input cannot eat the run. |
| `maxUpstreamRequests` | Optional hard ceiling on requests to Milanuncios. Left empty, one is derived from your input — it is never unbounded. |
| `proxyConfiguration` | Defaults to Apify Proxy residential with a Spanish exit IP, which is what this Actor is measured on. |

`province` narrows keywords only, and takes the slug Milanuncios itself uses —
lowercase, unaccented Castilian, one word for almost every province. A slug the site
does not have is reported as `url_not_found`, never quietly searched across all of Spain.

For `searchUrls` put the province in the URL itself: Milanuncios keeps it inside the path
of each category page and the shape differs per category
(`/coches-de-segunda-mano-en-madrid/`, `/pisos-en-barcelona-barcelona/`), so copy the
real link rather than building one. Setting `province` with only URLs is rejected rather
than silently ignored.

### Output

One row per ad:

```json
{
  "recordType": "listing",
  "listingId": "616474771",
  "title": "Bicicleta eléctrica de montaña",
  "price": 1900,
  "currency": "EUR",
  "previousPrice": 2000,
  "priceDropLabel": "- 5 %",
  "province": "Madrid",
  "city": "Alcorcón",
  "region": "Madrid",
  "category": "Bicicletas de montaña",
  "categoryPath": ["Deportes y náutica", "Deportes", "Bicicletas", "Bicicletas de montaña"],
  "publishedAt": "2026-09-24T18:41:49Z",
  "updatedAt": "2026-10-24T18:37:05Z",
  "specs": [{ "name": "color", "value": "verde claro" }],
  "imageCount": 2,
  "url": "https://www.milanuncios.com/bicicletas-montana-de-segunda-mano/bicicleta-electrica-de-montana-616474771.htm",
  "sellerType": "private",
  "sellType": "supply",
  "query": "bicicleta eléctrica",
  "sourceUrl": "https://www.milanuncios.com/anuncios-en-madrid/?s=bicicleta+el%C3%A9ctrica",
  "page": 1,
  "rank": 1,
  "partial": false,
  "partialReason": null,
  "collectedAt": "2026-10-25T09:12:44.118Z"
}
```

Plus one free `input_status` row per keyword or URL, saying what it returned and whether
that is all there was:

```json
{
  "recordType": "input_status",
  "query": "bicicleta eléctrica",
  "listingsReturned": 41,
  "duplicatesSkipped": 0,
  "pagesFetched": 1,
  "totalAds": 6923,
  "totalAdsRelation": "eq",
  "lastPageOffered": 169,
  "partial": false,
  "partialReason": null,
  "statusText": "Collected 41 listing(s) for this input; the result list was followed to its end or to the limits you set."
}
```

And `RUN_SUMMARY` as the last dataset row and under that key in the run's key-value
store: rows collected and charged, duplicates skipped, pages read, empty inputs, partial
inputs by reason, requests sent against the ceiling, and bytes received.

**`partial: true` never means "no ads".** It means that input's results are incomplete,
and `partialReason` says why:

| `partialReason` | What happened |
| --- | --- |
| `listing_limit_reached` | Your `maxListings` or `maxListingsPerInput` cut this input short. |
| `upstream_request_cap` | The run's request ceiling was reached while on this input. |
| `blocked` | Milanuncios served its interstitial from every proxy session tried. |
| `search_rejected` | Milanuncios rejected the search itself and returned its own error instead of a result list. |
| `url_not_found` | The URL does not exist — Milanuncios 404s it or redirects it to its homepage. |
| `page_not_rendered` | A 200 arrived with no result payload in it. |
| `upstream_error` | The request failed for another reason. |
| `not_collected` | The run stopped before reaching this input. |

An input that genuinely has no results is written with `partial: false` and
`listingsReturned: 0`.

### Limits and things to know

- **Spanish residential IPs only.** Milanuncios answers a datacenter IP with a 403
  "Pardon Our Interruption" interstitial. The default proxy setting handles this;
  changing it will make runs report `partialReason: "blocked"`.
- **Pagination depth.** Milanuncios offers at most 200 pages (~8,200 ads) per result
  list and caps its own reported total at 10,000 — `totalAdsRelation: "gte"` means the
  count is a lower bound. To go deeper, split the search by province, category or a
  price filter.
- **No personal data.** No seller name, no phone number, no email and no free-text ad
  description is collected. `sellerType` keeps the private/professional distinction. A
  professional ad's display name is skipped too, because Milanuncios puts a person's own
  name there as often as a shop's.
- **Result pages only.** This Actor reads the fields Milanuncios renders into its result
  lists; it does not open each ad's own page, so ad-detail-only fields are not included.
- **A province page is not a hard filter.** Milanuncios mixes a couple of nationally
  promoted ads into a province result list — measured 39 of 41 on `baleares` and on
  `guipuzcoa`, 41 of 41 on `madrid`. Every row carries its own true `province`, so filter
  on the field rather than trusting the page.
- **Deduplication is per run.** An ad matching several keywords is written and charged
  once. Across two separate runs you will see it twice.
- **Prices are as stated.** `price: 0` is what Milanuncios sends for an ad that names no
  price; it is passed through rather than guessed at.

# Actor input Schema

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

One Milanuncios search keyword per line, in Spanish (bicicleta, iphone 15, sofá chaise longue). Each keyword is searched separately, in the province set below, and its ads are labelled with it. An ad that matches several keywords is returned once and charged once. Leave empty if you give result page URLs instead.

## `searchUrls` (type: `array`):

Milanuncios result-list URLs, one per line - open the category, the filter or the search you want on milanuncios.com and copy the address bar: https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/, https://www.milanuncios.com/pisos/, https://www.milanuncios.com/motos-de-segunda-mano/?precio_hasta=3000. Every filter in the URL is kept. A URL copied from page 3 starts collecting at page 3.

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

Spanish province slug as Milanuncios spells it in its own /anuncios-en-<slug>/ links: madrid, barcelona, valencia, sevilla, baleares, guipuzcoa, vizcaya, tenerife - lowercase, unaccented Castilian, one word for almost every province. It narrows the keywords above to that province. A slug Milanuncios does not have is reported as url_not_found rather than quietly searched across all of Spain. It does not change the result page URLs, because Milanuncios keeps the province inside the path of each category page and the slug differs per category - put it in the URL itself there. Leave empty to search all of Spain.

## `maxListings` (type: `integer`):

Upper bound on the listing rows this run writes in total. One Milanuncios result page carries 41-42 ads, so this also decides how many pages per input the run may walk. The input that hits the bound is marked partial with reason "listing_limit_reached".

## `maxListingsPerInput` (type: `integer`):

Optional share of the run budget for one keyword or URL. Leave it empty and the first input may spend the whole of maxListings, which is what a run with a single input wants; set it when you give several inputs and want each of them collected. An input that reaches its share is marked partial with reason "listing_limit_reached" and the next input still gets its own.

## `maxUpstreamRequests` (type: `integer`):

Hard ceiling on the HTTP requests this run sends to Milanuncios, counting every result page and every retry. Left empty, the Actor works a ceiling out from your inputs and maxListings. At the ceiling collection stops - the inputs not reached are written with partialReason "upstream_request_cap" and the run then ends normally.

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

The proxy the requests to Milanuncios go through. The default is Apify Proxy residential with a Spanish exit IP, which is what this Actor is measured on: Milanuncios answers a datacenter IP with a 403 "Pardon Our Interruption" interstitial, and a Spanish residential IP with the fully rendered result list. Changing this is likely to make runs report partialReason "blocked".

## Actor input object example

```json
{
  "searchQueries": [
    "bicicleta"
  ],
  "searchUrls": [
    "https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/"
  ],
  "maxListings": 100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "ES"
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

# 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": [
        "bicicleta"
    ],
    "searchUrls": [
        "https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("lanternlab318/milanuncios").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": ["bicicleta"],
    "searchUrls": ["https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/"],
}

# Run the Actor and wait for it to finish
run = client.actor("lanternlab318/milanuncios").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": [
    "bicicleta"
  ],
  "searchUrls": [
    "https://www.milanuncios.com/coches-de-segunda-mano-en-madrid/"
  ]
}' |
apify call lanternlab318/milanuncios --silent --output-dataset

```

## MCP server setup

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

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/TYPXrxvbubJSp6X9f/builds/LfWSCn8vg2sqA0Fzt/openapi.json
