Explore Vivino by region or producer and extract every wine on the page: wine ratings, number of ratings, prices, vintages and full taste profiles. Use it to build a wine list, run market research, track a cellar or compare producers. Export to JSON, CSV or Excel. By MrBridge (mr-bridge.com)
All notable changes to Vivino Wine Scraper are documented here.
v3.0.6 (2026-08-29)
Publication-control release. The scraper runtime, output contract, billing and Store copy are unchanged.
Changed (deployment ownership)
Removed title, description, seoTitle and seoDescription from .actor/actor.json. Those four fields are maintained in the Apify Console, so future source deployments cannot overwrite the approved Store copy or its mr-bridge.com attribution.
Actor version 3.0 now owns the latest build tag. The six temporary candidate-* tags used during the 2.1 and 3.0 acceptance cycles were retired after a successful test build moved latest forward.
Added (regression lock)
Added a schema test that fails if any Store-controlled marketing field is reintroduced into the Actor manifest.
v3.0.5 (2026-08-29)
Failed publication test; never promoted. Build Qh2SHlc3rUPf7QIid stopped during dependency installation, before an Actor image could be created or run.
Fixed in v3.0.6 (release packaging)
The isolated release package had retained the development-only dependency wine-core: workspace:*, although wine-core is already bundled into dist/main.js and no workspace is uploaded. The successful package omits that local workspace reference, matching the package shape already used by production build 3.0.4.
The failed build never received latest, never ran, and changed no Store copy, runtime behavior, output or billing.
v3.0.4 (2026-08-24)
Release-history reconciliation. Apify platform build 3.0.4 (cXErs5fc2v4NL3jS4) is the successful production build that promoted source release v3.0.3; its embedded package.json is version 3.0.3 and its embedded changelog starts at v3.0.3.
Changed (release record only)
Recorded the platform build number explicitly so the public build history and source changelog can be reconciled without treating 3.0.4 as an unreported runtime change.
No scraper behavior, output schema, billing event or Store content changed between source release v3.0.3 and platform build 3.0.4.
v3.0.3 (2026-08-24)
Fixes the gap that rejected candidate 3.0.2 (build WFkBuz872hH42ona9, never promoted), found by run 4fan3WbIiy7e22vi0. Production still runs 2.1.9.
Fixed (the summary contract on a graceful abort)
An aborted run advertised a summary record it did not have.output_schema.json links OUTPUT as a run output, and OUTPUT was written only at the end of runActor, which a mid-collection abort never reaches. The aborted run left a CHECKPOINT, an INPUT and a run-errors record, and answered 404 on the one the Output tab points at. No row and no billed event was ever wrong, which is why this is a broken contract rather than a billing defect.
A graceful abort now publishes a partial summary, and says so: the new summaryComplete field is false on it and true on every summary written at the end of a run. A consumer tests that field rather than inferring completeness from aborted.
The partial summary keeps what is certain and nulls the rest. It never invents a zero. Certain: the collected wine count from the stabilized snapshot, the delivered and billed counts from the charge counter after the flush, the error count, aborted: true and stopReason: "aborting". Null: the price split, the fill rate, the rating average, the ratings total, the null-rate metrics, the drift warnings and the preview. Those are computed from the finished wine list and do not exist yet. Reporting zero priced and zero unpriced over 452 collected wines would type-check and lie.
The abort handler runs a fixed order: flush the row in flight, flush the error telemetry, settle the counters, take ONE snapshot, then CHECKPOINT, then OUTPUT, then the status message. One snapshot rather than two, so the checkpoint and the summary cannot disagree about how far the run got.
The summary writer is centralised and idempotent, and claims the record before awaiting. A repeated abort signal, two concurrent writers, or a runActor that returns late and tries to publish a complete summary after the handler published a partial one, all resolve to exactly one write, and the first one wins. A run that was cut short must not end up advertising a complete summary.
The handler still never pushes a dataset row and never charges. Its only writes are to the key-value store.
Changed (published schema)
key_value_store_schema.json declares summaryComplete and makes every aggregate nullable, so a partial summary is representable without widening a type or inventing a value. All 16 properties stay required: a partial summary sets them to null, it never omits them. Normal outputs keep their current types and report summaryComplete: true.
Fixed (two defects found reviewing this same release)
The abort snapshot could report one wine fewer than the dataset held. The orchestrator updates its collected count only after await deliverWine(...) resolves, several frames later, so an abort landing during that write flushed the row and snapshotted a delivered counter of N against a collected count of N-1. CHECKPOINT.winesCollected and OUTPUT.totalWines would both have contradicted the dataset and the billing. The run now records whether it delivers one row per wine, and in that mode the snapshot derives the wine count from the stabilized delivered counter instead of the racing field. In vintage mode a wine produces many rows, so the separately tracked count stays the only answer. totalRecords is taken from the snapshot too, rather than re-read from the counter afterwards, so the two numbers cannot drift apart between two lines.
A failed or in-flight summary write made the run header lie. The writer tracked "someone is writing" with a boolean, so a concurrent caller got an immediate false and the abort handler then read the result one frame too early: it announced Summary could not be saved about a record that was about to exist, and when the first attempt failed nothing ever retried. The in-flight attempt is now a shared promise. The first caller owns the record, a concurrent caller waits for the durable outcome before reporting anything, the same record is offered up to three times before the run gives up, and the state is released in a finally so a run that failed every attempt can still be offered a fresh record later. Replacing a record that already exists is still refused: a run cut short must not end up advertising a complete summary.
The abort status message names both records, so a customer reading only the run header knows whether the summary they are about to open exists: Checkpoint saved. Summary saved., or could not be saved for either.
Added (regression locks)
33 new it(...) declarations and no removals since 3.0.2, for 382 executed tests against 350, including the reproduction of run 4fan3WbIiy7e22vi0 at 452 wines and zero rows, an abort after several delivered rows where delivered equals billed equals summarised, a row still in flight when the signal lands, a repeated abort, two concurrent writers, and the four normal summary paths cross-validated field by field against the schema read from disk.
49 locks in total, each verified by reintroducing the defect it claims to prevent, among them an orchestration test that fires the abort from inside a real dataset write and asserts that the checkpoint, the summary, the dataset and the billing all report the same count.
v3.0.2 (2026-08-24)
Fixes the defect that rejected candidate 3.0.1 (build ddqJDdZCHfzEfFwPI, number 3.0.2, never promoted), found by run IJKTwoXF4plhga25v. Production still runs 2.1.9.
Fixed (the alias contract)
year contradicted vintageYear on the same row. The dataset schema documents year as the historical alias of vintageYear, kept so existing integrations do not break, and the no-vintage fallback row published vintageYear: 1997 next to year: null on a wine whose own name ended in 1997. Both values were individually legal JSON, so schema validation could never catch it: only their relationship was wrong.
The fallback row now mirrors the wine's own vintage instead of hardcoding a null. In region mode the Explore payload does give the wine a vintage, and discarding it threw away a value the row prints in its fullName. In producer mode the wine has none, so both fields stay null and still agree.
The output boundary settles the two fields structurally on any row carrying both: a non-null vintageYear wins because it is the canonical field, a non-null year is kept only when the canonical one has nothing to say, and the two always leave equal. Rows carrying just one of the two, such as every standard-mode wine row, are untouched and gain no column.
Fixed (copy)
The all-priced status message is conjugated for a single wine: Done: 1 wine with a price. instead of Done: 1 wine, all with a price., which is not a sentence. Two or more wines keep the existing form.
Added (regression locks)
8 new tests, 346 in total. The fallback row is asserted before normalization as well as after, so the choice of year is observable rather than masked by the boundary invariant that would repair it. The three contradictions are pinned directly: null against 1997, 1997 against null, and 1998 against 1997. Real vintage rows are checked to keep the two fields equal, and the singular and plural message forms are pinned exactly.
31 locks in total, each verified by reintroducing the defect it claims to prevent. The fallback fix and the boundary invariant are deliberately redundant, so neither is visible alone at the delivery level, and a combined regression of both is locked by its own orchestration test.
v3.0.1 (2026-08-23)
Hotfix for the two SEV-1 defects that rejected candidate 3.0.0 (build xb11fz7Gteq2r1oxk), both observed on a single capped candidate run. That build was never promoted: production still runs 2.1.9.
Fixed (schema conformance of delivered rows)
A row whose Vivino payload had the wrong JSON type was rejected by the platform and lost..actor/dataset_schema.json is enforced, not documentation: POST /v2/datasets/{id}/items answers 400 schema-validation-error and drops the record. The extractors passed several payload fields straight through, so a wine whose year was Vivino's non-vintage marker "N.V." cost a delivered row.
Every producer of a delivered row now normalizes its output through one explicit, pure boundary: the Explore extractor, the Winery extractor, the vintage walk, the taste profile and the no-vintage fallback row. Years, identifiers, booleans, names, currency codes and the nested taste counters each become the type the schema promises, or null, or the documented default on a non-nullable field. Nothing is coerced into an approximation, and the schema was not widened.
Concretely: "N.V.", an empty string, NaN, an object or an implausible year become a null vintageYear, while a canonical numeric string is converted. Identifiers accept positive safe integers and canonical numeric strings and refuse everything else, past Number.MAX_SAFE_INTEGER included. isNatural accepts only the boolean, 0/1 and "true"/"false" representations, never Boolean(raw), which reads "false" and {} as true. Names keep only real strings, so no cell can read [object Object] and no URL can be built from one. A currency is a three-letter code, upper-cased, or null.
priceStatus is now settled against the price that survived normalization rather than trusted from the producer, which makes the row invariant structural: a row carries a price if and only if it says available. A contradictory or unreadable status fails closed to retrieval_failed and never to no_offer.
The vintage walk now sets wineUrl, imageUrl and isNatural itself. They were grafted on by the orchestrator afterwards, which left the row that fetchWineVintages returns missing a field the schema requires. VintageRow.year is typed number | null, matching what the schema always declared and what the no-vintage fallback row always delivered.
Delivered values that were already valid are unchanged, byte for byte, in the output JSON.
Fixed (run status truthfulness)
A run that failed reported SUCCEEDED with exit code 0, no summary record and no status message.main.ts ended with finally { await Actor.exit() }, and Actor.exit() defaults its exit code to success and calls process.exit inside its own handler, so the process was gone before the rethrown error could reach the platform. A customer saw a green run and a silently truncated dataset.
There is no unconditional exit any more. Success and failure are two mutually exclusive paths: a clean return exits zero with no failure message, and a throw logs the original error in full, flushes the non-billed error telemetry once, and exits non-zero with a bounded, single-line terminal status message that always names a cause. A failure while flushing that telemetry cannot turn the run back into a success.
An unreadable price was published as a confirmed absence. A missing amount and an amount we cannot read are two different facts, and only the first says anything about the market. They are now read as three distinct outcomes, available, absent and invalid, and an amount that is present but unusable (a string, a negative, zero, an object) reports retrieval_failed on every path: the Explore row itself, the producer search and the region backfill. The price lookup records which wines it could not read, so one unreadable wine reports a failure without condemning the rest of the producer's catalogue, and a row this producer owns but that carries no usable wine id makes the whole lookup incomplete, because the wine it priced is then indistinguishable from a wine with no offer.
Counts are refused rather than truncated. A review count of 12.7 is not a count, and publishing 12 would invent a measurement out of a broken payload. Fractional, non-finite and unsafe values become null, or the documented zero on the non-nullable nested taste counters.
The vintage duplicate-statistics guard compares raw ratings again. Narrowing them to two decimals before the comparison made two genuinely different vintages look like Vivino leaking one wine-level aggregate, and legitimate years were skipped. Only the value written to the row is rounded. The comment explaining why the count still needs narrowing was wrong about JavaScript and has been corrected: > coerces a string operand to a number, so the risk is the strict equality in the guard, not the comparison.
A row the price lookup cannot be asked about was published as a confirmed absence. A Winery-API wine with no usable id is in neither the price map nor the unreadable set, so it fell through to the completeness default and a complete lookup labelled it no_offer. A complete lookup proves absence only for a wine we can name. Such a row now reports retrieval_failed on both the producer search and the region backfill, and the run summary counts it as a failure rather than as a market with nothing in it. A price the row already carried is still kept: an unidentifiable row is not evidence against an answer Explore already gave.
The vintage year ceiling follows the calendar again, at the reference year plus one. A fixed far-future constant had been introduced to make the normalizers pure and, in 2026, it accepted seventy years of impossible vintages. The reference year is a parameter with a clock-reading default instead, so the bound is both correct and directly testable.
Added (regression locks)
109 new tests, 338 in total. Delivered rows are validated against .actor/dataset_schema.json read from disk, never against a copy, by a small explicit validator that owns no dependency and checks the JSON wire form rather than the in-memory object.
The exit codes are demonstrated end to end: a test bundles the current sources with tsup --out-dir into a temporary directory, runs that bundle against a stub apify whose Actor.exit() calls the real process.exit, and asserts 0 on a clean run and non-zero on a fatal one. It neither reads nor writes dist/, so running the suite cannot mutate a tracked build artifact. What it proves is that the current sources exit correctly, not anything about the contents of dist/, which the release chain checksums separately.
The tests are type-checked too, through tsconfig.tests.json, and the check is proved by the compiler's file list rather than by its exit code. A first attempt was a false green: adding tests/**/* to include while inheriting the parent's exclude, which lists tests, built the sources twice and no test, returning 0 either way. Restating exclude surfaced ten real type errors, all fixed without a single @ts-ignore, new any, extra exclusion or weakening of strict. Two fixtures had drifted from the type they stood for and passed only because the path they exercised returned before reading the missing field. A fixture that no longer matches its type is a lock that has quietly stopped locking.
The exit-code test bundles through npm run build, the release script, with npm forced offline. tsup is not a dependency of this package, so npx could have resolved a different version from the network, and a test that bundles with someone else's toolchain proves nothing about what we ship.
Each lock was verified by reintroducing the defect it claims to prevent and checking that it fails.
v3.0.0 (2026-08-23)
Actor version moves to 3.0. The output contract changes in ways an existing integration can notice, so this ships as a new major rather than overwriting 2.1. Anyone pinning Actor version 2.1 keeps the old behaviour until they choose to move.
BREAKING CHANGE (output semantics)
A missing number is now null, never 0. Vivino publishes 0 when it has too few reviews to average, and vintage mode surfaced that as rating: 0. A customer averaging the rating column got a wrong answer and a customer reading the table concluded the wine was bad. rating, ratingsCount and price are now null when absent. Taste dimensions are deliberately excluded: zero is a legitimate reading there and is preserved. Integrations testing === 0 must switch to a null check.
New mandatory field priceStatus on every row, with three values: available, no_offer, retrieval_failed. It answers the question the product could not answer before, namely whether a missing price is an absence on the selected market or a lookup we failed to complete. price !== null always implies available.
Added (price transparency)
The run summary now carries winesWithPrice, winesWithoutOffer, priceRetrievalFailures and priceFillRate. The three counts always sum to totalWines, and they are computed on unique wines rather than on delivered rows, so vintage mode reports the market and not the size of the vintage walk.
The run status message reports coverage in plain language and names the market. Below 50 percent it explains why, and exactly 50 percent is not treated as low. An early stop, a spending cap, an abort or the time limit outranks the coverage sentence.
The price lookup now reports its own completeness. A failed page, an ignored winery filter or a truncated walk marks the wines it could not price as retrieval_failed instead of silently calling them absences. Prices actually found are kept. The region backfill performs the lookup too, where it previously shipped every row unpriced.
Incomplete lookups are recorded in the non-billed key-value telemetry, with a reason and an id, never a raw payload.
Changed (usability)
maxWines defaults to 100 instead of 10,000. The old default could not finish inside the default one-hour timeout on a slow region, which is what the timed-out runs were. The 10,000 ceiling and the prefill are unchanged.
The region menu no longer contains the 31 artificial sep-* separator entries, which were selectable and produced an empty successful run. It now offers one empty option and the 299 real regions, each title prefixed with its country so the visual grouping survives. A separator submitted by an old API caller or a saved Task is still refused, now visibly: explicit status message, stopReason: "invalid_input", a summary record with zero counters, no dataset row and nothing billed.
Output is English. The Explore API was being asked for French, so countryName came back as a French spelling and taste keywords were French inside otherwise English rows. Every Vivino request now sends Accept-Language: en-US,en;q=0.9, and country names are resolved from their ISO code. Wine, producer and region names are proper nouns and are never translated.
A soft run deadline stops the run early enough to still write its summary and status message, reporting stopReason: "time_limit_reached". It reads ACTOR_TIMEOUT_AT, an environment variable the platform injects, so nothing is hardcoded. When no deadline is known, the mechanism disables itself rather than inventing a budget. Note that this covers the platform timeout, not a container kill.
Fixed (CTO review of this release)
The low-coverage sentence no longer blames merchants for our own failed lookups. As soon as one retrieval_failed exists, the message itemises the three counts instead. The "because Vivino lists a price only where a merchant is offering that wine" explanation is now reserved for runs where every unpriced wine is a confirmed absence. "Only 1 carries a price" is also conjugated correctly.
The winery_ids[] filter is verified on every page of the price lookup, not just the first, and only rows belonging to the requested producer feed the price map. A mixed page, a page of other producers after page 1, or a row with no identifiable winery now makes the lookup incomplete instead of turning an indeterminate absence into a confident no_offer.
The HTTP client obeys the run deadline: each attempt is bounded so it cannot outlive the reporting reserve, a request is refused outright when nothing but the reserve is left, and a retry is skipped when its backoff plus another attempt would not fit. The vintage walk checks the deadline between years rather than only between wines.
Vintage rows are delivered and billed one year at a time, as each is produced. They used to accumulate in memory until the wine was finished, so an abort or a deadline in the middle of a long wine discarded years that were already complete.
The price range now reaches rows priced after extraction. FilterConfig carries priceRangeMin and priceRangeMax, and Winery-API rows are re-checked once their price is known. A wine out of range is skipped without ending collection, which is why the collector callback now returns accept, skip or stop rather than a boolean.
priceStatus is required and carries an enum in the dataset schema, and tasteProfile documents its item structure rather than just the outer array. The price invariant is locked by tests over the real code paths: a price above zero always means available, and any other status always means a null price.
The run summary is reachable from the Output tab: output_schema.json links the default key-value store record OUTPUT, and the non-billed run-errors record with it. The key-value store schema documents both.
Runtime region validation covers any unresolvable region, not only the former sep-* values.
Fixed (second CTO review)
A vintage delivery failure is no longer swallowed.onVintage ran inside the per-year try, so a DatasetDeliveryError or a PpeConfigurationError was logged as "Year check failed" and the walk carried on. Delivery errors now leave fetchWineVintages unchanged, no further year is attempted, and a row whose delivery failed is not counted as delivered. Found by a dynamic test on the real module, which the orchestration tests could not see because they stub the whole vintage module.
Vintage cooldowns obey the same clock as the HTTP client. The 1 to 2, 3 to 5, 10 to 15 and 30 second pauses are skipped when they would reach into the finalization reserve, a DeadlineExhaustedError never triggers a pause on top, and the walk tells the orchestrator so the last wine still records time_limit_reached.
imageUrl is carried onto vintage rows. It is a visible table column, so leaving it out made that column empty by construction in vintage mode, which is the very thing FR-7 forbids.
Schema parity: the input schema no longer claims 32 countries, the summary schema marks all 15 always-written properties as required, taste fields and nested tasteProfile keywords carry examples and descriptions, and the key-value store no longer advertises a regionName input the runtime never reads.
Fixed (third CTO review)
An undefined aggregate is now null, not zero, which is the same rule already applied to rating, ratingsCount and price, finally applied to the summary. averageRating is null when no wine carried a usable rating: a Vivino average lives on a 1 to 5 scale, so zero was a false reading that dragged any downstream mean toward the floor. priceFillRate is null when the run found no wine at all, because a proportion of nothing does not exist. Both are declared nullable in the key-value store schema.
The distinction that matters: a fill rate of exactly zero is still reported when wines were found and none carried a price. That is a real answer about the market. Only an empty denominator becomes null. The end-of-run log prints N/A instead of 0.00 or 0.0% in the undefined cases, and the invalid_input path writes null for both.
A new test cross-checks every value the run writes to OUTPUT against the type its schema declares. The Apify CLI validates the schema file on its own and never sees what a run produces, so a schema that forgot to allow null would have passed validation and failed in production.
Changed (packaging and documentation)
.actor/dataset_schema.json is now a documented schema: every delivered field has a type, a nullability, a title, a description and an example, including the three priceStatus values. The results table shows vintageYear, which is filled in both modes, and no longer shows year, which was empty by construction in standard mode. year is kept on vintage rows and documented as a historical alias so integrations do not break.
The Actor runs no HTTP server, so the webServerSchema declaration and .actor/web_server_openapi.json are removed. The published package drops from 13 files to 12.
buildTag and the stale ECOMMERCE plus MARKETING category pair are removed from .actor/actor.json. The release chain always passes the build tag explicitly, and the Console is the authority on categories.
README: year and vintageYear described as they actually behave, taste fields qualified with when Vivino provides them, unproven throughput figures and the "every region" promise removed pending cloud measurement, the support link given its affiliate parameter, 32 countries corrected to 31, the invented "1 wine/minute" vintage rate replaced by an honest description of what drives the cost, priceStatus documented, the new default documented, the non-existent Standby endpoint removed, a real Issues link added.
v2.1.8 (2026-08-16)
Fixed (PPE spending limits and delivery)
The spending guard now reads the caller-specific apify-default-dataset-item price from Actor.getChargingManager().getPricingInfo(). FREE, BRONZE, SILVER and GOLD runs therefore stop against the price they are actually billed. The former $0.003 constant remains only as an offline fallback when no platform pricing grid exists.
The SDK's live calculateMaxEventChargeCountWithinLimit() result is checked before every dataset write and already accounts for prior charges such as Actor start. An unreadable live counter now fails closed; the $0.003 estimator is restricted to offline runs.
Standard wine rows are pushed one at a time as soon as extraction and enrichment finish. Explore results no longer wait for the end of the run; Winery results are delivered immediately after taste and price enrichment. Vintage output is also pushed one row at a time instead of in batches of 1,000.
Added an idempotent Actor.on('aborting') handler. It waits for the single serialized write already in flight, flushes non-billed errors, persists a compact CHECKPOINT, and never calls Actor.charge() or re-pushes a delivered row.
Dataset delivery failures now propagate as fatal errors instead of being swallowed as ordinary Vivino fetch failures. This prevents the Actor from continuing to scrape after persistence or billing has failed.
.actor/pay_per_event.json no longer carries a misleading flat priceUsd. The published manifest keeps the exact shape every sibling Actor uses (schemaVersion plus events), and the Console-authoritative statement lives in the event description. No tier table is published there: that file ships in sourceFiles[], so a dated grid would become the next thing to drift, which is the failure this release set out to remove.
Fixed (review pass on the above)
Billing failures are no longer absorbed as Vivino failures, on both collection paths. PpeConfigurationError now propagates like DatasetDeliveryError out of the Explore pagination catch and out of the Winery collector catch. The Winery case mattered as much as Explore: its stop checks run inside the same try as the winery fetch, so a broken spending guard was being filed as FETCH_ERROR and collection simply moved on to the next producer.
A run that finds nothing now still writes its OUTPUT record, in both modes. Neither an empty region nor a producer without a matching wine returns early any more.
Every guard read over finished work is gone, not just the one in the summary. deliverWine now reads the guard once per row instead of twice, and when pushData refuses a unit the stop reason is recorded from that refusal rather than by asking the SDK again. The vintage delivery loop and the two post-collection checks in producer mode read the recorded run state instead of re-querying. A failing telemetry read can no longer turn completed, already billed work into a FAILED run.
Removed two dead exports, getResolvedPricePerItemUsd and the unused bulk enrichWithExplorePrices helper.
Added an orchestration harness for runActor() plus billing-propagation tests for the Winery collector. They lock the four review fixes: a billing failure stops the run on the Explore path and on the Winery path, an empty result still reports in both modes, and the summary completes even when the charging manager dies after scraping. Each was checked to fail against the unfixed code.
Added MODE A parity for the post-scraping SDK failure, and two includeAllVintages tests: one wine yielding two vintages produces two unit pushes with totalRecords 2, and a guard refusal mid-vintage stops delivery without a second guard read.
Added a last-row test: the manager dying once the final vintage row is delivered must not fail the run, and the pacing sleep now only runs between wines.
Total: 104 tests passing across 9 files.
Console follow-up
No memory code change was needed: .actor/actor.json already constrains runs to 256-1024 MB and declares a 1024 MB default. The live Console setting still reads 4096 MB, but maxMemoryMbytes already caps what a run actually receives: the 2026-08-16 smoke run was allocated 1024 MB and was charged a single apify-actor-start event. Aligning the Console value is a consistency fix, not a billing saving, and it remains a separate, explicitly authorized Console operation.
v2.1.7 (2026-08-15)
Security (packaging - no behavior, billing or schema change)
The published package no longer contains anything but the runtime artifacts. Earlier versions were pushed without a deploy allowlist, so apify push uploaded the whole Actor folder and the public sourceFiles[] API exposed the TypeScript sources, the test suite, the bundle source map and the build configs. A positive .deploy-allowlist now stages exactly 13 files: dist/main.js, the 8 .actor/* definition files, package.json, package-lock.json, README.md and this changelog. Note that isSourceCodeHidden only hides the Console UI, never the API.
tsup no longer emits dist/main.js.map. A source map reconstructs the full TypeScript from the bundle, so the release build stops producing one rather than relying on the allowlist to withhold it.
Fixed (documentation drift)
The output reference dropped alcohol from the JSON example and from the field group list, and the field count went from 27 to 26. The field itself was removed from the output in v2.1.5 and the README had not followed. Example and field list are now both in exact parity with WineRow.
The pricing section was documenting a flat $0.003 per row with fixed cost tables and a free-credit estimate. That has not matched the billing configuration for a while: prices are tiered per Apify plan, and a small apify-actor-start event is also charged at run start. The README now explains the two events and the plan-dependent pricing, and points to the live pricing tab as the single source of truth instead of quoting figures that go stale. This is a documentation alignment with pricing that was already active. No price and no billing logic changed in this release.
v2.1.6 (2026-06-02)
Changed (simplify - DRY consolidation, no behavior change)
Adopted shared wine-core@0.5.0 helpers: summarizeQuality (replaces the inline computeNullRates+detectDrift wiring and its unsafe cast) and createErrorSink (replaces the local error-row accumulator/flush; charge.ts public API unchanged). Also flush accumulated error rows from main.ts's catch so telemetry survives a mid-run throw (L2).
Removed alcohol from the output. Vivino's Explore and Winery list APIs do not return ABV (it lives only on the per-wine detail page, which this actor does not fetch in standard mode), so the field was 100% null in practice and misleading. fizziness is retained - it is legitimately null for still wines but populated for sparkling. Audit 2026-06-01 finding X-3.
v2.1.4 (2026-06-02)
Added (SEV-3 observability - selector-drift early warning)
The OUTPUT KV record now carries fieldNullRates (per-field null-rate % over the result wines) and driftWarnings. When a drift-prone field (wineName, rating, regionName) exceeds 80% null on a run of ≥10 wines, the run logs a loud "possible selector drift" warning. This turns silent Vivino-response-shape changes from a 14-day blind window into a self-flagging run. Uses shared wine-core@0.4.0computeNullRates/detectDrift. Audit 2026-06-01 finding X-2.
v2.1.3 (2026-06-02)
Fixed (SEV-4 billing - error rows were auto-charged)
EXPLORE_ERROR / VINTAGE_ERROR / FETCH_ERROR rows are no longer written to the default dataset (where the synthetic apify-default-dataset-item event auto-billed them at $0.003 each). They are now accumulated and flushed to the KV store key run-errors, surfaced as errorCount in the OUTPUT summary. Infrastructure failures deliver zero value and must not be billed (Apify PPE doctrine). pushErrorItem → recordError + flushErrors. The no-vintage placeholder (a complete wine record minus vintage breakdown) remains a billed result - it carries real value. Audit 2026-06-01 finding X-1.
v2.1.2 (2026-06-01)
(Apify auto-bumped from 2.1.1 -> 2.1.2 at push time; a prior 2.1.1 build slot already existed on the platform.)
extractWineryWineData (src/winery.ts) now falls back from statistics.ratings_average -> statistics.wine_ratings_average (and the matching *_count siblings), matching the explore-path extractor in src/explore.ts (lines 130-131). Long-standing parity bug inherited from the JS monolith: when the Winery API omits the primary fields but populates the wine_ratings_* siblings, the wine's rating was null and silently dropped by the minRating filter even when Vivino did report a rating. RawWineryWine already declares both fields in src/types.ts:181-185.
Step 2 (Winery-API backfill in MODE B) now skips wineries that already saturated their representation during Step 1 (Explore). A new wineryWineCounts: Map<wineryId, number> is incremented in the Explore loop; wineries whose count is >= STEP2_SATURATION_THRESHOLD (5) are filtered out of the map passed to collectWinesFromWineries, with a log.info entry per skip. Previously every winery discovered in Step 1 was re-queried even when Vivino had already returned its top 5+ wines on the Explore page - paying for a second fetch + taste lookup per wine for marginal recall gain on saturated producers.
Tests
+2 unit tests in tests/winery.test.ts (rating fallback chain: wine_ratings_average used when primary missing; primary preferred when both present).
Total: 68 tests passing (was 66 in v2.1.0).
v2.1.0 (2026-05-31)
Changed
Adopted wine-core@0.3.0's createSpendingLimitChecker(pricePerItem) helper inside src/charge.ts. The stateful pushData counter + isSpendingLimitReached() public API is preserved (no consumer change in run.ts / winery.ts); only the internal env-parsing and limit-comparison logic is delegated to the shared helper (consolidation, no behavior change).
Build: tsup ESM bundling into dist/main.js (~51 KB). Adopts workspace conventions and wine-core for shared helpers (sleep, randomInt, getRandomUserAgent).
Charge: Actor.pushData(item) without explicit eventName (synthetic PPE event apify-default-dataset-item). Spending limit checked via manual estimate (rows times pricePerItem vs ACTOR_MAX_TOTAL_CHARGE_USD).
The legacy JS sources (src/main.js, src/regions.js) were removed. Git history preserves them.
Output schema (dataset_schema.json, input_schema.json) is unchanged: existing consumers see no row-shape difference.
PPE event (apify-default-dataset-item at $0.003) is unchanged.
v1.0.104 (2026-05-18)
Added
## FAQ section with 6 Q&A (cost per wine, legal, MCP integration, default maxWines rationale, wines without prices, rate-limit handling)
Changed
## Performance & costs H2 renamed to canonical ## Pricing
Pricing math reconciled with pay_per_event.json: cost table now reflects $0.003/wine event price (previous "$0.05 per 100 wines" implied $0.0005/wine, inconsistent with the actual PPE)
Vintage-mode cost added explicitly: ~10-20 rows/wine, $3 to $6 per 100 wines
Cross-promo table: "Wine Searcher" → "Wine-Searcher" (hyphenated), reordered to popularity-first phrasing, fixed "8 major critics" → "18"
Removed 2× etc. (sub-regions row, grape parameter row)
Removed all em-dashes (zero em-dash policy 2026-05-18)
v1.0.103 (2026-04-11)
Screenshot updated: the README header image now shows the v1.0.100+ form layout (4 primary filters at the root + 6 collapsed sections: Rating Range, Price Range, Regional Settings, Output Settings, Vintage Analysis, Advanced)
The PNG was uploaded to the existing Apify KV store (vivino-scraper-images) by replacing vivino-apify.png. The README URL is unchanged.
Local backup committed to screenshots/vivino-powerful-scraper.png for versioning
v1.0.102 (2026-04-11)
New "At a Glance" section at the top of the README: a visual ASCII mockup of the actual input form, mirroring the Apify console UI exactly (4 primary filters at the root + 6 grouped sections)
The mockup shows real prefill values (Bourgogne, White, 3.5, 100, 2005) and dropdown counts (299 regions, 21 grapes, 15 markets, 8 currencies)
Validates schemas: input_schema.json confirmed at 16 fields / 7 groups, parity 1:1 with the README; all .actor/*.json (input, output, dataset, pay-per-event) aligned with build 1.0.101+
v1.0.101 (2026-04-11)
README Input Parameters section rewritten to mirror the Apify console Input tab: four primary filters at the top, followed by six dedicated sections (Rating Range, Price Range, Regional Settings, Output Settings, Vintage Analysis, Advanced)
Every section caption, field order, and default value now matches input_schema.json exactly. One-to-one visual parity with the form shown in the Apify UI.
Documentation-only change: no behavior change, no schema change, no code change
v1.0.100 (2026-04-11)
Input form reorganized: the four primary filters (Region, Wine Type, Grape, Producer) are now at the root of the form for immediate visibility. The "Wine Filters" section has been removed.
New "Rating Range" section groups Minimum and Maximum Rating, mirroring the adjacent "Price Range" section for a consistent range-filter UX
No behavior changes: identical field names, defaults, and prefills. The automated daily test is unaffected.
v1.0.99 (2026-04-10)
Code cleanup: removed unreachable "Unknown region" warning (Apify input schema enum blocks invalid region slugs at HTTP 400 before reaching code)
Simplified regionIds/wineTypeIds/grapeIds initialization into single-line ternary expressions
Stress test: 5/5 runs on v1.0.98 SUCCEEDED. 100% success rate across volume, producer, vintages, obscure region, and separator validation scenarios.
v1.0.95 (2026-04-07)
Fixed price retrieval in Producer Search: wines searched by producer name now include marketplace prices (was always null)
New enrichWithExplorePrices() queries the Explore API with winery_ids[] filter after collecting wines from the Winery API
Tested on Domaine Coche-Dury: 14/17 wines enriched with prices (3 without = no marketplace listing on Vivino)
Discovered Explore API supports winery_ids[] parameter (array format, like region_ids[] and wine_type_ids[])
v1.0.92 (2026-04-06)
Major reliability update: 7 bug fixes. Proper Actor.exit(), content-type validation (CAPTCHA/geo-block detection), 30s fetch timeout, batch pushData (OOM-safe on 200k+ records), safe pushErrorItem, parseFloat edge case fix, countryCodes array coercion, fetchWithRetry unification, User-Agent bumped to Chrome 134.
Full taste profile with flavor keywords: primaryKeywords and secondaryKeywords with mention counts per group. BREAKING CHANGE: flavors field replaced by tasteProfile. Winery wines enriched via /api/wines/{id}/tastes.
Earlier history (v1.0.0 to v1.0.80, 2025-12 to 2026-03-25)
Initial release with region/grape/wineType/producer filters, support for wines without prices via winery API, 299 regions across 32 countries, full taste profile with flavor keywords, faster HTML-based winery search, multiple reliability fixes (Actor.exit, batch pushData, fetch timeout, OOM prevention), and dataset table view rendering.