Scrape Wine-Searcher's 1,054 grape variety pages, from Pinot Noir to obscure indigenous grapes. Get popularity, critic scores and prices with auto-detected currency, plus product names, regions and countries. Export, schedule, or pipe into Zapier, Make, and n8n. By MrBridge (mr-bridge.com)
All notable changes to Wine-Searcher Grape Scraper are documented here.
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.jsondefaultMemoryMbytes 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.0summarizeQuality (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.0computeNullRates/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.
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"
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.