# Changelog of Milanuncios Scraper (`rastriq/milanuncios-scraper`) Actor

- **URL**: https://apify.com/rastriq/milanuncios-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/rastriq/milanuncios-scraper.md

## Changelog

### 0.2 — 2026-09-16 · `stripSellerPII` — GDPR PII removal for private sellers

New boolean input `stripSellerPII` (default: `false`). When enabled, personal data of private
(non-professional) sellers is removed from the output:

- `userId`, `authorId`, `contactPhone` → `null`
- `authorLatitude` / `authorLongitude` → truncated to 2 decimals (~1 km)
- `description` → phone numbers and emails replaced with `[REDACTED]`

Professional seller data (shop name, phone, address) is never affected.

### 0.2 — 2026-08-22 · `SEEN_IDS` carries its own trustworthiness

`SEEN_IDS` was a bare list of ids. A consumer reading `held − SEEN_IDS = sold` would have deleted
live vehicles whenever a sweep was truncated or blocked — and a rate-limited run, which scans
nothing, would have read as "every car in this area sold overnight".

The record now reports whether it can be trusted:

- **`reconcilable`** — true only when the sweep exhausted its slice AND scanned more than zero.
- **`sweep_ended`** — `feed_exhausted` / `last_page` / `max_pages` / `max_items` / `http_error` /
  `no_payload` / `unhandled_error`. Every termination path is now labelled at the point it happens.
- **`scope`** — the filters that define the slice this list speaks for. Absence is only meaningful
  inside it; comparing a provincial sweep against a national catalogue deletes the country.
- An unusable sweep logs a **warning** instead of passing silently.

`SUMMARY` carries `sweep_complete` and `sweep_ended` too, so the canary can track truncation.

Verified on the platform: a finished sweep reports `reconcilable: true, sweep_ended: "last_page"`;
a capped sweep reports `false` with the reason; and the promotion smoke run hit a real coches.net
rate limit and correctly reported `reconcilable: false, sweep_ended: "http_error"`.

### 0.2 — 2026-08-21 · Ledger delta engine, geo, coordinates, and three silent bugs fixed

#### Fixed — three filters that failed without saying so

All three were found while measuring national volume for a client quote, not by a failing run.
Each returned HTTP 200 and plausible-looking output.

- **`tag_km` and `tag_year` were always null.** The parser keyed tags on `kilometros` and
  `anno`; the source writes `kilómetros` and `año`. Measured over 1,000 live listings: the
  source supplies km on 974 and year on 1,000, and the parser found **0 of 1,000** for both.
  `combustible` carries no accent, so that one field kept working and the block looked healthy.
  Tag keys are now folded (accent-stripped, lowercased) on the way in, so the next tilde cannot
  empty a field in silence. Values are parsed to integers — `"299.000 kms"` → `299000` — with
  absent distinguished from a genuine `0 kms`.

- **The price filter never filtered.** `minPrice`/`maxPrice` were sent as `precio_min` /
  `precio_max` query parameters, which Milanuncios ignores. Verified on Soria: 298 listings
  unfiltered, and 298 with `precio_max=1`. Price is a **path segment** on this site —
  `/precio/hasta-3000-euros/`, `/precio/desde-N-hasta-M-euros/`. Every price filter a user set
  was a no-op and nothing in the run said so.

- **An unknown province returned the whole of Spain.** Verified: a made-up slug and a
  misspelled one produce byte-identical national feeds — listings from Barcelona, León, Lugo,
  Madrid, Teruel — with a 200 and no warning. Our own input schema documented `bizkaia`, while
  the site uses `vizcaya`, so the documented example itself would have swept nationally and
  billed for it. The 52 real slugs are now embedded (multi-word ones use an underscore:
  `ciudad_real`, `las_palmas`), with generous normalisation for accents, co-official forms and
  slash variants, and a fuzzy fallback for typos. Only genuinely unrecognisable input raises —
  and it stops the run rather than sweeping the country.

#### Added

- **Incremental mode (`enableCursor`)** running the same ledger engine as the Wallapop and
  coches.net actors. A listing is emitted when its id is unknown, or when a fingerprint over
  `price / title / isReserved / sellerType / financedPrice / isHighlighted / cityName` moves.
  Emitted rows carry `_delta_status`, `_changed_fields`, `_previous_price` and `_price_delta`.
- **`cursorId`** — namespaces the ledger. Runs sharing an id share one ledger, so a listing
  surfaced by several queries (or several sources) is delivered once.
- **`emitSeenIds`** — writes every observed listing id to `SEEN_IDS`, including those skipped as
  unchanged, so consumers can expire vehicles that have sold.
- **Location fields**: `latitude`, `longitude`, `ine_municipality_code`, `geo_precision`.
- **`tag_transmission`** — the `cambio` tag, present on ~3% of listings and previously dropped.
- **`fetchCoordinates`** — fetches the listing detail page to get the seller's real coordinates
  from `ad.author.location`. Because it runs *after* the delta decision, only new and changed
  listings pay the extra request.

#### Why timestamps are not used as the change signal

Milanuncios publishes `publishDate`, `updateDate` and `sortDate`, and `updateDate` reads like a
seller-edit signal. Measured against the live site, it isn't:

```
?orden=date  ->  sortDate    40/41 descending
                 updateDate  40/41 descending      <- moves in lockstep
                 publishDate 29/41                 <- not the sort key
```

`updateDate` and `sortDate` sit a fixed ~58-minute offset apart and dozens of listings share the
same value to the second. That is a bulk re-index, the same pattern that made Wallapop's
`modified_at` useless.

The feed also churns hard: two identical sweeps seconds apart shared **1 listing out of 40**. Any
watermark scheme would advance past listings the run never saw.

So nothing is trusted except the listing id and the content itself. Verified by test: a listing
whose timestamps moved but whose content did not is *not* re-emitted.

#### Geo resolution, and its honest ceiling

`province.id` **is** the official INE province code. `city.id` is **not** the INE municipality code
— it is Milanuncios' own identifier — so the direct lookup that works for coches.net does not
transfer. Municipality is resolved by matching `city.name` within the province against the official
register, with name variants for bilingual spellings (`Galdakao/Galdacano/Galdakano`) and trailing
articles (`Palmas de Gran Canaria, Las`).

Measured on 42 live listings: **100% get coordinates, 62% at municipality precision.** The
remaining 38% are listings where Milanuncios reports a sub-municipal locality — `El Bocal`,
`Urbanizacion los Almendros`, `Barrio de el Castillo` — which is not a municipality and is
deliberately **not** force-matched to one. Those fall back to the province centroid, labelled.

That gap is precisely why `fetchCoordinates` matters more on this source than on coches.net: the
detail page has real seller coordinates for exactly the listings a centroid cannot place. Measured
on a sample: 3 of 5 detail fetches returned seller coordinates, ~2 km from the centroid.

#### Fixed

- **Pushes are batched with per-row fallback.** A single malformed listing can no longer abort a
  sweep — the failure mode that silently truncated the coches.net actor for three weeks.
- **Container entrypoint.** `CMD ["python", "src/main.py"]` put the file's own directory on
  `sys.path` instead of the project root, so the new relative imports failed at runtime with a
  green build. Now `python3 -m src`.

#### Compatibility

Backward compatible. Existing inputs keep their names and behaviour; new fields are optional. With
`enableCursor: false` the actor behaves as before, plus the location fields and the safer push.
