# Changelog of Wallapop Cars Scraper — Spain Classifieds (`rastriq/wallapop-cars-scraper`) Actor

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

## Changelog

### 0.6 — 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 before it reaches the dataset:

- `user_id` → `null`
- `latitude` / `longitude` → truncated to 2 decimals (~1 km precision)
- `description` → phone numbers and emails replaced with `[REDACTED]`

Professional seller data is never affected. The toggle is off by default — no change in
behaviour for existing users.

### 0.6 — 2026-08-23 · `timeFilter` filters by modification, not publication

`timeFilter` was documented as returning "listings created or modified today". Only the second half
is true, and the difference matters.

Measured against the live API, share of results that had gone more than 24 h without a
modification:

| | Unfiltered | `today` |
|---|---:|---:|
| By `modified_at` | 317 / 320 | **8 / 318** |
| By `created_at` | 320 / 320 | 159 / 318 |

The filter works — on `modified_at`. So:

- a listing **published 786 days ago and edited this morning is returned** by `today`;
- a listing **published yesterday and untouched since is not**.

These feeds bump and re-index constantly, so recency of modification says very little about
recency of publication. Anyone reaching for `timeFilter` to capture new supply will silently miss
most of it. The schema now says so, and the actor logs the same warning at runtime when the filter
is used.

Also measured: `today`, `lastWeek` and `lastMonth` returned **identical result sets** (238 of 238),
because the feed is dominated by recently bumped listings. The granularity between values is not
reliable and the schema says that too.

Where it is genuinely useful: sweeping for recent *activity* — price changes and bumps — without
scanning the whole catalogue.

### 0.6 — 2026-08-23 · `user_id`, and the truth about `seller_type`

`seller_type` has been null on 100% of output since this actor shipped. The fallback that was
supposed to fill it —

```python
item.get("seller_type") or (item.get("user") or {}).get("type")
```

— could never have worked: **`/api/v3/search` returns no `user` object at all.** Verified against
the live endpoint, an item's keys are exactly:

```
bump, category_id, created_at, description, favorited, has_warranty, id, images,
is_favoriteable, is_refurbished, is_top_profile, location, modified_at, price,
reserved, shipping, taxonomy, title, type_attributes, user_id, web_slug
```

The seller's type lives on a different endpoint: `GET /api/v3/users/{user_id}` returns
`seller_type`, `type` and `micro_name`, and `/stats` returns `rating_average` and `ratings`. That is
one extra request **per seller**, so it belongs behind a flag with a per-user cache, not in the row
builder. `seller_type` is now left null rather than guessed, and the dead fallback is gone — with a
test asserting it cannot come back.

**`user_id` is in the search response and costs nothing.** It is now mapped. On a 60-listing sample
of Madrid cars it revealed **4 unique sellers, one of them holding 54 of the 60 listings** — a
dealer identified with no extra request. Seller recurrence and supply concentration are answerable
today; professional-vs-private can follow by enriching only the unique ids.

### 0.6 — 2026-08-23 · Geographic stop, and the region as the unit of trust

`geoStopPostalPrefix`, `geoStopThreshold` (0.5), `geoStopPages` (3). When several
consecutive pages fall outside the postal prefix, the sweep stops. The streak resets if a page
comes back inside, so one border page cannot end a run, and a listing with **no** postal code is
never counted as outside — an absent field would otherwise look like drift.

#### Why this is not a workaround for a broken filter

Wallapop honours `distance_in_km` as a hard cut. Measured: 65 km requested returned a maximum of
65.7 km across 997 unique listings, with no drift beyond it. **The mismatch is geometric** — a 65 km
disc around Bilbao contains all of Álava, half of Gipuzkoa and part of Cantabria. With
`orderBy=closest` the sweep works outward, so leaving the region means there is nothing left inside
it.

Measured on Bizkaia, 997 listings within 65 km of Bilbao:

| Band (km) | Total | CP 48 | Outside |
|---|---:|---:|---:|
| 0–5 | 411 | 411 | 0% |
| 10–15 | 31 | 27 | 13% |
| 25–30 | 25 | 13 | 48% |
| 35–40 | 13 | 1 | 92% |
| 40–50 | 293 | 0 | 100% |

The mix crosses 50% at ~35 km, and **only 1 of 542 Bizkaia listings sits beyond that point**. The
far corner is geometrically real — Ermua is 36 km out — but holds almost no supply.

#### `geo_stop` is a complete sweep *of its region*

It joins `feed_exhausted` as a termination that sets `sweep_complete`, and `SEEN_IDS.scope` now
carries what makes that safe:

- **`postalPrefix`** — which slice the id list speaks for. Without it a consumer would compare a
  Bizkaia sweep against a national catalogue and deactivate the rest of Spain.
- **`inScopeScanned`** — listings **scanned** inside the region, not emitted. Emitted excludes
  unchanged listings, which are exactly the ones still alive.
- **`maxDistanceKm`** — how far the sweep actually reached, so the consumer can judge whether the
  far corner was covered rather than assume it.

#### A defect the negative control caught

Pointing the search at Vitoria while asking for prefix `48` stopped cleanly after 120 listings with
`inScopeScanned: 0` — and reported `reconcilable: true`. A consumer would have deactivated every
Bizkaia listing it held. **When a prefix is set the region is the denominator**, so reconcilability
now requires `in_region_scanned > 0`, not merely `scanned > 0`.

Found by deliberately running the guard against a region it could not satisfy, not by reading the
code.

### 0.6 — 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.6 — 2026-08-21 · Ledger-based delta engine

#### Added

- **Ledger-based incremental mode.** `enableCursor` now tracks a per-stream ledger of seen
  listing ids plus a content fingerprint over `price / km / title / reserved /
  has_warranty / is_refurbished / seller_type`. A listing is emitted when its id is unknown
  or its fingerprint moved; unchanged listings are skipped and not charged.
- **`_delta_status`** (`"new"` / `"updated"`), **`_changed_fields`**, **`_previous_price`**
  and **`_price_delta`** on every record in incremental mode. Price movements are now
  first-class output rather than something the consumer has to diff.
- **`cursorId` input** — namespaces the ledger. Runs sharing a stream id share one ledger;
  different ids are independent. Derived from the search filters when left empty.
- **`emitSeenIds` input** — writes every listing id observed during the sweep (including
  those skipped as unchanged) to `SEEN_IDS` in the run key-value store, so consumers can
  detect delisted/sold vehicles. No extra cost.
- **`maxPages` input** — explicit cap on pages swept. `0` keeps the previous automatic
  behaviour in normal mode and allows ~30 pages in incremental mode, where most listings are
  skipped and more pages must be walked to reach the new ones.
- **`location: "espana"`** added to the input schema enum. The code already defined it as the
  nationwide default; the schema rejected it with `invalid-input`.
- **Delta view** in the dataset schema, surfacing status and price movement.
- **Offline test suite** (`tests/test_delta_engine.py`, 26 assertions) including a replay of
  real production datasets.

#### Changed

- **Removed the early-stop.** The run no longer aborts when it meets a listing at or below a
  watermark. It sweeps to `maxItems` / `maxPages` and lets the ledger decide what to emit.
- **`SUMMARY` is now diagnostic.** Reports `scanned`, `emitted_new`, `emitted_updated`,
  `unchanged_skipped`, `ledger_size`, `ledger_pruned` and `engine`. The old `skipped_cursor`
  counter was structurally incapable of exceeding 1 and is gone.
- **Empty delta runs are no longer an error.** A run that scans listings and emits none is a
  correct outcome and is logged as such. A run that scans *nothing* still raises.

#### Fixed

- **Incremental mode dropped new listings permanently.** v0.5 stored the newest `modified_at`
  seen and stopped the run at the first listing at or below it. Two properties of Wallapop's
  API break that scheme:

  1. `order_by=newest` is not ordered by `created_at` or `modified_at` — measured at 57% of
     adjacent pairs out of order over 160 listings.
  2. `modified_at` is a re-index timestamp bumped in bulk, not a seller-edit timestamp.

  Together they meant the watermark advanced past listings the run had never seen. Because the
  feed is a rotating window, a listing published at 06:12 could surface at 06:20 — by which
  point the watermark had moved to 06:14 and the listing was below it **forever**.

  Measured end-to-end on 2026-08-21 over a 17-minute window, with a simultaneous control run:
  15 genuinely new listings, 9 delivered, **6 lost permanently — 60% recall**. Replaying the
  same production datasets through the v0.6 ledger recovers **15/15 — 100% recall**.

- **The cursor was global per account.** State lived under a single key
  (`FIREHOSE_CURSOR`, optionally suffixed by `sellerType`), so unrelated searches shared one
  watermark and cancelled each other out. Ledgers are now namespaced per stream.

- **Stale listings aborted whole runs.** A late-surfacing listing near the top of the feed set
  `cursor_hit` and killed the run — observed returning 3, 5, 8 and 9 items on runs configured
  for 100. No early-stop, no aborts.

#### Migration

Automatic and requires no action. v0.6 uses a new store (`wallapop-delta-state`) and does not
carry over the v0.5 watermark, which was not sound enough to build on. The first v0.6 run with
`enableCursor: true` captures a full baseline and seeds the ledger; subsequent runs return
deltas. Legacy v0.5 state is detected and reported in the log, and is left untouched.

Consumers should expect one larger-than-usual run at the transition. Anyone who was running
v0.5 incrementally should treat that run as a catalogue rebuild, since v0.5 gaps are not
recoverable from Wallapop after the fact.

#### Compatibility

Backward compatible. All existing input fields keep their names and semantics; new fields are
optional and additive. Runs with `enableCursor: false` behave exactly as before apart from the
richer `SUMMARY`.
