# Changelog of Wine-Searcher scraper by grape variety: prices (`mrbridge/wine-searcher-grape-scraper`) Actor

- **URL**: https://apify.com/mrbridge/wine-searcher-grape-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/mrbridge/wine-searcher-grape-scraper.md

## Changelog

All notable changes to **Wine-Searcher Grape Scraper** are documented here.

### v2.3.8 (2026-09-26)

#### Fixed

- **Each wine is delivered and charged once, on every grape.** On a grape with fewer than 25 wines, page 2 repeated page 1, so every wine was delivered and charged twice: 26 charges for 13 wines on a live run. Duplicates are now caught, and pagination stops after the first page that adds no new wine.
- **A run that hits an unexpected error no longer looks successful.** It now ends FAILED, and its `OUTPUT` record carries `status: "failed"`, the error code and message, and the counters up to the failure.

#### Changed

- Pages are fetched through a US connection, so `avg_price` is in US dollars. `targetCurrency` still converts it to any of the 30 codes.

#### Changed (breaking - product_url)

BREAKING CHANGE

- `product_url` is now one canonical link per wine, `https://www.wine-searcher.com/find/<wine>`, without the `/1/` ending it used to carry. A link to a specific vintage keeps its year. If you join datasets on `product_url` across runs, links written before this release will not match the new ones: re-key once on the new form.

### v2.3.7 (2026-08-15)

#### Fixed (HIGH - PPE leak: wines were delivered without being charged)

- Wines are now delivered one by one with `Actor.pushData(wine, 'wine-result')`, which computes the user's remaining budget BEFORE writing the row. The previous shape pushed the whole page and then called `Actor.charge({ count: wines.length })`, reading only `eventChargeLimitReached`: when the remaining budget covered 3 of 10 wines, 10 were delivered and 3 were billed. The `chargedCount` field was never read, and a charge that threw was logged and the loop carried on, handing out unbilled rows. Monetization audit 2026-08-14 finding F2.
- The run now stops the moment the budget is exhausted, before spending another ZenRows call. Counters, the quality sample and the OUTPUT summary count only wines that were both delivered and charged, so `totalWines` and `chargedWineEvents` are the same number by construction.
- A billing exception is no longer swallowed. It aborts the run after persisting a `CHECKPOINT` record, and surfaces as a failed run instead of a silent free delivery.
- ZenRows error rows and the `__circuit_break__` sentinel are still pushed without an event name, so they stay free.
- Note on `chargedCount`: the SDK merges the `wine-result` ChargeResult with the synthetic `apify-default-dataset-item` one, so a single delivered wine reports `chargedCount: 2`. On the live grid that is one paid charge plus one free synthetic tick, not two invoices. It is read as a boolean and the wine counter is incremented by one, which a regression test locks down.

#### Added (startup guard on the pricing grid)

- The run now refuses to start when the resolved pricing grid would mis-bill: `wine-result` missing or priced at zero (every wine delivered unbilled), or `apify-default-dataset-item` priced alongside it. Apify charges BOTH events for a single dataset row, so pricing the synthetic event would double every invoice. The guard runs before the first ZenRows call and before any dataset write, so a misconfigured Actor fails closed with an explicit status message instead of scraping. Local runs, where no pricing info exists, are unaffected.

#### Added (MEDIUM - graceful shutdown)

- `Actor.on('aborting')` handler registered after `Actor.init()`. Within the platform's 30-second window it flags the run so no new page is fetched, writes a compact `CHECKPOINT` record to the key-value store (current grape and page, next grape index, counters, stop reason) and updates the status message. It charges nothing: with per-wine delivery there is no unbilled buffer left to settle, and re-charging would double-bill. The handler is idempotent, so a repeated signal writes no second checkpoint. Audit finding F4.
- The checkpoint is kept for diagnostics and a possible future resume. The Actor does NOT read it back on restart, a restarted run starts from its input again.

#### Changed (MEDIUM - tiered pricing)

- Removed the unused `PRICE_PER_ITEM_USD = 0.003` constant, which stated the FREE-tier price as if it were everyone's price (paid tiers run down to 0.002). The run now resolves the price actually billed to the caller via `Actor.getChargingManager().getPricingInfo().perEventPrices`, logs it at startup and reports it in OUTPUT as `eventPriceUsd`. `PPE_PRICE_USD_OFFLINE_FALLBACK` is kept as the local-run fallback only. Audit finding F3.

#### Fixed (README factual drift)

- Output fields documented as `grape` / `grape_url` since the v2.2.0 rename are now `region` / `region_url`, and the undocumented `country` and `label_image_url` fields were added to the field table and the output example.
- Documented the `maxWines` input (default 1000, `0` for unlimited).
- Removed the claim that Camoufox, Crawlee, a session pool and Apify residential proxies are still in use. They were all dropped in v2.0. The Actor is ZenRows-only and the ZenRows key is operator-side, so users never supply one.
- Corrected "the scraper retries each page with a fresh proxy session" to the real behaviour: a single retry after 5s on 408 / 429 / 5xx and network timeouts, no retry on permanent failures.
- Corrected the memory section, which still claimed a 4096 MB default left over from the Python era. It now states the real values: default 512 MB, minimum 512 MB, maximum 2048 MB.
- The pricing section no longer restates rates, tier tables or per-scenario cost estimates. It states what triggers a `wine-result` event and what does not, then delegates rates, discounts and platform-managed events to the Pricing tab, which is the only live source. The previous figures were stale and self-contradictory (they claimed compute was both excluded and included).

#### Changed (memory: repo realigned on the live default)

- `.actor/actor.json` `defaultMemoryMbytes` goes from 1024 back to 512, which is what the platform actually runs today (`defaultRunOptions.memoryMbytes: 512`). The repo value had drifted above the live one, so the next push would have silently doubled every user's default allocation. `minMemoryMbytes` stays 512 and `maxMemoryMbytes` stays 2048.
- Rationale: the Actor is sequential, browser-free and does not autoscale, and Apify allocates CPU in proportion to memory (512 MB is 1/8 of a core, 1024 MB is 1/4). Compute units bill memory multiplied by run time, so doubling the allocation doubles the CU cost of the same wall-clock work without a matching speed-up. This does not revisit the v2.0 decision that took the default from 4096 MB down to 1024 MB when the browser stack was removed, it continues it.

#### Fixed (Store copy drift)

- `.actor/actor.json` no longer promises "grape blends", which stopped being extracted at the v2.2.0 rename. Its `title`, `seoTitle`, `seoDescription` and `description` were also realigned on the values live in the Console, which had been re-optimized there and would have been overwritten by the next push, including the `mr-bridge.com` mention at the end of the description.
- `.actor/pay_per_event.json` now describes what the event actually charges for (name, region, country, popularity, critics score, average price) instead of "name, grape, ranking, score, price".

***

### v2.3.6 (2026-06-02)

#### Changed (simplify + robustness)

- Adopted shared `wine-core@0.5.0` `summarizeQuality` (replaces inline null-rate wiring + cast). Hardened the Layout-B region name-suffix fallback (v2.3.5): the region/country candidate is now validated as a place (rejects segments with digits/ampersand like "Bin 95"), preventing mis-tags on non-standard names. The name segment's own digits no longer shift the positional parse.

***

### v2.3.5 (2026-06-02)

#### Fixed (SEV-3 coverage - region/country empty in WS compact layout)

- Wine-Searcher serves a compact row layout (observed on `?tab_F=mostpopular`) where wine rows carry NO `/regions-` link - region and country live only in the product-name suffix ("Producer Wine, Region\[, Country]"). The link-based selector matched 0 rows on this layout, producing 100% empty `region`/`country` (surfaced by the v2.3.4 drift warning). Added a name-suffix fallback (3+ segments → region=penultimate, country=last lowercased slug; 2 segments → region=last): when no region link is found, region/country are recovered from the name. Link-based extraction stays primary (the older layout still works). Live-verified on grape-76 mostpopular: region 0/10 → 10/10. `region_url` stays null on the fallback path (no link to derive). Root-caused via systematic debugging against the actual ZenRows HTML.

***

### v2.3.4 (2026-06-02)

#### Added (SEV-3 observability - selector-drift early warning)

- The OUTPUT KV record now carries `fieldNullRates` (per-field null-rate % over a bounded sample) and `driftWarnings`. When a drift-prone field (`region`, `critics_score`, `avg_price`) exceeds 80% null on ≥10 rows, the run logs a loud "possible selector drift" warning. This directly guards against a repeat of the v2.2.0 silent failure (the grape→region column rename that produced 100% null for an unknown period). Uses shared `wine-core@0.4.0` `computeNullRates`/`detectDrift`. Audit 2026-06-01 finding X-2.

***

### v2.3.3 (2026-06-02)

#### Fixed (SEV-4 billing - infra failures were charged)

- ZenRows fetch-failure error rows are no longer charged `wine-result` ($0.003). An infrastructure/transient fetch failure delivers zero value; per Apify PPE doctrine only a confirmed extracted wine is billable. The error row is still pushed to the dataset for visibility (SUCCEEDED + structured error). Audit 2026-06-01 finding X-1.

***

### v2.3.2 (2026-06-01)

#### Fixed (SEV-4 silent score corruption)

- Critics score parsing now handles 3-digit ("100 / 100") and single-digit ("9 / 100") scores. The old `\d{2}`-only regex captured "00" from a perfect "100 / 100" (reading as the worst possible value, precisely on the highest-rated wines) and dropped single-digit scores entirely. Extracted to a tested pure `parseCriticsScore()` with 1-100 range validation (`parser.ts`). Audit 2026-06-01 finding.

***

### v2.3.0 (2026-06-01)

#### Added

- `country` field on each wine record (ISO code extracted from the flag icon CSS class).
- `label_image_url` field (CDN-resolved bottle label image URL).
- New `maxWines` input (default 1000) - soft cap on total dataset rows across all grapes. Use 0 to disable. Protects against billing surprises in `scrapeAllGrapes` mode.

#### Fixed (SEV-3 resilience)

- ZenRows transient errors (408/429/500/502/503/504) now trigger 1 retry with 5s delay. Previously a single 502 burst lost ~21 grapes' pages 2..N on a 1054-grape run.
- Consecutive-failure circuit breaker: 5 consecutive ZenRows null returns abort the run gracefully with a `__circuit_break__` summary row. Likely-quota-exhaustion case.

### v2.2.1 (2026-06-01)

#### Fixed (SEV-4 contract drift)

- `tabFilter` input now consistently uses the Wine-Searcher canonical values: `mostpopular | best | bestvalue | mostexpensive | cheapest`. Previously the input_schema declared this enum, but the TypeScript type accepted any string (`| string` escape) and the runtime default was `'all'` (not in the enum). Result: user-supplied valid schema values worked by luck; the default sent `?tab_F=all` regardless of schema. Now all three sources of truth (schema, type, runtime default) agree on `mostpopular` as the default.
- Defensive runtime guard: unknown tabFilter values log a warning and fall back to `mostpopular` instead of being passed through to Wine-Searcher.

### v2.2.0 (2026-06-01)

#### Changed (breaking - field rename)

BREAKING CHANGE

- The `grape` field on each wine record is renamed to `region`, and `grape_url` is renamed to `region_url`. Wine-Searcher's listing pages now render Region links (`/regions-XXX`) in the column that previously held Grape links (`/grape-XXX`). The v2.0/v2.1 parser silently produced empty `grape` strings on 100% of rows because the underlying selector pattern stopped matching. The rename aligns the field name with the actual data shape and the page header label.
- `source_grape` and `source_grape_id` (the input-URL-level grape identifiers) are unchanged.
- Output schema (`dataset_schema.json`) updated to reflect the new field names: `region` displayed as "Region", `region_url` displayed as "Region Link".
- Parser selector changed from `a[href*="/grape-"]` to `a[href^="/regions-"]`.
- Added two test invariants in `tests/parser.test.ts` that would have caught the original bug: (1) at least 50% of rows must carry a non-empty `region`, and (2) every `region_url` (when present) must match `/regions-` pattern.

#### Migration notes

- Consumers that read the `grape` field were already getting empty strings on v2.0/v2.1. The v2.2 rename surfaces this as a clean schema break: update field references from `record.grape` to `record.region` (and similarly for url).
- If you need the per-grape source identity, use `source_grape` (e.g., "Pinot Noir") - that field reflects the input URL's grape and is populated correctly.

### v2.1.0 (2026-05-31)

#### Changed

- Migrated currency module (`fetchExchangeRates`, `extractPriceAmount`, `convertAmount`) to `wine-core@0.3.0` (consolidation, no behavior change). The local `src/currency.ts` and its `tests/currency.test.ts` were removed; coverage is preserved in `packages/wine-core/tests/currency.test.ts`.
- Bundle size: 146 KB -> 145 KB (slight decrease).

### v2.0.0 (2026-05-29)

#### Changed (major)

- **Full rewrite from Python to TypeScript.** Source converted from Python 3.12 + Crawlee + Playwright + Camoufox (2719 LOC) to TypeScript + fetch + cheerio (~800 LOC). Behavior preserved end-to-end: same output schema, same field extraction, same multi-currency parsing (24+ symbols / ISO codes / 2-letter country markers), same pagination, same PPE event (`wine-result` @ $0.003), same input options.
- Runtime: `apify/actor-node:22` (was `apify/actor-python:3.12`).
- Memory footprint: default 1024 MB (was 4096 MB); no browser to host.
- Package: `wine-core` workspace dependency adopted (`sleep` helper today, ready for future shared logic).
- Build: single-stage Dockerfile with `dist/main.js` produced by `tsup` (ESM bundled, ~146 KB including the baked-in 1054-grape table).
- Tests: pytest -> vitest, 31 unit tests (3 grapes data integrity, 13 currency, 15 parser) locked against the same Pinot Noir HTML fixture.

#### Removed (intentional)

- Camoufox plugin, Crawlee browser pool, session pool, proxy rotation country list.
- Warmup navigation, click-nav, behavioral simulation (scroll / mouse / dwell), random wine browsing, route intercept setup, block detection.
- PerimeterX-direct fallback path: in v1.x the Actor could bypass ZenRows and hit Wine-Searcher directly with the Camoufox stack as a last resort. v2.0 drops this entirely (would require re-implementing equivalent stealth in TS, which doesn't exist as of 2026-05).
- `anti_detection.py` (591 LOC) module deleted.

#### Operational regression (consciously accepted)

- **`ZENROWS_API_KEY` is now mandatory at runtime.** If the env var is missing (Actor settings: secret `zenrowsApiKey`), the run aborts cleanly with an explicit status message and exits without producing data. There is no browser fallback. This trade-off was approved to retire ~1000 LOC of browser anti-detection plumbing that had become a maintenance burden.

#### Migration notes

- The Python source (`requirements.txt`, `src/*.py`, `tests/test_*.py`) was removed from the repo. Git history preserves the legacy implementation.
- Output schema (`dataset_schema.json`, `input_schema.json`, `output_schema.json`) is unchanged: existing consumers see no row-shape difference.
- PPE event (`wine-result` at $0.003) is unchanged.
- `scripts/refresh_grape_list.py` is kept as a one-shot Python utility (excluded from the Docker image) for refreshing the ALL_GRAPES data table from Wine-Searcher's grape index.

### v1.0.1 (2026-05-18)

#### Added

- Sister Actor cross-promotion to `wine-searcher-scraper-from-list` in both the "Which wine scraper should I use?" comparison table and the Related Actors list
- Canonical PPE event table (`Event | Price | When triggered`) above the Plan capacity table, exposing the `wine-result @ $0.003` event

#### Changed

- Dropped legacy "Rankings & Prices" wording from all 4 titles: `actor.json` title, `seoTitle`, `input_schema.json` title, `output_schema.json` title and description
- All titles now use hyphenated "Wine-Searcher" (was inconsistent mix of "Wine Searcher" and "Wine-Searcher")
- README H1, intro paragraph and "What does X do?" H2 reordered to popularity-first: "popularity, scores & prices"
- `seoTitle`: "Rankings, Scores & Prices" replaced with "Popularity, Scores & Prices"
- `seoDescription`: extended from 140 to 153 chars (within 145-160 target), reordered popularity-first, added "Pay-per-event" framing
- `actor.json` description rewritten to drop "popularity rankings" wording in favor of "popularity"
- Cross-promo table cell wording aligned with the new popularity-first positioning
- Removed all em-dashes (zero em-dash policy 2026-05-18)

### v1.0.0 (2026-05)

BREAKING CHANGE

**Major pivot**: this Actor now scrapes **grape variety pages** instead of region pages. Wine-Searcher modified its region page layout, breaking the previous scraper. Grape pages retained the legacy DOM structure, so the Actor was redirected at v1.0.

#### Breaking changes

BREAKING CHANGE

- Input field `region` renamed to `grape` (dropdown of 1054 grape varieties)
- Input field `customUrl` pattern: `/grape-{id}-{slug}` instead of `/regions-...`
- Input field `maxPagesPerRegion` renamed to `maxPagesPerGrape`
- Removed: `scrapeSubRegions`, `maxDepth` (no hierarchy among grapes)
- Added: `scrapeAllGrapes` checkbox to crawl every grape variety in one run
- Output field `region` replaced with `source_grape` (page name, e.g. "Pinot Noir") + `source_grape_id` (Wine-Searcher numeric ID)

#### What stayed the same

- All other output fields (`product_name`, `product_url`, `grape`, `grape_url`, `popularity`, `critics_score`, `avg_price`, `currency`, `avg_price_converted`, `target_currency`, `source_url`)
- The `tabFilter` sort (Most Popular / Best Rated / Best Value / Most Expensive / Cheapest)
- The currency conversion feature (now backed by a 300+ currency live feed, see v1.0.x entries)
- Pay-per-event pricing at $0.003/wine
- The Camoufox + ZenRows anti-detection stack

If you had scheduled runs on the v0.x region scraper, they will now log a deprecation warning and require reconfiguration with the new `grape` or `customUrl` fields.

### Earlier history (v0.1 to v0.3, 2025-12 to 2026-04): region scraper era

Before the v1.0.0 pivot, this Actor scraped Wine-Searcher **region** pages instead of grape pages, under the slug `wine-searcher-region-scraper`. Across the v0.x line it accumulated: pay-per-event pricing, the Camoufox + ZenRows anti-detection stack, the 30-currency conversion via ECB rates (predecessor of today's 300+ live feed), a 146-region dropdown, recursive sub-region crawling, parser hardening for ~30 currency symbols (Rs / Rp / RM / R / kn / лв / Kč), price-column SVG-marker extraction, popularity-rank leak fix, proxy-country URL cleanup, and graceful exits.

When Wine-Searcher changed its region-page layout in 2026-05, the region scraper broke. The grape pages kept the legacy DOM, so the Actor was redirected to grape pages at v1.0.0. All of the v0.x engineering (anti-detection, currency parsing, pay-per-event billing) carried over and remains live in v1.0.x.
