# Changelog of Vivino region scraper: wine ratings and prices (`mrbridge/vivino-powerful-scraper`) Actor

- **URL**: https://apify.com/mrbridge/vivino-powerful-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/mrbridge/vivino-powerful-scraper.md

## Changelog

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.

#### Tests

- Added runtime-price, BRONZE threshold, exact remaining-capacity, per-unit delivery, concurrent-producer serialization, graceful-abort ordering, checkpoint and anti-double-charge tests.
- 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).

***

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

#### Changed (SEV-2 schema honesty - removed always-null field)

- 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.0` `computeNullRates`/`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.)

#### Fixed (SEV-3 silent-drop parity bug + Step-2 backfill waste)

- `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).
- Bundle size: ~51 KB -> 46.83 KB (slight decrease).

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

#### Changed (major)

- **Full rewrite from JavaScript monolith to TypeScript.** Source split from 1575-line single-file `src/main.js` + `src/regions.js` into 13 single-responsibility modules under `src/` (regions, constants, types, filters, taste, charge, api-client, explore, winery, winery-search, vintages, run, main). Behavior preserved end-to-end; consumers see no row-shape change.
- Runtime: `apify/actor-node:22` (was `apify/actor-node:20`).
- 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`).
- Tests: 66 vitest unit tests covering filters (17), taste extractors (16), region tables (13), explore extractor + buildApiUrl (13), winery extractor (7).

#### Migration notes

- 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
- Free-tier capacity surfaced explicitly: ~1,600 wines standard, ~150-300 wines vintage mode
- 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.

### v1.0.90 (2026-03-31)

- **Faster winery search**: HTML-based search replaces slow Explore API scan (~2s vs ~40s). Reliably finds small/niche producers (e.g. Caroline Morey, previously unfindable). Adds taste fields (sweetness, tannin, acidity, intensity, fizziness, tasteProfile) to dataset table view.

### v1.0.86 (2026-03-30)

- **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.
