# Changelog of Wine-Searcher scraper from list: wine and spirits (`mrbridge/wine-searcher-scraper-from-list`) Actor

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

## Changelog

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

Format based on [Keep a Changelog](https://keepachangelog.com/). Versions follow the Apify build numbering (`0.1.XX`).

***

### v0.1.173 (2026-08-30)

#### Fixed

- **Billing reconciliation no longer reports a false mismatch on the default dataset.** Apify SDK 3.7.2 combines the custom `wine-extracted` result with its synthetic default-dataset event in the count returned to the Actor, even though the platform records only the custom event. The Actor now counts the one row confirmed by each one-row delivery, so its final reconciliation log matches the platform event total.

***

### v0.1.172 (2026-08-30)

#### Fixed

- **Vintage-dated wines no longer look non-vintage when structured data is incomplete.** If the structured vintage is absent, a leading four-digit year in `wineName` is now used. `2017 F E Trimbach Riesling Clos Sainte Hune` therefore returns `vintage: 2017`, while a genuinely non-vintage name such as Krug Grande Cuvee remains empty. The fallback also repairs rows retained from earlier extractions when they are delivered again.
- **The published dataset schema now declares `error`.** Failure rows and the Output tab agree on the plain-language explanation field instead of returning an undocumented key.

#### Changed

- **BREAKING CHANGE for positional CSV consumers: the Overview columns were reordered.** No JSON field was removed, renamed or retyped, and CSV readers that select columns by header remain compatible. Readers that use numeric positions must update: `winePopularity` and `winePopularityRank` are now columns 5 and 6, `cheapestPriceAmount` starts the price block at column 13, the first 23 columns are the stable core, and the variable `criticScores/*` columns now come last.
- **Popularity has a self-contained column title.** `winePopularity` keeps its bare ordinal values such as `"1,107th"`; only its displayed title changes to `Popularity rank on Wine-Searcher`.
- **Resolution and price descriptions now state their edge cases.** The output `inputType` describes the route used by the Actor, including a wine-name lookup upgraded to LWIN. `cheapestPriceConverted` and `cheapestPriceEur` cross-reference each other and state that they are identical when the target currency is EUR.

#### Documentation

- Documented the stable 23-column Overview core and its variable publication-specific critic tail.
- Documented the measured **2 h 09** runtime for a 500-wine production batch and the default 30-day row-reuse window. The run log now reports how many rows were served from an earlier extraction and how many inputs required source fetching.

***

### v0.1.171 (2026-08-25)

Four output additions, all aimed at the file you actually open in a spreadsheet. No output field was removed, renamed or retyped: `winePopularity`, `criticReviews`, `cheapestPriceEur`, `cheapestPriceAmount`, `cheapestPriceCurrency`, `error` and `notFound` keep their exact name, type and meaning. This release also reduces the maximum input from 1,000 to 500 wines per run, as called out below.

#### Added

- **Every row now states its outcome.** A new `status` field on every row, with a fixed vocabulary: `ok`, `not_found`, `blocked`, `timeout`, `parse_empty`, `invalid_input`, `failed`. `ok` is the only value that is billed, and a row is submitted for billing as it is written, so on a run that finishes normally counting the `ok` rows gives the number of charged events. You reconcile an invoice against the data instead of inferring the rule. `error` keeps its plain-language explanation beside it. Each value states only what can be established: a service error reads `failed` rather than `blocked`, because we will not tell you the site refused a request when it did not.
- **Sortable popularity.** A new `winePopularityRank` integer beside the published `winePopularity` string: `"1,826th"` becomes `1826`. A smaller number is a better rank, and an unknown rank is empty rather than zero so it never sorts to the top. Sorting the published string put the 1,000th most popular wine above the 94th.
- **Choose the currency prices are converted to.** A new Target Currency input with 30 ISO currencies, US Dollar by default, and a new `cheapestPriceConverted` field on every wine. The rate is fetched once at the start of the run and applied to the whole batch, so the rows stay comparable with each other. `targetCurrency` is stated on every row, including rows where the conversion could not be done, and nothing is ever converted at a guessed rate.
- **Critic scores usable in a spreadsheet.** A new `criticScores` object keyed by publication name, so a CSV carries `criticScores/Wine Spectator` and similar stable columns you can sort, filter and average. The `criticReviews` array is unchanged for anyone already parsing it, but in CSV its column numbers are positions rather than critics, which is the problem this solves. A wine with no reviews gets an empty object, so the columns still line up.

#### Changed

BREAKING CHANGE

- **BREAKING CHANGE: the maximum batch size is now 500 wines per run, down from 1,000.** A production measurement with the default 4 h timeout delivered 937 of 1,000 inputs before processing stopped under the 4 h run limit, so accepting 1,000 would promise a complete run the Actor could not reliably finish. The new limit was then measured in turn: a 500-wine production batch completed in **2 h 09**, all 500 inputs delivered, with more than an hour and a half to spare under the 4 h timeout. The Console and direct API validation now enforce the same 500-wine limit for names, URLs and LWINs. Split larger catalogs across multiple runs.
- **The Overview view is now the recommended export for spreadsheets**, and the run's Output tab links to it directly, including a one-click CSV. It carries the four new fields and leaves out the positional critic array that makes the all-fields export change shape between runs.
- **Documentation corrected.** The payment table previously promised a `notFound: true` flag on a wine that could not be found. Not-found rows now carry `status: "not_found"`, and the whole table is expressed in `status` values. The integration example no longer asks you to detect success by the absence of a field.

#### Fixed

- **A run that reaches its maximum total charge no longer delivers a wine it did not bill you for.** A row is now submitted for billing as it is written, so the platform decides whether it can charge before the row lands. Previously the two were separate steps, and the wine that crossed the limit could reach the dataset without a matching charged event. Remaining wines are now left undelivered, and the run states it in the log.
- **A wine can no longer end up with two contradictory rows.** If a dataset write failed, the run used to write a second row for the same wine reporting the failure, even though the first row may already have been stored. A write that cannot be confirmed now stops the run, with the log naming every wine affected, instead of adding a row that might contradict one already delivered. Every row that is accepted is written once and only once. To continue after such a failure, use **Resurrect** on the failed run: it restarts on the same dataset and skips the wines already delivered. A new run starts on a new, empty dataset and processes the whole list again.
- **An invalid input list no longer ends as a successful run with nothing in it.** When the whole input is rejected, the run explains why in a `status: "invalid_input"` row. If that row itself could not be written, the run used to finish successfully with an empty dataset and no explanation anywhere. It now fails, with the reason in the log.

***

### v0.1.170 (2026-07-21)

#### Fixed

- **Stricter match threshold on results-page selection.** A result now needs at least two words in common with the requested wine name to be followed (when the request offers at least two) - a single shared word such as a famous family name is not an identification. Prevents rare wrong-wine resolutions introduced by the v0.1.169 selection logic.

***

### v0.1.169 (2026-07-21)

#### Fixed

- **More wines are found on ambiguous searches.** When a lookup lands on a results page instead of a wine page, the scraper now picks the result that best matches the requested wine name, rather than blindly following the first link (which could be an unrelated or promoted wine). When the retry with a simplified name also lands on a results page, the best-matching result there is now tried too, instead of giving up. This recovers a meaningful share of the ~9-10% of lookups that previously returned "no direct wine page found", and skips requests that would have fetched an obviously wrong wine.

***

### v0.1.168 (2026-07-21)

#### Changed

- **Right-sized the default memory allocation (4096 MB -> 1024 MB).** Production measurements show the scraper peaks around 210 MB even on all-fresh 1000-wine batches, so the default now matches the actual footprint with ~5x headroom. Validated on a live batch before release: identical reliability and extraction quality. Users who prefer extra headroom can still raise memory up to 8192 MB in the run options; the built-in memory check was recalibrated to the same measurements.

***

### v0.1.167 (2026-06-25)

#### Changed

- **Documentation polish.** Clarified that concurrency tuning is an advanced API-level override (the default of 6 wines in parallel suits all batch sizes), harmonized the pricing wording to "pay-per-event", and tidied the wording of earlier changelog entries. No functional change to the scraper.

***

### v0.1.166 (2026-06-25)

#### Fixed

- **Grape variety, vintage and label image are now returned consistently.** A subset of wines could come back with these three fields empty even though the information is available on Wine-Searcher. They are now populated for those wines too, so every resolved wine carries the same complete field set.

***

### v0.1.165 (2026-06-25)

#### Fixed

- **Unrelated-wine matches are now caught and reported instead of returned.** When Wine-Searcher could not find the exact wine requested, a lookup could occasionally resolve to a completely different wine (another producer, another region) and return it as if correct - with a misleading price. When the resolved wine shares no distinctive word with your query, the row now returns a clear "resolved wine does not match the query" error instead of incorrect data.
- **The cheapest price now always reflects a standard 750ml bottle.** Magnum, jeroboam, half-bottle and other non-standard formats are skipped when picking the lowest offer, so the reported price is no longer inflated by a large-format listing (or under-stated by a half-bottle).
- **More vintages are populated.** When the structured vintage field is missing, the vintage is now recovered from the leading year of the wine name, filling in vintages that were previously left empty on clearly vintage-dated wines.

#### Changed

- **The wine label is now shown as an image thumbnail** in the dataset Overview, instead of a plain link.

***

### v0.1.164 (2026-06-24)

#### Changed

- **The default dataset view now shows more columns** - the label image link, the winery page link, and the critic-review count are added to the Overview table, alongside the existing fields. All of these were already present in the data; this only enriches the default table display.
- **Documentation accuracy** - refreshed the README's throughput/concurrency notes and clarified which fields can be empty for niche or very new wines.

***

### v0.1.163 (2026-06-24)

#### Fixed (winery details & popularity coverage)

- **More wines now return their winery link and popularity ranking.** On wine pages that list the producer in a compact "Winery" info card (rather than a full producer carousel), the link to the producer was being missed, leaving `wineryUrl` and `winePopularity` empty even though the producer page exists on Wine-Searcher. The parser now reads that card too, recovering winery details and popularity for many smaller producers (e.g. Château Malmaison, Julien Meyer, Gabriel & Paul Jouard).

***

### v0.1.162 (2026-06-22)

#### Improved (popularity coverage under load)

- **Popularity enrichment now recovers automatically within a run after a temporary block.** Previously, a short burst of blocked winery lookups would pause popularity enrichment for the entire remainder of the run; now enrichment briefly backs off, then automatically resumes once conditions clear. This noticeably improves `winePopularity` coverage on large runs and when several runs execute at the same time. Core wine data (prices, scores, grape, vintage, winery) was never affected.

***

### v0.1.161 (2026-06-19)

#### Fixed (price precision)

- **Per-bottle prices derived from case listings are now rounded to 2 decimals.** Prices from cases (e.g. a case of 3 or 6 divided down to a single bottle) could be emitted with long floating-point tails (e.g. `1116.6666667`); they are now consistently rounded (e.g. `1116.67`), matching the rest of the price output.

***

### v0.1.160 (2026-06-19)

#### Fixed (pricing quality)

- **Prices are now sourced from the UK fine-wine market by default**, which has the deepest merchant coverage (including in-bond offers) and gives the most representative "cheapest worldwide" figure. This restores reliable pricing and coverage; raw prices come back in GBP and every result still carries `cheapestPriceEur` normalized to EUR for cross-wine comparison. (Power users can still target a specific market by passing `proxyCountry` in the API input.)

***

### v0.1.159 (2026-06-17)

#### Changed

- **Simplified input - removed the "Advanced Settings" section.** The proxy is no longer locked to a single country, which broadens proxy coverage and improves reliability against blocks. Prices now reflect global merchant availability and are always provided normalized to EUR via `cheapestPriceEur` for cross-wine comparison, alongside the original local-currency `cheapestPriceAmount` / `cheapestPriceCurrency`. The guidance-only run-timeout field was also removed - the run timeout is set through the standard Apify run options.

***

### v0.1.158 (2026-06-17)

#### Fixed (data quality)

- **Popularity ranking now resolves for vintage-prefixed wine names.** Wines named in the common "Vintage Producer" style (e.g. "2017 Château Margaux") were failing to retrieve their `winePopularity` because the vintage year was treated as part of the producer name during the winery-page lookup, even though popularity is ranked per wine across all vintages. The year is now ignored when matching, recovering the ranking for short producer names. The most impactful data-quality fix of this release.

#### Changed (performance)

- **Faster by default.** The default level of parallelism has been increased, so multi-wine runs now complete in roughly half the wall-clock time, at no cost to reliability. Existing per-request pacing and safeguards are unchanged.

#### Documentation

- Usage guidance: always include the vintage when you care about price - it determines which vintage's price the actor returns.

***

### v0.1.157 (2026-06-15)

#### Fixed (search resolution)

- **More wine-name searches now resolve to a direct wine page.** When a search lands on a Wine-Searcher results page instead of a single wine, the actor now (a) ignores filter/refinement links when looking for the wine and (b) retries once with a noise-reduced query (dropping parenthetical asides, a leading importer/company prefix, and adjacent duplicate words). Recovers wines that previously returned "no direct page found". Only the results-page path is affected - direct hits are unchanged.

***

### v0.1.156 (2026-06-13)

#### Added

- **Four new fields from Wine-Searcher's structured data**, at no extra cost:
  - `grapeVariety` - grape variety or blend (e.g. "Chardonnay", "Bordeaux Blend Red").
  - `vintage` - vintage year as a number (null for non-vintage wines).
  - `labelImageUrl` - absolute URL to the wine label image.
  - `criticReviews` - per-critic reviews as an array of `{ author, score }` (e.g. Robert Parker 95, Jasper Morris 90), so you get individual critic ratings, not just the aggregate score. Nested array - use JSON/JSONL for clean access (CSV/Excel flatten it into columns).

***

### v0.1.155 (2026-06-13)

#### Added

- **`cheapestPriceEur` - prices normalized to EUR.** Every result now also carries the cheapest price converted to EUR at current exchange rates, so prices are directly comparable across wines regardless of the merchant's local currency. The original `cheapestPriceAmount` / `cheapestPriceCurrency` are unchanged; `cheapestPriceEur` is `null` when the amount is missing or a conversion rate is unavailable.

***

### v0.1.154 (2026-06-13)

#### Fixed (data quality)

- **Currency accuracy.** When an offer (or the average-price header) carries no detectable currency symbol, `cheapestPriceCurrency` is now `null` instead of being silently labelled `USD`. Prevents mislabelled currencies in mixed-region results - applies to both the structured offer-card path and the average-price fallback.
- **Per-bottle price precision.** Case-listing prices divided down to a single 75cl bottle are now rounded to 2 decimals - no more long floating-point tails (e.g. `95.83` instead of `95.8341666…`).

#### Fixed (resilience & cost)

- **Smarter handling of unrecoverable scraping errors.** A fatal scraping-service error now stops the run immediately instead of retrying it against every remaining wine; a malformed-request error retries at most once. Eliminates wasted retry storms that previously slowed runs without recovering data.
- **Timeout-aware shutdown.** As a run approaches its time limit it now stops launching new wines early enough for in-flight work to finish, and skips the optional winery-popularity enrichment when there isn't time to complete it (those wines are returned with `winePopularity: null`). Large runs now end as a clean partial result instead of being killed mid-task. The recommended-minimum-timeout guidance in the input schema was updated to match the measured throughput.

#### Changed

- Internal developer notes refreshed and the billing-table wording tightened to outcome-level rows.

***

### v0.1.150 (2026-06-04)

#### Fixed (resilience - winery enrichment circuit breaker)

- When Wine-Searcher anti-bot-blocks `/merchant/` (winery) pages, each optional popularity fetch burned scraper.ts's full block-retry budget (~8 min) and returned null - so a blocked session could hang a run for minutes per wine. Added a run-level circuit breaker (`src/winery.ts`): after 3 consecutive *slow* failures (a null taking >60s, indicating blocking rather than a legit "not found"), winery enrichment is skipped for the rest of the run. Main wine data is unaffected (popularity is optional), so a blocked session now degrades fast instead of hanging. Pure `recordWineryOutcome`/`isWineryCircuitTripped` policy (+4 unit tests; reset on the winery-buffer reset helper).

***

### v0.1.149 (2026-06-03)

#### Added (name→LWIN matching via shared wine-core match table)

- When a `wineNames` input resolves against the shared `wine-match-table` (populated from AVA's manual corrections + LWIN catalog, read at run start via wine-core `loadMatchTable`), the task is upgraded to a **native `/find/lwin{code}` lookup** - Wine-Searcher's authoritative LWIN resolution - instead of the fuzzy `/find/{name}` search. New `extractVintage` splits the vintage so the LWIN is built at the right granularity (`lwin7+vintage`, or `lwin7+'1000'` for NV). Misses and the no-table case fall back to the existing fuzzy search unchanged (graceful - the actor never depends on the table being present). `WineTask.viaMatchTable` + a run-log hit-count surface the upgrade rate. Abbreviation expansion is shared from wine-core (`expandAbbreviations`).

***

### v0.1.148 (2026-06-01)

#### Added (selector-rot insurance)

- JSON-LD schema.org/Product extraction layer (`src/jsonld.ts`). The parser now reads structured data first and falls back to CSS selectors per field. Wine-Searcher serves a complete Product schema on every detail page - when WS rotates a CSS class (the common cause of silent regressions), users keep getting full data via JSON-LD instead of nulls.
- 3 raw HTML fixtures captured from live Wine-Searcher pages (`tests/fixtures/ws-detail-*.html`, `tests/fixtures/ws-search-results.html`) - first time the test suite covers raw page HTML rather than hand-crafted snippets, so selector rot is caught at PR time, not by users.
- 15 tests covering JSON-LD extraction edge cases (malformed JSON, non-Product schemas, brand-as-string, single-offer-as-object, out-of-range scores, absurd per-bottle prices, Case-of-N normalization).

#### Internal

- No public API change: input schema, output schema, dataset row shape, and pricing model are unchanged. Selector-rot insurance only.

***

### v0.1.140 (2026-06-01)

#### Fixed (SEV-3)

- Currency mapping now covers 25+ symbols (JPY, CNY, HKD, CHF, AUD, CAD, NZD, SGD, ZAR, BRL, ARS, COP, INR, PHP, IDR, KRW, MYR, RUB, VND, NGN, THB, RSD, KES, AED, PLN, CZK, NOK, HUF, RON, etc.). Unknown symbols now return `null` instead of mislabeling as `USD` - callers can decide how to handle.
- `inputType` field is now always present on dataset rows (success AND graceful-error rows). Previously omitted on errors, forcing downstream consumers to defend against undefined.
- New `npm run prepush:version-check` script catches version drift between CHANGELOG.md and package.json before deploy.

***

### v0.1.139 (2026-06-01)

#### Fixed (SEV-4 cost containment)

- Cascade-block circuit breaker added. The actor now monitors the ratio of blocked errors over a sliding window of 30 requests. When the rate exceeds 50% past the warmup phase (first 10 requests), the run exits SUCCEEDED with `shutdownReason: 'cascading_blocks'` rather than continuing to burn compute against a hostile target. Eliminates runaway-cost runs observed in production (3/30 runs TIMED-OUT over 6h).
- OUTPUT KV record now includes `shutdownReason` for run-level observability.

***

### v0.1.138 (2026-06-01)

#### Fixed

- Source code visibility hardened. Local `.gitignore` now excludes `src/*.ts`, `tests/`, `dist/main.js.map` from the Apify upload set (these were previously bundled by `apify push`). `.actor/actor.json` adds `isSourceCodeHidden: true` to also hide remaining files in the Apify Console UI. Compliance with the global "source-files privacy" rule. No runtime change.

***

### \[0.1.136] (2026-05-21)

#### Changed

- Rate limiter switched from a uniform 250 ms delay between launches to a gaussian-distributed delay (mean 1500 ms, stddev 500 ms, floored at 250 ms). The target's anti-bot sensors use uniform-timing patterns as a bot signature - even with concurrency 3, three requests landing every 250 ms is robotic. With the new jitter, 10 initial requests now span ~15-20 s instead of 2.5 s, much closer to human browse cadence.

#### Rationale

Anti-bot best practice is that every action must continue to look human - passing the first request is not enough. Combined with the 0.1.135 concurrency drop, this gives the actor two independent levers against burst-detection: lower parallelism + non-uniform per-request timing. Trade-off: throughput per minute decreases proportionally to the new mean delay (250 ms → 1500 ms ≈ 6× slower at the launch step), but successful scrapes per minute is what matters, not theoretical request rate. If 0.1.135's concurrency drop wasn't enough to keep blocks down, this should close the gap.

***

### \[0.1.135] (2026-05-21)

#### Changed

- `DEFAULT_MAX_CONCURRENCY` lowered from 10 to 3. Two back-to-back production runs reproduced a full-cascade anti-bot block failure on the first batch of 10 parallel requests: the burst was flagged as bot traffic, all 10 wines exhausted their 3 retries with HTTP 403, run effectively delivered 0 scraped wines. The previous default of 10 was calibrated against a single isolated run; under back-to-back load or stricter anti-bot modes it craters. Default 3 gives a launch pattern ~3× less aggressive (~9 req/3s burst vs ~30 req/3s) and brings actual scrape success back. `MAX_CONCURRENCY_HARD_CAP` stays at 10 so power users on cooperative sessions can opt into faster wall-clock manually.
- README updated: default concurrency mentions changed from 10 to 3, expected wall-clock for 1000 wines from ~2 h to ~6 h (default) with the ~2 h figure noted as the conc=10 opt-in.

#### Trade-off

1000-wine batches now take ~6 h at default vs ~2 h previously. Reliability is prioritized over raw speed; the per-wine price is unchanged.

***

### \[0.1.134] (2026-05-21)

#### Added

- TSV pipeline now expands common French wine-name abbreviations (`Gds` → `Grands`, `NSG` → `Nuits-Saint-Georges`, `VV` → `Vieilles Vignes`, `Vge` → `Village`, `Chass` → `Chassagne`, `Mch` → `Montrachet`). Whole-word, case-insensitive matching; no false positives on words like `VVin`. Aligns POS-shorthand inputs with Wine-Searcher's canonical naming.
- TSV pipeline now flips known reversed-producer surnames to canonical order ("Lachaux Charles" → "Charles Lachaux"). Conservative whitelist of one entry to start; we only add names when a customer run surfaces them as a systematic miss. Most producers (e.g. "Lamy Hubert") are left alone because Wine-Searcher's fuzzy search handles them already.
- 8 new unit tests for abbreviation expansion + producer flip.

#### Context

Run `FNRPfCGBMDFRUgSI4` (115 wines, build 0.1.133): 51/59 succeeded (86%). The 8 remaining failures split into 3 categories: (A) reversed producer order - 2 cases for "Lachaux Charles", (B) abbreviations in wine names - 2-3 cases like "Gds Suchots", and (C) inputs Wine-Searcher genuinely cannot resolve (generic queries like "Leflaive Chardonnay", 2023 vintages not yet indexed, or obscure producers). This release covers categories A and B. Category C is documented as a Wine-Searcher coverage limitation - no fix possible from the scraper side.

***

### \[0.1.133] (2026-05-21)

#### Added

- TSV / Excel-paste input support: when a wine name contains tab characters (typical of POS export pasted from a cellar-management spreadsheet - format "Producer\tWine\tColor\tFormat\tVintage"), the actor now extracts producer + wine name + vintage and drops color/bottle-format metadata. Previously the entire string was URL-encoded as a single search query, which Wine-Searcher could not parse (e.g. "La Vougeraie Vougeot 1er cru Les Cras Rouge 75 cl 2011" → no match). Now the same input becomes "La Vougeraie Vougeot 1er cru Les Cras Rge 2011" and matches reliably. Implemented in `parseTsvWineInput()` which feeds into the existing `cleanWineName()` pipeline; 10 new unit tests cover the format variants (Magnum, Jéroboam, accented names, empty fields, vintage in non-final column).

***

### \[0.1.132] (2026-05-21)

#### Changed

- Internationalization sweep: all remaining French log strings, status messages, error messages, JSDoc and inline comments across `src/` and `tests/` translated to English. Aligns with the project's English-only doctrine for internal docs and applicative logs. Wine names (Château, Pétrus, Rosé, Cuvée, etc.) preserved as proper nouns. 279/279 tests passing. No runtime behavior change.

***

### \[0.1.131] (2026-05-21)

#### Changed

- Refactor: `handleFatalError` + `FatalErrorOutcome` extracted from `src/main.ts` into a dedicated `src/error-handling.ts` module. Pure module with no Apify SDK dependency, unit-testable without the 90-line `vi.mock` block that the previous test file required to import `main.ts`. No behavior change; the deployed binary is functionally identical.

***

### \[0.1.129] (2026-05-20)

#### Changed

- `MEMORY_TIERS` recalibrated for the post-0.1.121 concurrency cap of 10 (was 40). New recommended-memory floors: ≤200 wines → 512 MB, ≤500 wines → 1024 MB, ≤800 wines → 2048 MB, >800 wines → 4096 MB (was 8192). Aligns with the new `defaultMemoryMbytes: 4096` so default-configured 1000-wine runs no longer fire a spurious "memory tight" warning. Refuse thresholds untouched - genuinely under-provisioned configs still get a hard stop.
- French strings in `src/memory-guard.ts` translated to English (header comment, refuse messages, warn message). Aligns with the internal-language doctrine.

***

### \[0.1.128] (2026-05-20)

#### Changed

- `defaultMemoryMbytes` lowered from 8192 to 4096. The 8192 default was provisioned for the old concurrency-40 mode; since 0.1.121 the concurrency hard-cap is 10, which cuts peak memory pressure roughly fourfold. Observed peak in production: ~50 MB. `maxMemoryMbytes` stays at 8192 so users with 1000-wine batches can still scale up if the memory-guard warning fires. This also lowers the default compute footprint.

***

### \[0.1.126] (2026-05-20)

#### Fixed

- Empty or malformed input no longer crashes the run with exit 1. Calls with `inputType: "wineNames"` (or `urls`, `lwins`) and an empty/missing corresponding array, an unknown `inputType`, a batch over the 1000-wine cap, or zero items surviving per-item validation, now exit cleanly with a single dataset row carrying the error reason. Prevents buggy user scripts from crashing the actor on repeated bad input.
- Per-item errors (LWIN format, URL domain, empty name) keep their existing behavior of being partitioned into the `invalid[]` array - only top-level structural errors changed semantics.

#### Changed

- Internal: `validateInput` now throws a typed `InputValidationError` (with `code` and `field` metadata) instead of a generic `Error`. Error messages migrated to English in line with the project doctrine. New `handleFatalError` helper in `src/main.ts` classifies caught errors and converts validation errors into structured dataset rows + status messages. 278 unit tests cover the new paths.

***

### \[0.1.122] (2026-05-18)

#### Changed

- Canonical name unified across 3 artefacts to "Wine-Searcher Scraper from List" (hyphenated): README H1 + H2, `actor.json` title, `input_schema.json` title (was 3 different forms)
- `actor.json` description, `seoTitle` and `seoDescription` reordered to popularity-first ("popularity, critic scores, prices")
- `## Legal & Compliance` H2 renamed to `## Legal & compliance` (sentence case per de-AI-ification doctrine)
- Cross-promo to sister `wine-searcher-grape-scraper`: slug fixed (was broken `wine-searcher-region-scraper`), wording updated from "Region Scraper" to "Grape Scraper" and from "appellation" to "grape variety"
- Pricing: Starter plan tier renamed `$49 → $29/month` with recomputed capacity (~1,160 wines/month)
- Cross-promo table cell and Option 1 link text: "Wine Searcher Scraper from List" → "Wine-Searcher Scraper from List" (hyphenated)
- Features bullets reformatted: `**Title Case** -` AI-tell pattern replaced with `**Sentence case**:`
- Removed all em-dashes from `input_schema.json` (6 → 0)
- Removed all em-dashes from README and remaining `.actor/*.json` (zero em-dash policy 2026-05-18)

***

### \[0.1.121] (2026-05-13)

#### Changed

- **Concurrency capped at 10 (default + hard cap).** Empirically: conc=30 delivered 7.9 wines/min on the 102-wine reference run vs 8.77 wines/min at conc=10 - higher concurrency triggered more anti-bot blocks on the winery pages, dragging wall-clock with 30-60s retry backoffs that cancelled the parallelism win. Conc=10 is the measured sweet spot for reliability.
- **`MAX_CONCURRENCY_HARD_CAP = 10`** introduced in `src/main.ts`. The previous schema range 1-50 is now clamped to 1-10. Existing callers passing `maxConcurrency: 30+` will be silently capped. The successful result shape remains backward compatible.
- **Adaptive concurrency caps removed** (`LARGE_BATCH_THRESHOLD`, `VERY_LARGE_BATCH_THRESHOLD`, `MAX_CONCURRENCY_LARGE_BATCH`, `MAX_CONCURRENCY_VERY_LARGE_BATCH`, `RECOMMENDED_MIN_CONCURRENCY`) - dead code once the hard cap is 10.
- **Default `timeoutSecs` 7200 → 14400 (4h)** to accommodate 1000-wine runs at conc=10 (~2 h expected, comfortable margin for retries).

#### Why this reversal

The 0.1.120 default of conc=40 was based on linear extrapolation from a single 100-wine / conc=10 benchmark (8.77 wines/min → 35 wines/min @ conc=40). The 102-wine / conc=30 run that actually existed in production showed *worse* throughput than conc=10 - the bottleneck is the block-retry tail, not raw concurrency. Aggressive concurrency burns retries, not throughput.

#### Trade-off

1000-wine batches now take ~2 h instead of the previously claimed ~30 min. The per-wine price is unchanged ($0.025/wine).

***

### \[0.1.120] - 2026-05-13

#### Changed

- **Run capacity raised: 500 → 1000 wines.** `MAX_ITEMS_PER_RUN` (input.ts) and `maxItems` (input\_schema, 3 places) raised to 1000. `DEDUP_READBACK_LIMIT` (main.ts) 1000 → 2000 to stay strictly > `MAX_ITEMS_PER_RUN` after container migration.
- **Default memory: 1024 → 8192 MB** (`maxMemoryMbytes` aligned to 8192). Provisioned for 1000 wines @ conc=40 with comfortable headroom.
- **Default concurrency: 30 → 40.** Target 1000 wines / 30 min under the measured throughput baseline (~8.77 wines/min @ conc=10, assuming linear scaling up to conc=40).
- **Adaptive concurrency caps recalibrated** for the 1000-wine range:
  - `LARGE_BATCH_THRESHOLD` 200 → 400 (conc cap 25 → 40)
  - `VERY_LARGE_BATCH_THRESHOLD` 400 → 800 (conc cap 20 → 35)
  - `RECOMMENDED_MIN_CONCURRENCY` 20 → 30
  - `LARGE_INPUT_WARNING_THRESHOLD` 300 → 1000 (the "split into batches of 500-800 wines" warning now fires only beyond the new ceiling).

#### Memory guard

- **Tier-2 refusal added**: <2048 MB on >700 wines → refuse (before scraping). Prevents likely OOM in the 1000-wine range.
- **Recommended-memory tiers** introduced via `recommendedMemoryForTasks(taskCount)`: ≤200 → 1024 MB, ≤500 → 2048 MB, ≤800 → 4096 MB, >800 → 8192 MB. The warn is now proportional to batch size instead of a fixed threshold.
- **Minimum warn threshold**: no memory nudge below 100 wines (any config passing the refusal tiers stays "ok").

#### Tests

- 270 tests passing (+ 11 new: 1 for the tier-2 refusal, 3 for the ok at 8192/1000, 8 parametrized on `recommendedMemoryForTasks`).

#### Expected performance (to validate in live)

- 1000 wines @ conc=40, 8192 MB → ~30 min wall-clock (vs ~57 min @ conc=20 with old default).
- User cost unchanged: $25 / 1000 wines (PPE unchanged at $0.025).

#### Why now

Analysis of the last 61 runs (30 days): all 5 non-trivial incidents (TIMED-OUT, ABORTED, FAILED) in May involved batches of 350-500 wines with 512-1024 MB RAM. The 1024 MB default was under-provisioned beyond ~300 wines.

***

### \[0.1.112] - 2026-05-10

#### Fixed

- **"Showing results for…" page detection previously missed**: the `isSearchResultsPage` scan was limited to the first 30,000 characters of HTML, but Wine-Searcher pages are 250+ KB and the marker can appear around ~50 KB. Now a full HTML scan (~1 ms overhead). 33 wines per batch of 500 (~7%) fell through this gap - their H1 "Showing results for 'lwin1065208'" became the wineName, all other fields were null, and PPE was charged for junk data.
- **Downstream guard `isSuccessfulResult`**: if despite the upstream detection a wineName starts with "Showing results for", billing is now refused (defense-in-depth backstop). Estimated saving: ~$0.83 per batch of 500 wines, and popularity-on-success rises from 92.4% → ~98%.

#### Changed

- **`MAX_ADDITIONAL_WINERY_PAGES` 5 → 10**: for large producers (Méo-Camuzet with >60 wines listed across >6 pages), the specific wine was on page 7+ and the algorithm gave up after 5 additional pages. `PER_WINERY_BUDGET_MS = 600s` remains the hard cap, so this extension does not create uncontrolled fan-out.

#### Tests

- 252 → 254 (+2 tests sur le backstop "Showing results for" dans `isSuccessfulResult`).

***

### \[0.1.116] - 2026-05-10

#### Fixed

- **`cheapestPriceAmount` is now ALWAYS per-bottle** - when the offer card is a case ("Case of 12", "6x75cl", "12 bottles"), the price is divided by the number of bottles before being returned. Before this fix, some EU merchants (e.g. SELECTION SOMMELIER on Smith Haut Lafitte 2020 → €408 for 12 bottles) returned the full case price, causing a silent massive overpricing (~12×). Mainly affects Bordeaux/Burgundy wines sourced from Europe. **If you have an internal baseline built from the raw price, expect prices to drop for these wines - this is the correct behavior per the `cheapestPriceAmount = "per 75cl bottle"` documentation.**

#### Changed

- **New `bottlesPerUnit` field** in the dataset: `1` for single-bottle (default case), `2-24` for detected cases. Allows reconstructing the case price (`cheapestPriceAmount × bottlesPerUnit`) or auditing the normalization. Detected patterns: `case of N`, `Nx75cl`, `Nx750ml`, `N bottles`, `N-pack`. Sanity guard: if the per-bottle price after division falls outside \[$1, $100000], the offer is skipped (likely false positive).

#### Tests

- 254 → 259 (+5 parser tests: case-of-12, 6x75cl, false positive "case of 999", sanity skip).

#### Credit

- Bug reported by a user via the Apify Store Issues tab, reproduced in production on Smith Haut Lafitte 2020.

***

### \[0.1.110] - 2026-05-10

#### Changed

- **Concurrency caps raised after stress tests**: `MAX_CONCURRENCY_LARGE_BATCH` 15→25 (>200 wines) and `MAX_CONCURRENCY_VERY_LARGE_BATCH` 10→20 (>400 wines). Calibrated on cold-start stress tests at 350 and 500 wines where memory stayed ≤320 MB peak thanks to the LRU-bounded winery HTML buffer from 0.1.103. Expected speedup ~1.8×–2× on large batches without OOM risk (pre-LRU memory = 80+ MB for 200 wineries → now capped at ~12 MB).

#### Fixed

- **`memory guard warn` message fixed**: now recommends the next tier up (1024→2048 MB for batches >300 wines already at 1024 MB) instead of the contradictory "1024 MB recommended" when the user is already at 1024 MB. Discovered during stress tests.

***

### \[0.1.106] - 2026-05-10

#### Changed

- **PPE policy hardened: billing only on full success.** `Actor.charge('wine-extracted')` is now guarded by `isSuccessfulResult(data)` which requires `wineName !== null && no error`. Consequence: `notFound`, infrastructure errors (timeout, blocked, transient\_api, transient\_net, rate\_limit), internal bugs, and parsed-empty results are pushed to the dataset as structured errors **but are no longer billed**. Previously every push (except invalid input + auth) was billed.

#### Fixed

- **Parsed-empty detection + analytics alarm.** When a scrape succeeds (HTML 200 OK) but parsing returns `wineName/price/winery` all null, this is now treated as a structured error (`error: "Parsed empty (possible site change)"`) without billing PPE and with a `parsedEmptyCount` counter. If > 0, an explicit warning prompts checking the Wine-Searcher DOM. Free DOM-change canary.
- **Isolated success counter**: the final "Done: N wine(s) extracted" message now uses `validCompletedCount` (real successes only) instead of `pushedInputValues.size` which included error rows.
- **Defensive try/catch** around `pushAndCharge` in the global catch of `processWine` - a transient Apify storage hiccup during error-handling no longer flips the run to FAILED.
- **`maxConcurrency` clamped** between 1 and 50 (NaN → default). Prevents 2h CPU spins on malformed REST API input.
- **Sanitizer logs** now also scrub the literal API key value (in addition to the URL and provider brand name).
- **Internal data reads return defensive copies** to prevent callee mutations from corrupting shared state.
- **LRU `peek()` non-touching** + identity guard before winery-buffer eviction to prevent a stale handler from deleting an entry freshly pushed by another consumer (rare race post-eviction).
- **`DEDUP_READBACK_LIMIT = 1000` named constant + assertion `> MAX_ITEMS_PER_RUN`** at startup. Guarantees that any future move to >1000 wines per run will not cause silent duplicate entries after container migration.

#### Tests

- 245 → 252 tests (+7). Full suite passes locally.

***

### \[0.1.103] - 2026-05-09

#### Fixed

- **Startup memory guard**: a run with a structurally OOM-bound RAM × volume combination (e.g. 512 MB × 350 wines) is now refused immediately with a clear action message, instead of running for 18 minutes before a SIGKILL.
- **Non-fatal validation**: an invalid LWIN, URL, or wine name no longer kills the entire batch. Rejected entries are pushed to the dataset as structured errors (without PPE billing), letting the user see exactly what was rejected. Before this fix, a single 18-digit LWIN format would fail an input of 500 wines.
- **Double winery-retry layer removed**: the external retry layer (`MAX_WINERY_SCRAPE_RETRIES`) that duplicated `scraper.ts`'s internal policy is removed. Worst-case per winery drops from 10.5 min to ~8 min on blocks, freeing the concurrency pool and avoiding run timeouts.
- **Winery HTML buffer bounded (LRU 30)**: the winery HTML buffer is now limited to 30 entries (≈ 12-30 MB max) instead of growing linearly. Prevents OOM SIGKILLs on batches >300 wines at 512 MB memory.
- **Per-winery budget 10 min**: a new global ceiling protects against the worst-case where multiple pagination pages of the same winery are all blocked (which could otherwise reach 6 × 8 min = 48 min per winery).

***

### \[0.1.102] - 2026-05-08

#### Changed

- **Winery timeout increased from 90s to 120s** for better reliability on slow winery pages.

#### Tests

- Exported `MAX_WINERY_SCRAPE_RETRIES` constant; tests now derive all magic numbers from it. Fixed invalid HTML fixtures (`<a>` without `<td>` wrapper). Renamed shadowed `WINERY_URL` variable.

***

### \[0.1.100] - 2026-05-07

#### Fixed

- **Winery URL fallback for producers without carousel/profile section.** When the wine page lacks the "Also from..." carousel and profile section (small producers like Edmond Vatan), the parser now searches all `/merchant/{id}-{slug}` links on the page and matches the slug against the extracted winery name. Fixes `winePopularity: null` for wines where the winery name was found but the URL wasn't.

#### Changed

- Internal data-format versioning fix so wines affected by the missing winery URL fallback now return complete winery data.

#### Tests

- 2 new tests: winery URL fallback matching, non-matching merchant slug. 205 tests total.

***

### \[0.1.99] - 2026-05-07

#### Fixed

- **Winery popularity: multi-page search for wines not on page 1.** Winery pages show 10 wines per page sorted by popularity. When a wine isn't on page 1, all Chassagne-Montrachet entries (for example) tie at the same score, and the first entry's rank is returned incorrectly. The scraper now detects ambiguous matches (multiple entries tied at the best score) and lazily scrapes additional winery pages (`/11`, `/21`…) until finding a confident match. Fixes wines like Colin-Morey *Les Charmes*, *Baudines*, *La Garenne* which were all returning "487th" (Corton-Charlemagne's rank from page 1).

#### Added

- `detectWineryPagination()` - extracts additional page URLs from winery HTML pagination links.
- `parseWineryPopularityDetailed()` - internal parser returning score + `tiedCount` for ambiguity detection.
- Multi-page fetch loop in `fetchWineryPopularity()` with lazy evaluation (stops on first confident match) and cap at 5 additional pages.

#### Tests

- 9 new tests: pagination detection (4), tie detection (2), multi-page resolution (3). 203 tests total.

***

### \[0.1.98] - 2026-05-07

#### Fixed

- **Stale shared popularity corrected.** Wines extracted before 0.1.97 could carry the wrong shared popularity (all wines from the same winery getting the first wine's rank). Each wine now gets its own correctly parsed rank.

#### Changed

- Internal data-format versioning added so future fixes propagate cleanly.

***

### \[0.1.97] - 2026-05-07

#### Fixed

- **Winery popularity: per-wine parsing instead of shared result.** Refactored the winery HTML buffer to store raw HTML instead of the parsed popularity string. Each wine now gets its own popularity ranking from the winery page - previously, all wines from the same winery shared the first wine's ranking (e.g. all 5 Colin-Morey wines returned "487th" instead of their individual ranks).

#### Changed

- In-memory winery dedup now stores `Promise<string | null>` (HTML) instead of parsed popularity.
- New internal `fetchWineryHtml()` handles scrape + buffer + retries. `fetchWineryPopularity()` now calls it then parses individually per wine name.

#### Tests

- New test: "concurrent wines share same scrape but get individual popularity" - 2 wines from the same winery get different rankings (487th vs 2,622nd) with a single scrape call. 194 tests total.

***

### \[0.1.96] - 2026-05-07

#### Fixed

- **Winery popularity: normalize hyphens and accents in name matching.** `parseWineryPopularity()` now normalizes both the wine name and the winery page HTML text before comparison - hyphens are converted to spaces and diacritics are stripped (Unicode NFD). Fixes `winePopularity: null` for producers like **Pierre-Yves Colin-Morey** where the winery page uses different hyphenation/accentuation than the wine page (e.g. "Pierre Yves" vs "Pierre-Yves", "Chatenière" vs "Chateniere").
- **Winery timeout raised from 60s to 90s.** Some winery pages (e.g. Pierre-Yves Colin-Morey) intermittently require >60s due to scraping provider retry loops. The increased timeout improves first-attempt success rate without excessive pool blocking.

#### Tests

- `tests/winery.test.ts` - 4 new tests for hyphen/accent normalization (hyphens in name only, hyphens in HTML only, both, neither). 194 tests total (was 190).

***

### \[0.1.92] - 2026-05-07

#### Fixed

- **Winery popularity: internal retries benefit all concurrent consumers.** Moved retry logic inside the shared Promise so that when multiple wines share the same winery, a transient scrape failure triggers up to 2 internal retries (5s delay each) and ALL concurrent consumers receive the successful result. Previously, the external `wineryFailCount` mechanism only worked for sequential requests - under concurrency 30, all wines from the same winery joined the same failing Promise with no retry opportunity.

#### Changed

- Removed `wineryFailCount` Map and `handleWineryFailure()` - replaced by internal retry loop within `fetchWineryPopularity()`.
- The winery-buffer reset helper simplified (no longer needs to reset fail counters).

#### Tests

- `tests/winery.test.ts` - Fully rewritten: 7 tests (was 6). New test: "concurrent wines benefit from internal retry on failure" - 5 concurrent calls, first scrape fails, retry succeeds, all 5 get the result. 190 tests total.

***

### \[0.1.89] - 2026-05-07

#### Added

- **Warning on large batches (>300 wines).** A log warning now recommends splitting into batches of 200-300 wines when more than 300 are submitted, to prevent performance degradation.
- **Success rate monitoring.** At the end of each run, the analytics module calculates and logs the success rate (%). If the rate drops below 85% on batches >10 wines, a warning is emitted to flag potential recurring errors. The success rate tracks **scraping infrastructure failures** (timeouts, blocked requests, API errors) - not whether a wine exists on Wine-Searcher. Invalid or unknown wines are still scraped successfully and return a result row with partial data (name from Wine-Searcher's "Showing results for…" fallback, no score/price).
- `successRate` field in `RunAnalytics` (persisted in internal analytics storage).
- `SUCCESS_RATE_ALERT_THRESHOLD` exported constant (85%).
- `tests/analytics.test.ts` - 3 new tests (successRate calculation, null on empty, threshold constant). 20 tests total (was 17).

***

### \[0.1.88] - 2026-05-07

#### Changed

- **Default memory raised to 1024 MB** (was 512 MB). Prevents OOM kills (exit 137) on large batches that previously hit the 512 MB ceiling at ~500 wines.
- **Adaptive concurrency cap.** Batches >400 wines are automatically capped at concurrency 10; batches >200 wines at concurrency 15 - regardless of the user's `maxConcurrency` setting. Reduces peak memory footprint by limiting in-flight HTML pages.

#### Added

- **Winery timeout (60s).** Each winery scrape is now wrapped in a `Promise.race` with a 60-second deadline. If a winery page is blocked and the scraping provider retries for minutes, the timeout fires, the winery is evicted from the in-memory winery dedup buffer (allowing a future retry), and the wine is pushed with `winePopularity: null` instead of blocking the entire pool.
- `tests/winery.test.ts` - 1 new test (winery timeout returns null). 6 tests total (was 5).

***

### \[0.1.86] - 2026-05-05

#### Fixed

- **winePopularity null on batch runs.** When multiple wines share the same winery and the winery scrape fails (blocked, timeout), the null result was stored permanently in the Promise-based in-memory winery dedup buffer. All subsequent wines from the same producer inherited null popularity without retrying. Now failed entries are evicted from the buffer with up to 2 retries per winery URL before giving up.

#### Added

- `tests/winery.test.ts` - 5 tests covering retry logic for `fetchWineryPopularity`.

***

### \[0.1.85] - 2026-05-05

#### Fixed

- **403 errors on user URLs.** URLs passed in `urls` mode are now normalized: country/currency suffixes (`/usa/usd`, `/fr/eur`) are stripped and non-ASCII characters (e.g. `Rosé`) are percent-encoded. This prevents systematic 403 errors caused by malformed URLs.
- **Increased blocked request retries** from 2 to 3 (4 total attempts, backoff 30s + 60s + 120s) to handle transient blocks more reliably.

#### Added

- `normalizeWineSearcherUrl()` exported from `src/input.ts` for URL sanitization.
- "Blocked by Wine-Searcher (403 errors)" troubleshooting section in README.

***

### \[0.1.82] - 2026-05-05

#### Added

- **Graceful shutdown.** Actor now handles `aborting` (user cancel), `migrating` (container migration), and a soft deadline (timeout − 45s) to stop cleanly. In-flight wines finish, partial results are delivered as SUCCEEDED with a clear status message (`Partial: X/Y wines (stopped by {reason})`). Reduces TIMED-OUT and ABORTED failure rates.
- **Typed shutdown reason.** `ShutdownReason` union type (`'user abort' | 'migration' | 'timeout'`) prevents stringly-typed bugs.

#### Changed

- **Timeout estimation formula corrected.** Now accounts for concurrency: `max(120, ⌈batchSize ÷ concurrency⌉ × 25)` instead of `batchSize × 8`. Documentation updated in input schema and README.

***

### \[0.1.80] - 2026-05-05

#### Fixed

- **Memory pressure reduced - fixes OOM on large batches (500 wines).** In-memory winery dedup now stores parsed popularity strings instead of raw HTML, freeing ~60 MB of permanently retained data. Wine page HTML is released immediately after parsing, saving ~9 MB at concurrency 30. Combined savings prevent OOM kills (exit code 137) that occurred at 503/512 MB.
- **Dead code removed** - redundant guarded block eliminated.

#### Changed

- Internal data accessors simplified to return `T | null` directly (was a wrapped object) - callers no longer need defensive destructuring.

***

### \[0.1.77] - 2026-04-25

#### Changed

- **README optimized for Apify Store.** Complete restructure aligned with Vivino actor template: added "What is" intro, "Which wine scraper should I use?" cross-selling table, "Quick Start - Test in 60 seconds", "Why scrape Wine-Searcher?" use cases, data extraction table with "Always included" column, configuration table with JSON examples, "Tips for best results", "Troubleshooting" (5 scenarios), "Privacy & Security", "Resources", "License". Pricing reformatted with tiers. FAQ enriched (10 questions). Changelog limited to 10 most recent versions. "Related Wine Scrapers" moved up and expanded.

***

### \[0.1.76] - 2026-04-22

#### Added

- **POS/inventory wine name cleaning.** Wine names from POS systems (e.g. `Champagne, Dom Perignon Brut, 2013, Champagne, France`) are now automatically cleaned before searching Wine-Searcher. The pipeline strips category prefixes (Champagne, Port, Dessert Wine, Red Blend, Sauvignon Blanc…), bottle sizes in parentheses ((375ml), (Split 187ml), (1.5L)…), and replaces commas with spaces. The original input is preserved in `inputValue` - only the search URL is cleaned. This dramatically improves match rates for clients sending POS/inventory-formatted wine lists.
- New exported function `cleanWineName()` in `src/input.ts` with 27 unit tests.

#### Changed

- 170 total tests (was 139).

***

### \[0.1.74] - 2026-04-21

#### Fixed

- **Numeric LWIN codes no longer crash the actor.** REST API and integration clients (n8n, Make, Python, etc.) sending LWIN codes as numbers (`[1067130]`) instead of strings (`["1067130"]`) caused an immediate fatal error. `normalizeLwinEntry` now accepts `number` entries and converts them to strings before validation. Affects LWIN7, LWIN11 and longer codes.
- **Missing `inputType` no longer crashes the actor.** API clients omitting the `inputType` field (which is only auto-filled by the Apify Console UI) caused an immediate fatal error. `validateInput` now auto-detects the input type from whichever array field is populated (`lwins`, `urls`, or `wineNames`).

#### Changed

- `LwinEntry` type extended to accept `number` in addition to `string` and object formats.
- 10 new tests covering both fixes (139 total, was 129).

***

### \[0.1.73] - 2026-04-20

#### Changed

- **Unified pipeline.** Merged 2-phase architecture (Phase 1: wine pages → Phase 2: winery pages) into a single pipeline where each task chains wine scrape → parse → winery scrape → push without waiting for other tasks. Eliminates idle time between phases (~15-20% throughput improvement).
- Analytics: `phase1DurationMs` + `phase2DurationMs` replaced by single `scrapingDurationMs`.
- Scraping response metrics (`recordResponseMetrics`) downgraded from `log.info` to `log.debug` - reduces log noise on large batches while keeping the end-of-run summary in `log.info`.

#### Removed

- `WinePhaseResult` intermediate interface (no longer needed with unified pipeline).
- Phase separator logs (`=== PHASE 1 ===`, `=== PHASE 2 ===`).
- Winery-buffer reset call between phases (buffer is empty at startup).

***

### \[0.1.71] - 2026-04-20

#### Changed

- **Console UI and output streamlined.** Removed two experimental input fields, an internal timestamp field from dataset rows, an unused storage schema declaration in actor.json, and stale FAQ/pricing rows; reduced log verbosity at the default level.

***

### \[0.1.69] - 2026-04-19

#### Added

- **API Integration guide.** README now documents synchronous (`run-sync-get-dataset-items`) and asynchronous (`/runs`) API calls with cURL, Node.js and Python examples. Dataset export formats table (JSON, CSV, Excel, XML, JSONL) with field filtering.
- **Workflow & database integration guide.** New "Integrate into Your Workflow" section: scheduled runs (cron examples), webhooks (Flask → PostgreSQL example), full database integration examples (Node.js + PostgreSQL, Python + SQLite), no-code integrations table (Google Sheets, Airtable, Zapier, Make, n8n), and large catalog batching pattern (>500 wines).

#### Changed

- FAQ "Can I integrate this with my existing tools?" now links to the new integration section instead of a generic answer.

***

### \[0.1.67] - 2026-04-19

#### Changed

- **All wines billed uniformly at $0.025.** Updated all marketing copy (README, input schema) to reflect that every extracted wine carries the standard $0.025 PPE charge regardless of internal data origin. The code already billed uniformly - this aligns the documentation with actual behavior. Removed "free re-fetch" mentions from key features, pricing table, FAQ, and input field descriptions.

***

### \[0.1.63] - 2026-04-19

#### Added

- **Run analytics.** Structured metrics are now persisted to internal storage at the end of every run. Includes batch size, input type, success/error/not-found counts, phase durations, and full scraping retry distribution. Enables data-driven monitoring of actor health and usage patterns.
- **Timeout guidance.** New informational `timeoutSecs` field in the Apify Console input UI with recommended values. The actor now warns at startup if the allocated run timeout looks too low for the batch size (formula: `max(120, batchSize × 8)` seconds). README FAQ enriched with a batch-size-to-timeout recommendation table.
- **Review solicitation.** End-of-run logs now include a visible call-to-action with the Apify Store review link (with affiliate tag). The actor status message shows the wine count on completion.

#### Fixed

- Missing analytics tracking on 4 error paths: HTML-level 404 detection, search-results redirect failures, and Phase 2 inner catch fallback were not counted in `notFoundCount` / `errorCount`.

#### Changed

- Finalization logic (`logScrapingSummary`, analytics persist) moved to a `finally` block - ensures metrics are always saved, even on fatal errors.
- Extracted `cloneScrapingMetrics()` helper in `scraper.ts` to eliminate duplicated deep-copy logic between `getScrapingMetrics()` and `getAnalyticsSnapshot()`.

***

### \[0.1.62] - 2026-04-19

#### Added

- **LWIN16/LWIN18 support.** Longer LWIN codes (12+ digits) are now automatically truncated to the first 11 digits (LWIN11) before URL construction. Previously, these codes caused a validation error - now they work seamlessly for users whose wine management software exports extended LWIN formats.

***

### \[0.1.61] - 2026-04-18

#### Changed

- `maxConcurrency` removed from the Apify Console input UI - concurrency is now fixed at 30 for all users. The parameter remains functional via the REST API for power users.
- README FAQ updated: concurrency no longer advertised as a tunable setting.

***

### \[0.1.60] - 2026-04-17

#### Fixed

- **Duplicate dataset entries on long runs.** When Apify migrates the actor container mid-run, the script restarts from scratch on the same dataset - previously producing duplicate entries (up to 1.5× the expected item count). Now: existing dataset items are read at startup to detect restarts, already-pushed wines are filtered from the task list, and a Set-based guard in `pushAndCharge` prevents any double push within the same execution.

***

### \[0.1.59] - 2026-04-17

#### Changed

- Default `maxConcurrency` bumped from 10 to **30** - the actor now scrapes up to 30 wines in parallel (was 10), tripling throughput for large batches.
- Input schema description updated with realistic timing (500 wines ≈ 30-60 min) and consistent guidance.
- README FAQ updated to reflect new defaults and validated performance data.

#### Removed

- Internal operational metrics no longer appear in the actor logs. Logs now show request count + observed concurrency only.
- Provider brand references scrubbed from JSDoc comments and log lines - all naming is now generic (`scraping-api`).

***

### \[0.1.57] - 2026-04-16

#### Fixed

- **HTTP 403 no longer kills the entire run.** Previously, a single 403 from the scraping provider was classified as `permanent_auth` (dead API key) and triggered `Actor.exit(1)` - dropping all successfully parsed wines from the dataset. Now: 401 = `permanent_auth` (fatal), 403 = `blocked` (retryable with 30s/60s backoff + jitter). A 403 isolated to one URL is retried; if all attempts fail, that wine is marked as error and the run continues.

***

### \[0.1.56] - 2026-04-16

#### Added

- **Differentiated retry strategy** with 7 failure categories: `permanent_auth`, `not_found`, `rate_limit`, `transient_api`, `transient_net`, `blocked`, `timeout`. Each category has its own retry policy (0-5 retries, custom backoff, jitter ±30%).
- **Retry distribution stats** in end-of-run log: `attempt-1=N (X%), attempt-2=M (Y%), failed=K (Z%)` + failure categories breakdown.
- `Retry-After` header respected on HTTP 429 responses.
- `ScrapeResult` discriminated union type replaces raw `string | null` returns from scraper - callers get structured success/failure data.

#### Changed

- `scrapeWithRetry` refactored: `classifyError` pure function determines category, `RETRY_POLICY` table drives retry/backoff per category, exponential backoff with ±30% jitter prevents thundering herd.
- `not_found` (HTTP 404) is no longer retried (0 retries) - saves provider budget on wines that don't exist.

#### Removed

- Legacy `MAX_RETRIES` constant and uniform retry logic.

***

### \[0.1.55] - 2026-04-16

#### Changed

- `maxConcurrency` upper limit raised from 15 to **50**.
- `RECOMMENDED_MIN_CONCURRENCY` raised from 10 to 20 - runtime warning triggers for large batches with concurrency below 20.

#### Added

- README FAQ: "Which run timeout should I set?" (prompted by an external user's TIMED-OUT run with `timeoutSecs: 15`).

***

### \[0.1.54] - 2026-04-16

#### Added

- **Scraping instrumentation**: per-request log with concurrency remaining/limit and request ID. End-of-run synthesis with total request count and min concurrency observed.
- Hidden adaptive-request-mode flag (REST API only, not in input schema) for internal A/B testing of the scraping request configuration.

#### Tested

- Internal A/B test on 20 diverse wines: the alternative request mode offered **no measurable benefit** but doubled runtime due to 6× more HTTP 403 retries. Decision: keep the default mode.

***

### \[0.1.53] - 2026-04-16

#### Changed

- **Rate limiter reduced from 2s to 250ms** between scraping requests - major throughput improvement.
- Default `maxConcurrency` raised from 3 to 10, upper limit from 10 to 15.
- Maximum wines per run capped at **500** (was 1000) with input validation - prevents runs that are mathematically impossible to complete within the timeout.
- Default run timeout set explicitly to **2 hours** in `actor.json`.

#### Fixed

- TIMED-OUT runs reduced from ~9.3% to near zero (root cause: 2s rate limiter + concurrency 3 + no batch cap).

***

### \[0.1.38–0.1.52] - 2026-03-23 → 2026-04-16

#### Changed

- **Scraping backend upgraded** for better reliability and coverage. Transparent to users - same input, same output.
- Internal scraping client refactored to provider-neutral naming (`ScraperClient`, `SCRAPING_API_BASE_URL`).
- `parseWinePage(html)` consolidated to a single `cheerio.load()` call (was 3× per wine).
- Various parser optimizations: regex on raw HTML instead of `$('body').text()`, `toAbsoluteUrl()` helper, `isProTeaserCard()` predicate.
- `buildWineResult()` and `pushAndCharge()` helpers eliminate Phase 2 code duplication.
- `buildErrorResult()` helper replaces 5-6× boilerplate error objects.
- `fetchWineHtml()` helper encapsulates mock vs live dispatch.
- Internal storage handle memoized (avoids re-opening it on every call).
- `WineTaskInputType` union type replaces raw `string` for `WineTask.inputType`.

***

### \[0.1.37] - 2026-03-23

#### Added

- Optional `scrapingApiKey` input field marked as `isSecret` for users who want to provide their own key (overrides environment variable).

***

### \[0.1.36] - 2026-03-23

#### Changed

- Scraping API key environment variable renamed to a provider-neutral `SCRAPING_API_KEY`. Apify secret updated accordingly.

***

### \[0.1.35] - 2026-03-23

#### Fixed

- **Apify quality score improvements**: all input field descriptions rewritten (what + why + how + example format), `minItems`/`maxItems` added to arrays.
- Missing API key no longer crashes with `Failed` status - now exits cleanly via `Actor.exit()` with a user-friendly message.
- Scraping-provider mention removed from the README footer.

***

### \[0.1.34] - 2026-03-19

#### Fixed

- **LWIN object format support**: `{"lwin7": "1131644", "vintage": 2021}` and `{"lwin11": "11316442021"}` now work correctly (was stringified as `[object Object]`).
- Phase 2 errors no longer crash the actor - failed winery scrapes push results with `winePopularity: null` instead.

#### Added

- `normalizeLwinEntry()` function with validation (7-digit LWIN7, 10-11 digit LWIN11).
- 13 new LWIN input tests.

***

### \[0.1.33] - 2026-03-19

#### Fixed

- Charger price comment corrected (`$0.005` → `$0.025`).
- README Field Reference table completed (14 → 18 fields).

***

### \[0.1.32] - 2026-03-19

#### Added

- Internal storage schema declared in `actor.json`.

***

### \[0.1.31] - 2026-03-19

#### Changed

- **Pricing model changed**: from bring-your-own-key to **scraping included at $0.025/wine**. Zero setup required for users.
- The scraping API key was removed from user input - now managed via Apify secret.
- README completely rewritten for new pricing model.

***

### \[0.1.30] - 2026-03-19

#### Added

- Output schema (`.actor/output_schema.json`) with full JSON Schema for all 18 dataset fields.

#### Fixed

- **Dataset push crash on null fields**: `winePopularity` can be null - schema updated to `["string", "null"]` types.

***

### \[0.1.29] - 2026-03-19

#### Added

- SEO metadata and Apify Store categories in `actor.json`.

***

### \[0.1.27] - 2026-03-18

#### Fixed

- Winery name fallback: bottle size suffix (` - 75cl`) no longer pollutes extracted name; vintage years (19xx/20xx) are skipped as candidates.

***

### \[0.1.23–0.1.26] - 2026-03-17

#### Added

- **Smart winery retry**: wines with missing winery data (`winePopularity: null` but `wineryUrl` present) are automatically retried in Phase 2 instead of serving stale nulls forever.
- **Winery name fallback** via offer-card consensus: when a producer has no dedicated Wine-Searcher page (no `/merchant/` link), the winery name is extracted from offer descriptions using frequency-based voting across cards.

#### Changed

- Complete marketing rewrite: README with SEO structure (6 key features, 4 use cases, pricing table, 7 FAQ), input schema descriptions enriched, `actor.json` SEO description.

***

### \[0.1.22] - 2026-03-17

#### Fixed

- **Winery blocking reduced**: 4 anti-detection measures implemented:
  1. Global rate limiter (2s between requests) prevents burst patterns.
  2. Winery-specific backoff increased to 5s/15s/30s (was 2s/4s).
  3. In-memory Promise-based winery dedup - duplicate winery requests share a single scrape.
  4. Phase 1 (wine pages) and Phase 2 (winery pages) now run sequentially instead of interleaved.

***

### \[0.1.16] - 2026-03-17

#### Changed

- PPE pricing set to $0.008/wine (later raised to $0.025 in 0.1.31).
- Scraping API key configured via Apify secret (no longer required in input).

***

### \[0.1.13] - 2026-03-16

#### Added

- **Search results page detection**: when Wine-Searcher returns a search results page instead of a wine profile (common with ambiguous wine names), the actor detects it and follows the first wine link automatically.
- HTML scan window extended to 100k characters (Wine-Searcher header/nav occupies 70k+ chars before content).

***

### \[0.1.7–0.1.12] - 2026-03-16

#### Changed

- Migrated from Node.js 20 to **Node.js 22**.
- `proxyCountry` parameter implemented (controls which merchant offers and prices are displayed).
- Concurrency pool hardened.

***

### \[0.1.1–0.1.6] - 2026-03-07

#### Added

- **Initial release**: Apify Actor extracting wine scores, prices, winery info and popularity from Wine-Searcher.com.
- Three input modes: wine names (search), Wine-Searcher URLs (direct), LWIN codes (industry standard).
- Pay-Per-Event monetization via `Actor.charge()`.
- Cheerio-based HTML parsing.
- Docker multi-stage build (node:22-slim → apify/actor-node:22).
