# Changelog of Booking.com Room-Level Hotel Price Scraper (`noraview/booking-price-scraper`) Actor

- **URL**: https://apify.com/noraview/booking-price-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/noraview/booking-price-scraper.md

## Changelog

Every release of the Booking.com Room-Level Price Tracker, newest first.
The three most recent also appear in the [README](README.md).

***

<details open>
<summary><strong>v2.16.5 — September 2026 — The digest compares you with your competitors, and says when prices are before tax</strong></summary>

- 🐞 **Your hotel was compared with a median that included your own rate.** With four properties your rate is half of the middle, and it moves when you move. On the Perth run of 24 Sep the email read *"Wed 30 Sep: you are 18 under the market median (245 vs 263)"*; the three competitors were at 180, 281 and 363, so their median was **281** and the real gap **36**. Every *you vs the market* line, the 👉 headline and the median in *Next nights* are now your **competitors' median**, your rate left out. It needs two competitor rates: one rate is not a median. Without a hotel of yours the digest keeps the market median, and `market_median_rate` and the gap columns in `market_summary` are unchanged
- ⚠️ **The email did not say its prices were before tax.** Booking shows visitors from the US the rate before tax, with the tax on top: every Perth rate on the test runs was 10 % under what the guest pays (245 shown, 269 paid), which is also 10 % under what the hotel sees in its own extranet. The digest now says it under the header — *"Prices are before tax, as Booking shows them from US: the guest pays the tax on top."* — and warns when some properties show tax and others do not. The basis comes from the new `taxes_on_top` in `market_summary`: true when every rate on sale that night adds tax on top, false when every one has it inside, empty when they disagree or do not say
- 🐞 **A room back on sale read as a price jump.** Its price is compared with the last one seen *before* it sold out, and the digest listed it with the price moves: on the rerun of 24 Sep, *"ibis · Sat 26 Sep: +110.4% (Standard Queen Room 173 → 364)"* — at the previous run that room was sold out, and 173 was days old. It is now a return: *"🟢 ibis · Sat 26 Sep: Standard Queen Room back on sale at 364 (173 before it sold out)"*
- 🐞 **Pages that could not be read were not named.** On the run of 25 Sep, 2 of 40 pages failed — your hotel and ibis, both for Mon 28. The email said *"they are listed per hotel below"* and listed nothing, and Mon 28 read *"cheapest 272 (Sanno)"* as if it were the whole market, when ibis, usually the cheapest, had not been read. The ⚠️ RUN block now lists them (*"Not read: Flag Motor Lodge (you) Mon 28 Sep; ibis Budget Perth Airport Mon 28 Sep"*), the night says it before its numbers (*"Mon 28 Sep · ⚠️ not read: you, ibis Budget Perth Airport · cheapest 272…"*), your own section says which of your nights were not read, and a property that failed on every night still counts in the header
- 📖 **README rewritten from zero, around the fields.** Every field of the three datasets — 106 room fields, 42 in Market by date, 6 in Hotel metadata — is described by the question it answers, with a real example from the Perth runs and when it is empty. The 12 that matter most come first. A new test fails if a field is added to a schema and not documented, or documented and no longer written
- 🧹 **Field descriptions say what a field means now.** The version history left in eight descriptions (`cancellation_policy`, `is_refundable`, `max_nights_bookable`, `max_rooms_per_booking`, `los_prices`, `rooms_length_restricted`, `lead_in_rate` and the `includeLosAnalysis` input) moved out: Apify's dataset screens and AI clients reading the Actor through MCP show these texts

</details>
<details>
<summary><strong>v2.16.4 — September 2026 — Fix: with sold-out tracking off, the market read "every room type available"</strong></summary>

- 🐞 **Switching off Track Sold-Out Rooms made a full property look empty.** Sold-out rooms are then never written, so `market_summary` counted only what was on sale: `sold_out_ratio` 0, `demand_pressure` "normal", and the reason *"every room type available"* — on nights the property was nearly full. With tracking off, `room_types_offered`, `room_types_sold_out`, `sold_out_ratio` and `demand_pressure` are now left empty, and the reason says why
- ℹ️ **The input now states the trade-off.** Every row is a result you pay for, and on busy dates most rows can be sold-out ones (140 of 233 on a live four-motel run). Off saves those rows and gives up the demand read

</details>
<details>
<summary><strong>v2.16.3 — September 2026 — The digest leads with the one line that matters</strong></summary>

- ✅ **👉 One line first.** Under the header, the strongest signal of the run: a night where most competitors are sold out while you still sell, otherwise the night you sit furthest under the market median, otherwise furthest over it
- 🐞 **"Nights to watch" fired on 5 of 9 nights of a live Perth run** — two of them because *your own* hotel was sold out, the rest on "half the room types gone", which motels show for one adult most nights. It now lists only nights where **half or more of your competitors have nothing left to sell**
- 🐞 **The digest was dated by the server.** A Perth digest written at 22:57 UTC read "Tue 22 Sep" on the morning of the 23rd. It now carries the hotel's date

</details>
<details>
<summary><strong>v2.16.2 — September 2026 — Fix: a night that had already passed at the hotel read "sold out"</strong></summary>

- 🐞 **An empty `startDate` meant today on Apify's servers, which run on UTC.** Perth is UTC+8: the 2026-09-22 run at 22:57 UTC was 06:57 on the 23rd there, and the first night it asked for — the 22nd — had already gone. Booking answered the only way it can: Great Eastern with *"No rooms on the page"*, Flag Motor Lodge, ibis and Sanno with **every room sold out**. Those false sell-outs went into `demand_pressure`, the compset and the daily digest. The window now starts on **today at the hotel**, read from the hotel's own longitude before any date is scraped — same number of nights, one hour of margin so a night still on sale is never skipped. `lead_time_days` counts from that date too, so tonight reads 0
- 🔍 The auditor's past-dates check now reads the hotel's clock (`hotel_longitude`) instead of UTC's, so it catches exactly this
- ℹ️ **Digest email:** on a live run Apify refused with *"Your current plan does not support running public Actors"* — the account that runs this Actor also runs Apify's Send Email Actor, and some plans cannot. The log now says that in plain words, and the field's description says so up front. The digest itself was written (Output tab → Daily digest). For email on any plan, put a Make, Zapier or n8n webhook in *Post the daily digest to*

</details>
<details>
<summary><strong>v2.16.1 — September 2026 — Fix: a digest email that was never sent was logged as sent</strong></summary>

- 🐞 **"📬 Digest emailed" appeared even when Apify's Send Email run failed.** `Actor.call` hands back the run whatever became of it, and v2.16.0 did not look at its status. The log now says **NOT sent**, with the Send Email run's status and id, and points to the webhook field as another way to get the digest by email (a Make or Zapier webhook). An address the field cannot use is now said too, instead of being skipped silently

</details>
<details>
<summary><strong>v2.16.0 — September 2026 — The daily digest, and "nights" that were rooms all along</strong></summary>

> The morning read of a run, in one screen — and a correction to three fields that have been misread since v2.5.3.

- ✅ **New: the daily digest.** After every run, a one-screen summary a revenue manager can read in 30 seconds:
  - **Your hotel** (when `myHotelUrl` is set): cheapest or dearest on how many nights, the nights you sit furthest above and below the market median, the nights where most competitors are sold out while you still sell (*room to move*), and the nights you are sold out
  - **Next 7 nights**: the cheapest property and its lead-in, the market median, your rate and rank
  - **Nights to watch**: nights where at least one property has nothing left to sell and most of the market is going with it
  - **Competitor moves since the last run**, one line per property and night: *"▲ Great Eastern · Fri 25 Sep: 5 rooms up 10.47–15.25% (Queen Suite 222 → 249)"*, plus sell-outs and returns
  - **The run's own health**, first when anything failed
- ✅ **Where it goes**: always saved with the run (**Output tab → Daily digest**). Fill in **📬 Email the daily digest to** and it is emailed; fill in **📬 Post the daily digest to** with a Slack or Discord webhook and it arrives as a message — any other URL (Make, Zapier, n8n) gets the full digest as JSON. Every figure comes from this run's own rows; nothing is estimated, and a delivery that fails is a warning in the log, never a failed run
- 🐞 **`max_nights_bookable` counts rooms, not nights.** Booking's quantity dropdown is the proof: a saved page offers *"1 (US$130)", "2 (US$260)" … "5 (US$649)"*, and the page data this field is read from (`b_nr_stays`, `b_stay_prices`) holds exactly those prices. On the 2026-09-22 Flag Motor Lodge run it equalled the dropdown on every room (2/2, 6/6, 8/8, 4/4). The name stays so nothing that reads it breaks; its description, its column label and everything built on it are corrected — and it now backs up `max_rooms_per_booking` when the dropdown cannot be read
- 🐞 **`market_summary.restricted_ratio` said "priced for a single night only".** It is the share of room types **down to their last room**. `demand_pressure_reason` now says so, and the Market by date columns read *Last-room types* / *Last-room share*. `demand_pressure` itself never used it and still does not
- 🐞 **Sold-out rows came in two shapes again** — 86 and 99 columns on the 2026-09-22 Flag Motor Lodge run, found by the auditor. A room missing from a date's page lacked `booking_hotel_id` and twelve other columns, and a night the whole property sold out carried **no weekday and no lead time**, so it fell out of every weekday or booking-window view. Every sold-out row now has every column of a priced row, and a test compares them
- 🐞 **`includeLosAnalysis` was not a length-of-stay analysis** — `los_prices` are Booking's prices for 1, 2, 3… rooms at once. It was also always empty: it read a key the room never had. It now reads the selected rate's own ladder, `has_los_discount` means a multi-room discount, and the input is titled *Include multi-room prices*

</details>
<details>
<summary><strong>v2.15.0 — September 2026 — Cancellation dates, rooms still bookable, and the rate a guest actually sees first</strong></summary>

> Found by putting a live export beside another scraper's output for the same four Perth motels and the same night. The prices matched to the cent where the party and date matched. What was around the price did not.

- 🐞 **`free_cancellation_until` was empty on every row** — 0 of 240 on the 2026-09-20 Perth run. The date was parsed from the whole row, in US order only ("October 7"), while every page loads in British English ("7 October 2026"). Worse, where it did match it could take the wrong date: a cell reading *"Free cancellation before October 7, 2026 • Pay nothing until October 5, 2026"* published **5 October — the payment date**. The date now comes from Booking's cancellation sentence alone, in British, US and French wording, with or without a time
- ✅ **New: `free_cancellation_days_before_arrival`** — the policy as one number. "Free cancellation before 7 October" on a 9 October stay is **2**: Booking's own "cancel for free until 2 days before arrival". The date moves with every check-in; the number compares properties at a glance
- 🐞 **`max_rooms_per_booking` gave every room the page's largest number.** It read every quantity dropdown in the room table instead of the room's own, so Sanno's Standard King showed **10 beside its own "7 left" badge**, and 18 rows of that export offered more rooms than Booking said were left. It now reads the room's own dropdown — the number other tools publish as `number_available_at_this_price`
- ✅ **New: `bookable_now_label`, in Rate shopping V2 as "Bookable now".** Booking's dropdown offers *min(rooms still on sale, the property's per-booking ceiling)*, so the label says only what the page proves: **`4 left`** when the room's dropdown stops below another room's on the same page (or Booking shows its badge), **`10+`** at the page's top — at least that many. On the other scraper's output, one motel's eight rooms read 1, 1, 1, 2, 4, 6, 7 and 10 that night
- 🐞 **The market lead-in was not the cheapest rate a guest could book.** The README promised "each hotel's cheapest bookable rate"; the code used the headline rate, which prefers flexible terms and the exact party size. Sanno led in at **231** (single occupancy, breakfast included) while a **208** room-only rate for up to 2 guests — bookable by the same single traveller — was on the page; ibis led in at 173 over a 155 non-refundable rate. `compset_rank` and every gap were built on those numbers
- ✅ **New: `lowest_bookable_rate` and `lowest_bookable_terms`** on every room. `nightly_total_price` stays the like-for-like rate; this is the lead-in, and `market_summary.lead_in_rate` is now built on it. The `rate_selection_reason` sentence names the cheaper rate when the headline is not the cheapest
- ✅ **The market ranking is on the Output tab.** `compset_rank`, `market_median_rate` and the gap columns live in the `market_summary` dataset, which only Storage showed. The Output tab now links **Market by date** and **Rate shopping V2** directly
- ✅ **New input: Children's ages.** Booking.com prices a family stay by each child's age; the Actor only took a headcount, so a search with children sent a party Booking had to guess. Each age now goes to Booking with the search, and rows carry `requested_children_ages`
- 🔍 **The auditor checks all three** — a refundable rate with no date, a dropdown above its own badge, a lowest rate above the headline rate — on any export, including the ones taken before this release. Run on the 2026-09-20 export, it reports both defects above
- ℹ️ **Unchanged on purpose:** the original Rate shopping view (the default tab), how the headline rate is selected, and `rooms_left`, which is still filled only by Booking's own "Only N left" badge

</details>
<details>
<summary><strong>v2.14.2 — September 2026 — Every room keeps its line, and "your hotel" moved into Price tracking</strong></summary>

- ✅ **The same room sits on the same line every night.** Available rooms used to be written first and sold-out ones after, so a room moved up the list whenever a room above it sold out — on a live run, Great Eastern Motor Lodge's eight rooms came out in **eight different orders across ten dates**. Each date's rooms are now held until the date is complete and written in the property's own room list order, sold-out rooms in their own place. A room Booking.com never listed on the property page goes after the known ones, in the order the page showed it
- ✅ **"Your own hotel's URL" now sits inside 📈 Price tracking between runs**, between the tracking switch and the history store name — one section for setting up the daily job, instead of two. The warning that used to head its own section — *put your hotel AND its competitors in the SAME run* — moved into the field's own description, so it is still in front of you when you fill it in

</details>
<details>
<summary><strong>v2.14.1 — September 2026 — Fix: the Output tab sat at 0 while the run worked</strong></summary>

> If you upgraded to v2.14.0, take this one. A run that shows nothing for fifteen minutes is its own kind of broken, whatever order it eventually writes in.

- 🐞 **v2.14.0 held every row until its hotel had finished all its dates.** The ordering was right and the waiting was not: you could watch the log scrape date after date with the Output tab still reading **0 results**
- ✅ **A hotel now holds rows only while it is *not* its turn.** The first hotel's turn starts immediately, so it streams straight to the dataset and the Output tab fills from its first date. Each later hotel starts streaming the moment the one before it finishes — and whatever it held while waiting goes out first, in order
- ✅ **The order is unchanged**: hotel by hotel as listed in `hotelUrls`, dates ascending, rooms in Booking.com's page order. What changed is that only the hotels still waiting hold anything, which also shrinks what a killed run can lose

</details>
<details>
<summary><strong>v2.14.0 — September 2026 — The dataset reads the same every run</strong></summary>

> Same input, same screen. Every time.

- 🐞 **Two runs of the same input opened on different hotels.** ibis Budget on top one run, Flag Motor Lodge the next — same rows, different screen, every day. Hotels scrape in parallel, so rows reached the dataset in whatever order the work happened to finish. `sort_key` has always fixed it for anyone who sorts, and nobody sorts a screen they expect to already be in order
- ✅ **Rows are now written hotel by hotel, in the order you listed them in `hotelUrls`** — dates ascending, rooms in Booking.com's own page order, the same layout every run. Scraping stays concurrent; only the writing is ordered, and a hotel waiting its turn releases its concurrency slot so another hotel can scrape in it
- ✅ **A failed hotel cannot freeze the rest.** The hand-off is released unconditionally, and a hotel that has waited 15 minutes for the one before it writes out of order rather than holding its rows any longer — a stuck neighbour degrades the layout, never the data
- ℹ️ **The cost, stated plainly:** a hotel's rows live in memory until its turn, so a run killed outright loses what has not been written yet. Rows used to land date by date. In exchange, the export is the same shape every morning

</details>
<details>
<summary><strong>v2.13.10 — September 2026 — A column header that contradicted its own row</strong></summary>

- 🐞 **"Rate (incl. tax)" was not true for most rows.** `nightly_total_price` is Booking.com's displayed price: taxes it folds *into* the price are in that number, taxes it charges *on top* are not. On a live run of four Perth motels, **59% of rows read `⚠️ Additional taxes reported`** — 176 shown, 17.64 GST added at checkout — under a header telling the reader 176 was final. The column beside it exists to say which case you are in; the header must not answer for it. It now reads **"Rate (as shown)"**, and the field's own description says which taxes are inside it
- ℹ️ **There is still no "all-in" column, on purpose.** An excluded charge does not say whether it applies per night, per stay or per person, so adding it to the rate would be a guess — and a guessed total is the one number a revenue manager must not be handed. `taxes_excluded` is published raw for anyone who knows their market's rule. A test now keeps both decisions from drifting

</details>
<details>
<summary><strong>v2.13.9 — September 2026 — The original Rate shopping is back, and the rebuilt one has a name</strong></summary>

> Nothing on an existing job's screen changes. The rebuilt screen is now opt-in.

- ✅ **"Rate shopping" is the original 25-column view again**, column for column, still the default tab — so a saved task, a spreadsheet or a script built on it opens on exactly what it opened on yesterday
- ✅ **The rebuilt screen is "Rate shopping V2"** — 49 columns, the colour marks, the movement columns, `is_my_hotel`, the exact rate. Same dataset, one tab across. `?view=rate_shopping_v2` on the API
- ℹ️ **Restoring the columns did not restore the bugs.** The values in both views are the current ones: the room-night history that no longer resets itself, the availability label that no longer promises a rate it cannot sell, the sold-out rows that all carry the same columns
- ⚠️ **One known trade-off, recorded in a test.** The original view carries `availability_status`, an enum of four real availability states — there is no honest value in it for *"this date was never scraped"*, and widening that enum would change a token scripts filter on. So on the original view a failed row stays as sparse as it always was. Reading **why** a row is empty is one of the reasons to move a job to V2

</details>
<details>
<summary><strong>v2.13.8 — September 2026 — The proxy warning said more than the evidence does</strong></summary>

- 📖 **"Booking.com blocks datacenter IPs within a request or two" was too strong, and it was costing users money.** A real user runs this Actor daily with **no proxy at all** and gets clean results — and the loud banner added in v2.13.1 went further still, blaming a flagged IP for browser crashes that turned out to be [our own request interception](CHANGELOG.md), proven when the same crash happened again on a **verified residential proxy**
- ✅ **What it says now is what can be shown.** Every request leaves from one IP; Booking.com does block datacenter and shared IPs; the risk grows with the number of properties, dates and parallel hotels. A small daily run often works with no proxy and that costs nothing. The sign to switch Residential on is rows coming back **`⚠️ Not scraped — Booking.com served a block page`** — a named, visible signal rather than a fear
- ℹ️ Same wording in the input form, the README and the run log, so none of them can scare a user the others reassure

</details>
<details>
<summary><strong>v2.13.7 — September 2026 — The auditor catches a window that stopped moving</strong></summary>

- ✅ **New rule: check-in dates already in the past.** `startDate` is a fixed date, so a task that runs every day keeps asking for the same window while the window slides behind it. A night that has already happened cannot be sold, and Booking.com answers the only way it can — nothing available. In a compset that is not harmless: those nights inflate `sold_out_ratio`, which is what `demand_pressure` is computed from, so a stale window reads as a market under pressure. Leave `startDate` empty and the window moves with the run

</details>
<details>
<summary><strong>v2.13.6 — September 2026 — "One property only" now reaches the run card</strong></summary>

- ⚠️ **The most expensive mistake this Actor allows now shows on the run itself.** Ranking compares properties *inside a single run*, so scraping one hotel per run throws away every rate a compset would have produced — `compset_rank`, `market_median_rate` and `gap_to_market_median` all come back empty. It was warned about in the log, and a real user was found running **five separate one-hotel tasks**: nobody reads the log of a run whose card says *Succeeded*. The run's status line now ends with **"⚠️ 1 property only — no rate shop"**, `RUN_REPORT.json` explains the fix, and a new `one_property_only` flag lets a task or an alert branch on it without matching on prose
- ℹ️ **Nothing was removed and nothing was renamed.** Every field, in every view, is still there

</details>
<details>
<summary><strong>v2.13.5 — September 2026 — The auditor reads CSV, and a sold-out night stops vanishing</strong></summary>

> First clean full export: **335 rows, 5 properties, 14 dates, 0 broken.**

- ✅ **`audit_export.py` reads the CSV you actually download.** It only spoke JSON, and Apify's default download is CSV — an auditor nobody can point at their file is an auditor nobody runs. It now reads `.csv`, `.tsv`, `.json` and `.jsonl`, turns `""` back into null, `true` into a boolean and `83` into a number, and strips the byte-order mark Apify writes (without which the first column comes back named `\ufeff"hotel_name_clean"` and every rule that reads the hotel name goes quietly blind)
- 🐞 **A sold-out night could disappear from the export entirely.** When Booking.com redirects a date to its sold-out page we *know* the property is full — but with no room list to break the night into, the code wrote nothing at all. One night in a 5×14 grid was simply absent, and a hole reads as "we never looked", which is the opposite of what happened. That night now gets one row: **`⚫ Sold out — no room list`**, with the reason on it. It is marked sold out, not failed, and it stays out of the market summary so a redirect can never invent a room count

</details>
<details>
<summary><strong>v2.13.4 — September 2026 — A tool that reads the export so you do not have to</strong></summary>

> `python3 tools/audit_export.py export.json` — nineteen rules, one second, exit code 1 if anything is broken. Run it after every run before you trust a number.

- ✅ **New: an export auditor.** Every defect in the last three releases was found by reading raw JSON by eye. That does not scale and it does not survive a busy week. Each of those defects is now a rule: one property with two names, two rooms a reader cannot tell apart, a sold-out row carrying a price, an arrow that contradicts its own number, a property that came back with no history while its neighbours kept theirs, a hole in the hotel × date grid, a failure row that does not say what failed. It prints what it found, with examples, and returns a non-zero exit code when anything is broken — so it can gate a release
- 🐞 **Found by it immediately: five rows said `🟢 Available` and carried no rate.** Riad Gallery 49's *Standard Single Room* — a single room, on sale, just never for two people. Green beside an empty rate cell teaches a reader to distrust the column. Those rows now read **`⚪ No rate for 2 guests`**. `availability_status` is unchanged, so anything filtering on it keeps working
- 🐞 **And two rooms Booking itself calls "Double Room".** Ids 47398908 and 47398910, on the same night, at 60 and 85 USD — two real inventory blocks with one name, not a cleaning mistake. Where a property repeats a name, the row carries the Booking id: **`Double Room (#47398910)`**. Only where it is needed
- ✅ **The auditor is tested in both directions.** Every rule is shown a broken export *and* a healthy one, because a rule that cries wolf gets ignored and a rule that stays quiet on a real defect is how the defect reaches a customer

</details>
<details>
<summary><strong>v2.13.3 — September 2026 — A run with no crash, and the four things it showed</strong></summary>

> The first clean run since the driver fix. Reading its 50 rows found four defects, and every one of them came from the data, not from a guess.

- 🐞 **A property could lose its entire price history because one unrelated read failed.** Riad Dwiriyat kept its review score and every row carried its movement. Riad Gallery 49 and Riad Kech One came back with an empty review score and **every single row read `✦ New`** — two of three properties compared against nothing. The room-night key was *Booking's hotel id, or the URL slug if the id was not read* — so the key silently changed shape whenever the metadata read missed, and nothing matched. It is keyed on the slug now, which comes from the URL you give us and cannot fail. **Your existing history is carried over**, because a Booking room id identifies a room-night on its own; only rooms Booking never gave an id to restart, once
- 🐞 **One property arrived under two names in one export.** `riad-kech-one-amp-spa` on the row that failed, `Riad Kech One & Spa` on the rows that worked — the same hotel, sorted to two different places. Rows that carry no data are now held until the property's name is known, which on any run with one good date is always; if a property never gets that far, the slug is turned into a name a person can read. Five places used to build these rows by hand, and they had drifted: two never set the property's clean name at all, two never said which kind of failure it was
- 🐞 **Three different rooms were all called "Double Room".** Ids 47398908, 47398909 and 47398910, at 64, 91 and 112 USD, in one rate table. The name cleaner drops the bed description, which was the only thing telling them apart. Where shortening a name would make it ambiguous inside a property, the full name is kept now — and it is resolved before any of that date's rows are written, so a date can never use two rules
- 🐞 **Sold-out rows came in two shapes**, 39 columns and 49, depending on whether the room was on the page with no rates or missing from it entirely. `row["is_refundable"]` answered on one and raised on the other. One shape now
- 🐞 **…and three of them read `⚫ Sold out` beside `✦ New`.** A room seen for the first time gets "new" whether or not it had a price. Sold out is the whole story of that row

</details>
<details>
<summary><strong>v2.13.2 — September 2026 — The crash was ours, and it was in the image blocker</strong></summary>

> 🔴 **If runs keep dying partway through with `Connection closed while reading from the driver`, this is the fix.** The proxy was never the cause.

- 🐞 **Two runs died on the same line of Playwright's own code**, both in `_CRSession._onMessage` → `assert()`: `Assertion error` (29 of 33 pages lost) and `Invalid InterceptionId` (63 of 70 pages lost). The second run had a **verified residential proxy**, which is how we know the proxy had nothing to do with it
- 🐞 **The cause was our own resource blocker.** Blocking images and trackers used `page.route(..., abort)` — request *interception*. Chromium pauses every matching request, hands it to Playwright's Node process, and waits for a verdict; each pause carries an id. When a navigation times out or is cancelled mid-flight, those ids are released while verdicts for them are still in the pipe — and the driver's answer to a verdict for an id it no longer knows is `assert()`, which ends the Node process. That is not an error any `try` can catch: process gone, browser gone, all three parallel hotels gone with it
- ✅ **Chromium does the blocking now** (`Network.setBlockedURLs`). The same images, fonts and trackers are dropped, so the saving on your proxy bill is unchanged — but it happens inside the browser, with no pause, no round trip and nothing left to assert on. A test drives a real browser at a real page and checks that the image and the tracker never leave it, and that the JavaScript does
- ✅ **A property that loses its browser now starts its own and carries on.** One driver serves every parallel hotel, so a single assert used to end the whole group. Now the property opens a private browser, retries the date it lost, and finishes its range. Once per property — a second death is not bad luck
- ✅ **`browser_restarts` in `RUN_REPORT.json`**, so a run that recovered on its own says so plainly
- 📖 **The advice printed on these rows is corrected.** It told you to keep the proxy on and lower *Parallel Hotels*. The proxy half was wrong, and it is gone

</details>
<details>
<summary><strong>v2.13.1 — September 2026 — A run came back half empty and could not say why</strong></summary>

> 🔴 **If a run returned rows carrying a hotel name, a date and nothing else, this is the fix.** Upgrade and re-run — and keep the proxy on.

- 🐞 **Half the rows of a live run were blank, and nothing on screen said why.** 48 rows: 24 real, 24 with a hotel and a date and no data at all. They were failure rows — the run had lost those dates — but `status` and `error_message` are not columns in the Rate shopping view, so they arrived as empty lines. **Every failure row now says what happened in the Availability column**: `⚠️ Not scraped — our browser died mid-run`. And the Full detail view gained **Row status**, **Failure type** and **What failed**, which it never had
- 🐞 **Our own browser died, and the run blamed Booking.com.** Playwright's word for a dead renderer is *"Target crashed"*; the crash check was looking for *"Page crashed"*. The failure was filed as `error_type: page` — Booking.com's fault — when Booking.com had answered perfectly well
- 🐞 **Then the run kept asking a dead process for pages.** The crash took down the Node process behind the browser, so every call after it failed the same way — including the rebuild-and-retry that exists to recover from a crash. The run spent **two and a half minutes retrying 24 dates that could not be retried**. A dead browser is now recognised for what it is: the property stops there, and the dates it never reached are written out at once and labelled
- 🐞 **Two of the five properties never ran, and the export never mentioned them.** A property that dies before its first page left no trace at all, so the compset quietly shrank from five to three and the medians were taken from whoever was left. Every date a property owes the export is now named, even when the property never opened
- 🐞 **The run summary said "No pages were attempted". 33 had been.** Closing a dead browser throws, and that error escaped on the way out, taking the cohort's whole tally with it — then sent the reader off to check hotel URLs that were never the problem. Cleanup can no longer un-report finished work, and a **`crashed`** verdict now says what actually happened
- ✅ **Browser failures are counted apart from proxy and page failures.** `failures_browser` in `RUN_REPORT.json`, and in the summary line: `29 failed (0 proxy, 5 page, 24 browser)`. Three different problems, three different fixes
- ⚠️ **Running without a proxy is now a warning you cannot scroll past.** The run behind this release had the proxy switched off. Booking.com serves a datacenter IP its heaviest anti-bot pages, and that is what ran the browser out of memory. If it happens to you: keep **Proxy configuration** on **Residential**, and lower **Parallel Hotels** — each one is a whole browser in the same container

</details>

<details>
<summary><strong>v2.13.0 — September 2026 — `rooms_left` settled, with the page as evidence</strong></summary>

> Third time on this field, and the last. It is no longer read from prose at all.

- 🐞 **Booking.com's badge does not say "room".** A saved property page shows `🔴 Il nous reste 1 option` — *option*, not *chambre*. That is why the original loose regex appeared to work, and why tightening it to demand the word *room* (v2.12.0) made `rooms_left` null on every row of a live run. Both versions were guessing at wording
- ✅ **The number is now read from Booking's own element.** The scarcity icon's class encodes it — `-scarcity_indicator-pss_scarcity_1_left` — so wording, language and Booking's A/B tests all stop mattering
- ✅ **And the quantity dropdown is not inventory.** It offered `0-1` on the riad and `0-5` on every room of a motel, stocked or not. It is a ceiling, so it gets its own honest field, **`max_rooms_per_booking`**, and can never become `rooms_left`
- 🔒 **Both pages are committed as test fixtures.** One with a badge, one without. The suite now asserts the riad reports `1 / exact / 🔴 Only 1 room left` and the motel reports `null / hidden / 🟢 Available` — so neither direction of this mistake can return

</details>

<details>
<summary><strong>v2.12.2 — September 2026 — Room identity verified, and the scarcity badge caught again</strong></summary>

- ✅ **The room-identity fix is confirmed on live data.** Room `47398912` now reads *"Standard Single Room"* on every date it appears, where it flipped to *"Double or Twin Room"* on one date before. `became_sold_out` fires with a real previous price behind it
- 🐞 **…but tightening the scarcity patterns went one step too far.** Requiring the word *room* missed Booking.com's most common badge, which does not use it: **"Only 1 left on our site!"**. `rooms_left` came back null on every row of a live run. The patterns now also accept that wording — and stay safe because the discriminator is **"on our site"**, which the scarcity badge says and the rate dropdown (*"3 options"*) never does. Still no bare-number pattern anywhere
- ℹ️ **Some rooms restart their history once after this upgrade.** Their old entries were stored under ids the previous build had mis-assigned, so those keys no longer match. One run of `✦ New`, then normal

</details>

<details>
<summary><strong>v2.12.1 — September 2026 — Four defects a live run of the new build exposed</strong></summary>

- 🐞 **Room ids were paired to room names by list position.** The registry read names from the room-type anchors and ids from a separate `[data-room-id]` sweep, then zipped them with `ids[rooms.length]`. Nothing keeps two DOM scans in the same order, and they were not: room `47398912` came back as *"Standard Single Room"* on three dates and *"Double or Twin Room"* on the fourth. **That id is part of the price-history key**, so a shifted pairing compares one room against a different one and invents availability transitions. Each id is now read from the href of the anchor carrying the name, and a name with no id of its own gets `null` — a wrong id is worse than none
- 🐞 **A room was published as "Double Room with".** *"Double Room with Two Twin Beds"* matched the bed-phrase pattern, which removed the beds and left the preposition standing. Names never end mid-phrase now
- 🐞 **`is_my_hotel` read `false` on every row when `myHotelUrl` was simply left empty.** "This is not your hotel" and "you never said which one is" are different answers; the second one is `null`
- 🐞 **Sold-out rows carried no weekday and no lead time**, so the rows where the demand signal actually lives could not be segmented at all. And `photo_count` still fell back to a false `0` in four room builders

</details>

<details>
<summary><strong>v2.12.0 — September 2026 — Which hotel is yours, and a stable row order</strong></summary>

- ✅ **New input `myHotelUrl`, new field `is_my_hotel`.** Paste your own property's URL (and keep it in `hotelUrls` too) and every row of yours is flagged. Sort your rates to the top, and tell an AI *"my hotel is the row where `is_my_hotel` is true"* instead of letting it match a name it can get wrong. Matching ignores query strings and language suffixes, so any form of the URL works
- ✅ **New `sort_key` on every row** — hotel → stay date → room → room id. Rows reach the dataset in the order the scrape finishes them and hotels run in parallel, so a room that was row 1 yesterday can be row 4 today. Sorting on this one column lines up any two exports. **Nothing the Actor computes was ever affected** — every comparison matches on identity, never on position — and the movement columns exist precisely so you no longer have to diff two exports by hand
- ⚠️ **The run now warns when you scrape one property at a time.** Ranking compares properties *inside a single run*, so running each competitor separately leaves `compset_rank`, `market_median_rate` and every gap column empty — prices with nothing to compare them to. The log says so instead of leaving you to notice the blank columns

</details>

<details>
<summary><strong>v2.11.0 — September 2026 — Coverage, crash recovery, and an AI handoff</strong></summary>

- 🐞 **A missing competitor no longer reads as a moving market.** Seven dates of one property died on a browser crash — and that property was the cheapest of five, so its absence pushed the median *up* with nothing anywhere saying a property was missing. Every `market_summary` row now carries **`compset_expected`**, **`compset_observed`**, **`compset_coverage`** and a plain **`coverage_warning`** naming how many properties are missing from that date's figures
- 🐞 **A crashed browser is recovered, not inherited.** A dead renderer poisons its whole context, so every later date of that hotel failed the same way — seven in a row. The context is now rebuilt and the date retried once, and the row says `error_type: "browser"` instead of blaming Booking.com for a browser that died on our side
- 🐞 **`demand_pressure` no longer announces demand off an unverified field.** It used to read "high" on `restricted_ratio` alone — and `max_nights_bookable` was `1` on **69% of live rows**, so nearly every date would have claimed high demand. The restriction count is still published and still named in the reason; it just cannot set the level. Only rooms Booking.com says it cannot sell move the needle now
- ✅ **New `ai_input` view — 37 columns, about a quarter of the tokens of a full export.** The columns a pricing question needs, with the error rows kept *on purpose*: a model that cannot see which hotel-dates failed will read a missing competitor as a market that moved. The README now carries the full prompt to paste with it
- ✅ **A guard that sweeps every enum in the schema.** The v2.9.5 outage was an enum the code had outgrown, and the guard written for it only saw rows the test fixture could produce. This one reads the literals out of the source for every enum field, and found two more gaps the moment it ran: `error_type: "crash"` and three undeclared `record_type` values

</details>

<details>
<summary><strong>v2.10.0 — September 2026 — Five things a live-data audit proved wrong</strong></summary>

> Three days of real runs across five properties, then a line-by-line audit of the export. Every item below was verified on the data, not guessed.

- 🐞 **`rooms_left` was never a room count — again.** It equalled the number of priced stay *lengths* in **193 of 193 rows**: a perfect diagonal, 1↔1 through 5↔5. A pattern was matching Booking's rate dropdown (`3 options`) and then stamping `stock_confidence: exact` on it. Rooms bookable for one night only were being published as **🔴 Only 1 room left** — 189 of 336 rows were red for a reason that had nothing to do with inventory. Every scarcity pattern must now name a *room*, and four tests read them out of the source so this cannot return a fourth time
- 🐞 **`✅ Taxes included` was wrong on every row that carried it.** All 27 also had a positive `taxes_excluded` — VAT inside the price, city tax on top. There is a fourth state now: **`⚠️ Part included, more on top`**. Only `✅` means final
- 🐞 **Sold-out rooms were not tracked at all.** Rooms that vanish from the page (rather than showing a badge) are emitted from the room registry, and that path wrote no movement fields and **nothing to the history** — so the stored state still said "on sale", and a return read as an ordinary price move. `became_sold_out` and `came_back` appeared **zero times in three runs** on a compset where 40% of rows were sold out. Both paths now record the transition
- 🐞 **Error rows had a blank Hotel cell.** Seven dates of one property failed with a browser crash and rendered as seven nameless rows
- ✅ **New `nightly_total_price_exact`.** Booking prints `US$103`; its own data says `102.80`. The two differed on **173 of 173 rows**. The displayed figure stays the headline — it is what a guest is quoted — with the exact one beside it
- ✅ **Two fields now say what they actually are.** `max_guests` is labelled **Rate fits up to**: a 2-adult search matched a rate sold for 3 or 4 guests on 44 of 173 rows, so it never proved the quote was priced for two. And `max_nights_bookable` is marked unverified — every multi-night `stay_prices` value was exactly the one-night price × the nights, which is extrapolation, not a quote

</details>

<details>
<summary><strong>v2.9.6 — September 2026 — Fix: every row was being rejected by the dataset schema (again)</strong></summary>

> 🔴 **If a run reported `Unexpected: Schema validation failed` on every date, this is the fix.** Upgrade and re-run; no input change is needed.

- 🐞 **Fixed: `tax_clarity_label` gained a ✅ / ⚠️ mark in v2.9.5 and the schema still listed the unmarked values.** The field declares an `enum`, so Apify rejected every room row and every date came back as an error

- ✅ **The guard that should have caught it has been replaced.** The check written after the *first* schema outage compared each value's `type` and nothing else — hand-rolled, and blind to `enum`, `pattern`, `minimum` and every other rule Apify actually enforces. The tests now hand each pushed row to a real JSON Schema validator, the same way the platform does

- ✅ **And it now runs the second run too.** Every movement column is null on a first run by design, so a first-run test never exercised one of them with a real value. The harness replays the page with price history in place, marks yesterday down 10%, and validates the rows that come back — a move, a stability clock and a baseline, all checked

- ✅ **Price tracking has its own section on the input form, and it is the first one.** It was buried under *Performance*, between the length-of-stay toggle and the wholesale-rates one — a headline feature filed under speed settings. It now sits directly beneath *Track Sold-Out Rooms*, ahead of Search Configuration, with a heading that states the one thing that matters: run #1 has nothing to compare against, so save the input as a task and schedule it daily

- ✅ **`Price history store name` says it is optional**, because nobody could tell. Leave it empty and it fills itself in; it exists only for running two jobs whose histories must not mix

- ✅ **Every toggle now says what you get, after saying what it does.** The descriptions explained their mechanism and stopped there, leaving the reader to work out why they would want it. The mechanism stays — it is the part you need to operate the thing — and the answer follows it: a sold-out competitor room is the date filling up before anyone reprices; a wholesale rate is what your guest actually compares you against on that channel

</details>

<details>
<summary><strong>v2.9.5 — September 2026 — Documenting the movement columns, and a tax flag you cannot miss</strong></summary>

- ✅ **`Tax basis` moved out of the glance zone, into the tax block.** It was pinned beside the rate so a price could never be read as final when it is not — but it reads "Taxes included" on run after run, and a constant column is the one thing the glance zone cannot afford

- ✅ **…and the safety no longer depends on its position.** `tax_clarity_label` now carries a mark: **✅ Taxes included**, **⚠️ Additional taxes reported**, **⚠️ Final cost not confirmed**. A warning sign is visible from the far side of a wide table where a word is not. *The wording after the mark is unchanged*, so anything matching on the text (`"Additional taxes reported" in label`) still matches; `price_basis` remains the unmarked machine token

- 📖 **The movement columns are documented properly** — a new README section says, for each of **Move**, **Δ Rate**, **Δ %**, **Held (d)**, **Avail. move** and **Rate spread**: what it is, what it protects you from, and whether it needs a previous run at all (`rate_spread` does not — it works on run #1)

- 📖 **"It only works on a schedule" is now impossible to miss** — it is in the hero, it is step 4 of the Quick start, and the section explains *why* run #1 says `✦ New` and why the schedule must be **daily and at the same hour**

- 📖 **Documented what starts a fresh history** — changing occupancy, currency or proxy country changes the identity of the quote, so the comparison restarts rather than inventing a move out of a changed input

- 📖 **README cleaned up.** The body had drifted into engineering notes — version numbers in the middle of a field description, paragraphs explaining why a column sits where it sits, a views table cell that had grown into a design memo. It reads as a product page again; the history stays down here in the changelog, which is what it is for

</details>

<details>
<summary><strong>v2.9.4 — September 2026 — Rate shopping trimmed to what gets read</strong></summary>

> Column layout only. No field left a record: everything below is still on every row, in **Full detail**, in the CSV/Excel exports and at the API endpoint.

- 🗑 **The room photo is out of the default view.** It led the row for exactly one version. A thumbnail says who is *browsing*; this screen is for someone pricing against a compset they already know. `room_photo_url` stays on every record
- 🗑 **`Rooms left` loses its column** — `availability_label` already prints the number inline (**🔴 Only 2 rooms left**) when Booking states one, so the column was a second copy of a fact that was either already on screen or absent
- 🗑 **`Status (raw)` and `Sold out` dropped from the view** — `availability_label` says both, in words
- ✅ **`Max nights` moved up, beside `Guests`** and ahead of the cancellation block: how many guests and how many nights a rate will take decide whether it is comparable at all, so they are read before its terms
- ✅ **`Market` and `Cur.` now sit directly after `Net rate` / `Taxes & fees`** — a rate is only comparable inside its own market and currency, so the cohort travels with the price breakdown instead of sitting at the far end of the row

</details>

<details>
<summary><strong>v2.9.3 — September 2026 — A screen you can read at a glance</strong></summary>

> Two machine tokens, a fabricated-looking blank and a timestamp that was never a timestamp. All four were costing reading time on the one screen this Actor exists for.

- ✅ **Availability is a colour now.** `low_availability_urgent` is a token a script filters on — four of them cannot be told apart at a glance in a table of 1,800 rows. `availability_label` shows the same fact as **🟢 Available**, **🟠 Low availability**, **🔴 Only 2 rooms left**, **⚫ Sold out**, and folds in the room count *when Booking.com actually states one* (never a made-up number). `availability_status` is still on the row, in the tail, so nothing scripted breaks
- ✅ **Price movement is an arrow.** `price_move_label` reads **▲ +8 (+5.56%)**, **▼ -14 (-9.21%)**, **– Unchanged**, **✦ New**. Null for a sold-out room, which has no price move. `price_direction` stays in the tail for the same reason
- ✅ **`rooms_left` is back beside availability**, where it is read when Booking does state a count — it was exiled to the tail in v2.5.3 because it is blank on most rows, and the inline count in the label means that blank is no longer the only signal
- ✅ **Every row now carries its room photo** (`room_photo_url`), rendered inline in the table. "Superior Queen" at six properties is six different rooms. The URL comes out of the page's own embedded state — **no image is ever downloaded**, so the run does not get slower — and it is published at the size Booking.com itself serves, never a rewritten one that could 404
- ✅ **`photo_count` is a real number.** It reported `0` on every row until v2.5.3 and `null` ever since, because images are blocked and there is nothing in the DOM to count. The same embedded state lists every room photo, so the count is now a fact
- ✅ **Check-in *and* check-out in the glance zone**, replacing `Stay date` and `Cur.` — the currency is one value for the whole run and moved to the tail, while the two dates are what you scroll looking for
- 🐞 **Fixed: date columns rendering as `22:00:00 UTC`.** Apify's `date` format turns a plain `2026-10-05` into a full instant in a timezone the value never had — unreadable, and off by a day at the edges. Date-only columns are rendered verbatim now; only `scrape_timestamp`, which really is an instant, keeps the format
- 🗑 **`Prev. capture` dropped from the Rate shopping view** — a bare date next to `Gap (d)`, which already says the same thing as a number you can sort. Still on every record and in Full detail

</details>

<details>
<summary><strong>v2.9.2 — September 2026 — One screen, built for rate shopping only</strong></summary>

> No field was removed from a single record. This is about what the default screen shows first, and about the Actor doing one job instead of three.

- ✅ **The comparable price now leads the Rate shopping view.** `nightly_base_price` (Net rate) and `taxes_and_fees_total` (Taxes & fees) used to sit *in front of* the rate so the price read the way it is quoted — net → tax → total. In practice nobody re-adds the sum: the total already contains both, and it is the number the decision is made on. The breakdown moved right, next to `primary_tax_label` and `total_stay_price`, and reads in the same order where it sits
- ✅ **The two freed columns went to something the total cannot say** — `days_since_price_change` (**Held**: has this competitor moved in the last twelve days, or are they parked?) and `rate_spread` (how wide their rate ladder is on this date — the depth of their fencing). Both were already computed and already in the view; they were just too far right to be read
- 🗑 **Removed the Room comparison and AI room picker views.** This Actor sells to revenue managers. A traveller-facing screen and an agent-facing screen were two projections aimed at people who are not the customer, and they made the Output tab a menu instead of an answer. **Rate shopping** and **Full detail** remain; every field they carried is still on every record, still in Full detail, still in the CSV/Excel exports and the plain API endpoint
- ✅ **The low-token projection for automations is now `?fields=`** rather than a fixed view — same result, any column set you want, documented under *Handing the rate feed to an LLM*

</details>

<details>
<summary><strong>v2.9.1 — September 2026 — Fix: every row was being rejected by the dataset schema</strong></summary>

> 🔴 **If a run reported `Unexpected: Schema validation failed` on every date, this is the fix.** Upgrade and re-run; no input change is needed.

- 🐞 **Fixed: `photo_count` was declared `integer` but sent as `null`.** v2.5.3 correctly stopped reporting a false `0` for a count that images-blocked runs can never take — but the dataset schema was not updated with it, so Apify rejected **every room row** and every date came back as an error. It is `["integer", "null"]` now, which is what the value has meant since v2.5.3
- 🐞 **Fixed the same latent bug in `hotel_sustainability_certified`** — it is read from the property metadata, which is an empty object whenever the metadata visit fails, so any such run would have hit the identical wall
- ✅ **New guard so this cannot ship again.** `tests/test_rows_match_the_schema.py` validates rows against the declared schema two ways: statically at every push site (covering sold-out rows), and by running the real pipeline against the saved fixture page and checking every field of every row it produces. Reverting either fix now fails three tests locally instead of failing a paid run. `tools/row_harness.py` exposes the same path for manual inspection

</details>

<details>
<summary><strong>v2.9.0 — September 2026 — Price movement between runs</strong></summary>

> The question a rate shop exists to answer — *who moved, and by how much?* — could not be answered at all: every run was a snapshot with no memory of the last one.

- ✅ **Movement on every room row** — `price_direction` (up / down / unchanged / new), `price_change`, `price_change_pct`, `previous_price`, `days_since_previous_capture`, `days_since_price_change`, `first_seen_price` / `first_seen_at`, and `availability_change` (`became_sold_out` / `came_back`). They sit immediately beside the rate in the Rate shopping view
- ✅ **No extra requests.** The last price of each room-night is kept in a small named key-value store between runs, rather than downloading and paginating the previous run's dataset — which would be slow and would break whenever a run failed halfway
- ✅ **New inputs** `trackPriceChanges` (default on) and `historyStoreName`
- ⚠️ **It needs a schedule.** The first run has nothing to compare against and every row reads `new`. Save your input as a task and run it daily
- ✅ Honest by construction: a first sighting gives `null` deltas and never `0`; the age of the baseline is published so a stale comparison cannot read as "since yesterday"; a sold-out room reports an availability change and **no** price change, and its last known price is kept so it can be compared again when it comes back on sale
- ✅ Old entries are pruned once the stay date has passed or they have gone unseen for 30 days

</details>

<details>
<summary><strong>v2.8.1 — September 2026 — Rate shopping column order, from how the job is actually done</strong></summary>

- ✅ **The row opens with hotel → room → availability**, then the price laid out the way a rate is quoted: **net rate → taxes & fees → rate (incl. tax)**, contiguous and in that order, so the number can be checked at a glance instead of reassembled from three separate places
- ✅ **Stay date, day and lead time follow the price**, then guests and `is_genius_rate`
- ✅ **`hotel_star_rating` and `hotel_review_score` moved to the tail.** The person running a rate shop has already chosen their compset and knows those properties — the columns are reference for whoever receives the export, not part of the decision, and they were pushing the rate to the right
- ✅ `tax_clarity_label` still sits immediately right of the rate, and `availability_status` before every price column — both pinned by tests, so a future reorder cannot quietly break them

</details>

<details>
<summary><strong>v2.8.0 — September 2026 — The three things a rate shop could not see</strong></summary>

- 🐞 **New `is_genius_rate` — this one changes decisions.** Whether the reported rate is a Booking.com **Genius (member) rate** existed only nested inside `primary_rate_option`, where a flat table cannot read it. A revenue manager comparing their public rate against a competitor's Genius rate "matches" a price that was never public. It is now a top-level column, on the Rate shopping screen
- ✅ **A quality anchor next to the hotel** — `hotel_star_rating` and `hotel_review_score` are now columns 2 and 3 of Rate shopping. Comparing rates without knowing whether the competitor is a 3-star or your 4-star is how a compset produces a bad match
- ✅ **`max_guests` on the Rate shopping screen** — a 2-guest rate and a 3-guest rate are not comparable, and the column that said so (`price_basis`) was a machine string
- ✅ The glance zone is now **hotel · ★ · score · stay date · day · lead time · room · guests · availability · rate · tax basis · Genius rate** — every question a rate shopper asks before acting, with no horizontal scrolling

</details>

<details>
<summary><strong>v2.7.0 — September 2026 — The rate shop, and a Rate shopping view you can actually read</strong></summary>

- ✅ **Compset ranking in `market_summary`** — with several properties in one run, every property-night now carries `compset_rank` / `compset_size`, `price_position` (cheapest → most\_expensive), `gap_to_market_median`, `gap_to_cheapest`, `market_median_rate` and `market_lead_in_rate`. Ranking is on lead-in rate, same night, same occupancy. A sold-out property takes no rank and does not move the median — no price is not a high price. With one property in the run every comparison field is `null`, because rank 1 of 1 is not information
- ⚠️ **`market_summary` is now written when the cohort finishes**, not per date, because ranking a property against a half-scraped compset would be wrong. Room rows are unaffected — they are still written as each date completes
- ✅ **Rate shopping view reordered.** The rate used to be the 16th column: you scrolled past four availability columns and the *net* rate before meeting the number you compare on. Now the first nine columns are hotel · stay date · day · lead time · room · availability · **rate** · currency · tax basis. `availability_status` sits immediately before the rate so a blank price is explained by the cell on its left — one column for availability instead of four, with `rooms_left` / `stock_confidence` / `is_sold_out` moved right (mostly empty since v2.5.3) and the machine-readable `price_basis` dropped in favour of `tax_clarity_label`, which says the same thing in words
- ✅ **Labels now use revenue-management vocabulary** — "Rate (incl. tax)", "Net rate", "Tax basis", "Flex cost", "Lead time (d)", "Why this rate", "Market" — instead of field names
- ✅ **Room comparison reordered** on the same principle: availability → rate → terms → the room attributes a guest actually chooses on

</details>

<details>
<summary><strong>v2.6.0 — September 2026 — Signals a revenue manager can act on</strong></summary>

> v2.5.3 removed a fake scarcity number. This puts real demand signal in its place — all of it derived from what the page already states, with no extra requests and no history required.

- ✅ **New `market_summary` dataset** — one row per property per night, in its own storage (Storage → Datasets → `market_summary`). Carries `sold_out_ratio` (share of the property's room types it cannot sell that night), `restricted_ratio` (share of available rooms Booking.com prices for a single night only), `lead_in_rate` / `median_rate` / `highest_rate` / `rate_range`, and `refundable_premium_median`
- ✅ **`demand_pressure`** — `sold_out` / `high` / `building` / `normal`, from fixed rules over those two ratios, always accompanied by **`demand_pressure_reason`** stating the counts behind the label ("2 of 5 room types unavailable"). Unlike the old urgency tier, every input is something the page states
- ✅ **`lead_time_days`, `day_of_week`, `is_weekend`** on every room row — the booking window, which is the first thing any rate analysis segments on, and which previously had to be derived from two string columns in a spreadsheet
- ✅ **`refundable_premium`** per room — cheapest refundable minus cheapest non-refundable rate: what the property charges for flexibility, comparable across the compset and directly actionable
- ✅ **`rate_spread`** per room — how widely a property fences its rate plans on one night
- ✅ Views updated: Rate shopping 28 columns, Room comparison 30, AI room picker 53, Full detail 76

</details>

<details>
<summary><strong>v2.5.3 — September 2026 — `rooms_left` was never a room count</strong></summary>

> 🔴 **Read this if you filter or alert on `rooms_left`, `availability_status` or `stock_confidence`.** They were derived from the wrong field, and the values in datasets produced before this version are not room counts.

- 🐞 **Fixed: `rooms_left` was Booking's stay-length counter, not inventory.** The extractor read `b_nr_stays` — which is the number of stay *lengths* Booking prices for that room (exactly `len(b_stay_prices)`), not rooms remaining. A live 3-day run made it unmistakable: a room priced for {1, 2} nights reported "2 rooms left", one priced for {1} night reported "1 room left" **and was flagged `low_availability_urgent`**, and rooms priced for {1…5} all reported "5 rooms left" — 11 of 11 rows matching the stay-price count exactly, never exceeding 5. So the Actor's entire scarcity signal was a length-of-stay number wearing an inventory label. `rooms_left` now comes **only** from Booking's visible scarcity badge ("Only 2 rooms left on our site!"), and is `null` when no badge is shown
- ✅ **New `max_nights_bookable`** — the same underlying number, published under its real meaning: the longest stay Booking.com prices for that room from that date. A `1` means only a one-night stay is quoted, which is a genuine length-of-stay restriction signal and the closest thing this Actor can offer to `min_stay`
- ⚠️ **Expect far fewer `low_availability*` rows.** Most properties show no scarcity badge, so most rows now read `rooms_left: null`, `stock_confidence: "hidden"`, `availability_status: "available"`. That is the honest answer: unknown, not plentiful. The rows that still flag urgency are backed by a badge Booking actually rendered
- 🐞 **Fixed: bilingual amenity duplicates.** `'balcon'` is a prefix of `'balcony'`, and both sat in one flat keyword list, so an English "Balcony" produced `["balcony", "balcon"]` — the same feature twice in the same row. Keywords now map to canonical names before deduplication
- 🐞 **Fixed: the "More" button was scraped as a room facility.** It sits inside the same container as the facility chips, so it appeared in `facilities` on 4 of 5 rooms in a live run. Interface labels are filtered out
- 🐞 **Fixed: `photo_count` reported `0` on every row.** Images are blocked for speed, so a room photo count cannot be counted at all — `0` asserts "this room has no photos", which is never true. It is `null` now (the hotel-level photo count in the `hotel_metadata` dataset is unaffected)
- ✅ **Sold-out rows carry the same columns as priced rows** — `tax_clarity_label`, `primary_tax_label`, `rate_selection_reason`, `booking_terms_summary` and `max_nights_bookable` are now present and explicitly `null` instead of being omitted, so CSV and Excel exports stop shifting columns between rows

</details>

<details>
<summary><strong>v2.5.2 — September 2026 — Every run explains itself</strong></summary>

> A failing run used to cost more than the run: a red banner, a cryptic message, and no way for the person running it to tell whether the fault was theirs, ours, or Booking.com's. That is a reporting failure as much as a scraping one.

- ✅ **New `RUN_REPORT.json` in every run's key-value store** — verdict (`complete` / `partial` / `failed` / `nothing_attempted`), a one-line headline, a concrete `what_to_do`, totals split into **proxy vs. page** failures, and a per-hotel trail naming every date that failed and why. Written for every outcome, and written **before** the run can fail, so a red run still carries its own explanation
- ✅ **The headline is set as the run's status message**, so it reads on the run card without opening anything
- ⚠️ **A partial run no longer reads like a total loss.** The first version of the headline led with the failure cause, so a run where 5 of 7 pages returned real data was announced as "2 of 2 failure(s) were PROXY failures". The verdict leads now; the cause moved into `what_to_do`
- 🐞 **Fixed: failed pages were named after the browser's error screen.** A failed navigation still renders a document with a `<title>`, and the metadata extractor read it — so error rows and reports carried `hotel_name: "This page isn't working"` (or Booking's own "unable to handle this request" interstitial). A page that didn't load has no name; the URL slug is used instead
- 🐞 **Fixed: `ERR_PROXY_AUTH_UNSUPPORTED` was not recognised as a proxy failure** — found by driving a real browser at a socket that refuses CONNECT the way Apify does. Rejected proxy credentials were being filed as a page error and retried on the same dead IP. `ERR_HTTP_RESPONSE_CODE_FAILURE` is handled too, but only when a proxy is actually in the path, since a site can produce it on its own

</details>

<details>
<summary><strong>v2.5.1 — September 2026 — Proxy failures stop eating whole runs, and rate data stops crossing blocks</strong></summary>

> Triggered by a real run: one Australian hotel, 7 dates, **7 error rows**, all of them `Page.goto: net::ERR_TUNNEL_CONNECTION_FAILED`, and a summary that blamed "proxy blocked/disabled, or a dead hotel URL". The browser had never reached Booking.com — and every retry re-dialled the same dead exit IP.

#### Proxy resilience

- 🐞 **Fixed: "rotating the proxy" reused the dead IP.** An Apify sticky session pins one exit IP, and the retry path asked for a new URL on the *same* session id — so the log line "Rotated to fresh proxy IP" was untrue and the retry failed identically. Every rotation now takes a brand-new session id
- 🐞 **Fixed: a dead tunnel burned the retry budget instead of changing IP.** `ERR_TUNNEL_CONNECTION_FAILED` and friends are connection-level failures that cannot heal on the same IP; they were retried twice with exponential backoff (~30s per date, 3 identical failures). They are now recognised as proxy errors, handed straight back to the caller, and the date is retried on up to 2 genuinely new exit IPs before any error row is written
- ✅ **The proxy is now verified with a real request before the browser launches.** `create_proxy_configuration()` succeeding only proves Apify accepted the *configuration* — that gap is how a run logged "✅ Apify proxy ready" and then failed every single page. One HTTPS request through the tunnel now confirms it works and logs the exit IP, and the fallback ladder (requested group + country → requested group → `DATACENTER` + country → `DATACENTER`) degrades on **verified** failure
- ✅ **The cause is named instead of the symptom.** Chromium reports every proxy problem as the same opaque string; the probe surfaces Apify's own answer, so the log now says "no IP available for RESIDENTIAL / AU — plan, quota or empty pool" or "credentials rejected" rather than leaving you guessing
- 🐞 **Fixed: the native proxy widget silently got no proxy.** The cohort runner gated on the legacy `useApifyProxy` boolean while the startup banner read the new `proxyConfiguration` widget — a user who picked their proxy in the standard Apify editor saw "Proxy: RESIDENTIAL" in the log and ran with no proxy at all
- ✅ **New `error_type` field on error rows** — `proxy` (the request never reached Booking.com; the URL and dates are not at fault) vs `page` (Booking.com answered but the page could not be scraped). The end-of-run failure message says which one dominated, and how many IP rotations were attempted

#### Extraction correctness

- 🐞 **Fixed: rate data was attributed to the wrong rate plan.** Each rate block was parsed from a fixed character window around its `b_block_id`, which assumes that key comes first in its JSON object — Booking's key order is arbitrary. On a verified page, 2 of 9 blocks carry `b_block_id` near the *end* of their object, so the window started after their own data and ran into the next block's: `taxes_included` came back `null` for those two, and the remaining options each carried the **neighbouring rate's** tax, occupancy and `stay_prices`. The enclosing object is now located by brace matching, so every field belongs to its own block. Verified against a saved page: all 9 rate options now satisfy `tax = price / 11` (Booking's own "10 % TAX") and each option's 1-night `stay_prices` entry matches its own price
- 🐞 **Fixed: `review_count` was truncated at the thousands separator.** The page text reads `1,751 reviews` and the regex could not cross the comma, so it captured `751`. Every property with more than 999 reviews was understated. JSON-LD `aggregateRating.reviewCount` is now read first, and the text patterns accept `,`, `.` and spaces
- 🐞 **Fixed: `hotel_address` lost the street.** All five DOM selectors are dead on current markup, so it fell through to a body-text regex and returned `6103 Perth, Australia`. The street address is now read from the schema.org JSON-LD (`85 Great Eastern Hwy Rivervale , 6103 Perth, Australia`), with the Apollo `formattedAddress` as a second source
- 🐞 **Fixed: `has_private_bathroom` was false for rooms that have one.** Booking's own wording in the room facilities is "Attached bathroom", which the pattern didn't know. "Shared bathroom" is a different string and still reads as `false`
- 🐞 **Fixed: an impossible `star_rating: 8`.** Star icons were counted with no upper bound and Booking nests two `<svg>` per star. The `aria-label` ("4 out of 5 stars") is now read first, and any count outside 1–5 is discarded rather than published
- 🐞 **Fixed: `free_cancellation_until` was `null` in 100% of runs** — and was never listed as broken. It was parsed from policy *wording* in the visible text ("before October 6, 2026"), which doesn't match reliably in production sessions. It now reads `b_cancellation_limit_until_epoch`, a plain UNIX timestamp already sitting in the JSON parsed for taxes — no locale, no date-format guessing
- 🐞 **Fixed: `room_view` reported a sea view for rooms with a chair.** The pattern `/mer|sea|ocean/i` had no word boundaries, so it matched the letters **sea** inside Booking's own label "**Sea**ting area" — any room with a seating area was labelled sea view. The same bug existed in three places (main detection plus two fallback tables); all now use `\bsea\b`-style boundaries
- 🐞 **Fixed: `property_type` called every property a "Hotel".** Booking's schema.org JSON-LD hardcodes `"@type": "Hotel"` for everything — a property literally named "Econo Lodge Rivervale" still came back as `Hotel`. The breadcrumb (`"{Name} (Lodge) (Australia) Deals"`) carries the real category and is now tried first, with JSON-LD demoted to last resort
- 🐞 **Fixed: `hotel_neighborhood` was always `null`.** All four CSS selectors it relied on — including a hashed CSS-module class — are dead on current Booking markup (0 matches, verified). It now reads the structured `"__typename":"Location"` object embedded in the page, which is data rather than styling and survives redesigns
- 🐞 **Fixed: `primary_tax_label` and `taxes_breakdown_detail` were `null` at the top level** even though the nested `rate_options` entries carried real values. `_rate_option_to_dict()` is an explicit whitelist used to build `primary_rate_option` / `benchmark_rate_option`, and the two new fields were never added to it — the exact "everything must come from the selected rate" failure this Actor is built to avoid. Both are now promoted, and documented in the dataset schema
- 🐞 **Fixed: `booking_terms_summary` overclaimed "Breakfast extra".** `is_breakfast_included: false` only proves breakfast isn't bundled — not that it can be bought at all. It now says "Breakfast extra" only when the meal plan text carries an actual price (e.g. `Good breakfast US$13`), and the narrower, always-true "Breakfast not included" otherwise

#### New fields & views

- ✅ **Three clarity fields** — `rate_selection_reason` (why this rate was selected over the cheaper one on the page), `tax_clarity_label` (`Taxes included` / `Additional taxes reported` / `Final cost not confirmed`) and `booking_terms_summary` (`"Free cancellation · Breakfast included · Pay later"`). All three are rule-based and generated from the same code path that made the decision — never model-written, and an unknown condition is omitted rather than rendered as "No"
- ✅ **New hotel metadata on every row** — `hotel_latitude` / `hotel_longitude` (full-precision coordinates), `hotel_checkin_from` (check-in start time; see *Known issues*) and `hotel_sustainability_certified` (Travel Sustainable badge)
- ✅ **New tax detail** — `primary_tax_label` (the main charge's label, e.g. `10 % TAX`) on flat rows, and `taxes_breakdown_detail` (every labelled charge with its type and amount) inside each rate option
- ✅ **New "Full detail (ordered)" view (70 columns)** — Apify's built-in *All fields* tab sorts alphabetically and cannot be reordered from the schema, which puts `benchmark_rate_option` near the front and `nightly_total_price` in the middle. This view is the same complete field set in a deliberate reading order
- ✅ **Corrected view column counts** — Rate shopping 25, Room comparison 27, AI room picker 48, Full detail 71. The README had drifted to 21/23/41 and now fails its own test if it drifts again
- ⚠️ **Documented honestly instead of "fixed"** — `min_stay` cannot be read from the property page at all (the only minimum-stay text there is an unfilled `{num_nights}` template belonging to the interactive date-picker), and `hotel_checkin_from` still returns `null` in production despite working on a fully-loaded page. Both are now in *Known issues* with the evidence, rather than sitting in a vague "fields under investigation" list

#### Tooling

- ✅ **`tools/offline_extract.py`** — run the Actor's real extractors against a page saved from your browser: no proxy, no run, no cost. See *Debugging extraction without spending a run*
- ✅ **First page fixture + regression tests** (`tests/fixtures/`, `tests/test_extract_fixture.py`) — a real saved Booking.com page, with 16 assertions pinning its exact prices, taxes, occupancies and hotel metadata

</details>

<details>
<summary><strong>v2.5.0 — August 2026 — Three focused views + correctness fixes</strong></summary>

> ⚠️ **Behaviour change — read this if you filter on `is_refundable`.** It used to mean *"any rate plan for this room is refundable"*. It now means *"the rate you would actually book is refundable"*. Rows where only a non-primary rate was refundable flip from `true` to `false`. The same correction applies to `meal_plan`, `is_breakfast_included`, `book_now_pay_later`, `taxes_included` and `taxes_excluded`. These fields are now `null` (never `false`/`0`) when no rate matched your occupancy — `false` would assert "not refundable", `null` says "unknown".

#### Correctness fixes

- 🐞 **Fixed: runs with `includeSoldOut: false` returned zero room prices while reporting "Succeeded"** — `hotel_meta` was initialized inside the `if include_sold_out:` block but read by every room push. With the flag off it was never bound, and the resulting `NameError` was swallowed by the per-day error handler, so every date of every hotel emitted an `error` record instead of rooms. It is now bound unconditionally
- 🐞 **Fixed: rate conditions were mixed across different rate plans** — `is_refundable`, `meal_plan`, `is_breakfast_included`, `book_now_pay_later`, `taxes_included` and `taxes_excluded` were room-level rollups computed across *all* rate options (`is_refundable` was a logical OR; `meal_plan` was "the first plan that had one"). A single row could advertise *"Refundable ✓ / Breakfast ✓"* from one rate beside a price from another. All six now come from the same `primary_rate_option.block_id` as the price
- 🐞 **Fixed: `hotel_name_clean` was not cleaned** — it was byte-identical to `hotel_name`, so it carried Booking's generated page title, e.g. `Fashion Boutique Hotel (Hotel) (Morocco) deals`. It now prefers the structured property name from schema.org JSON-LD or `utag_data`, falling back to a conservative suffix cleanup that strips only Booking's own qualifier vocabulary — a legitimate name like `Hotel Example (Adults Only)` is left untouched. `hotel_name` still carries the raw title
- 🐞 **Fixed: availability urgency ignored the room count** — `low_availability_urgent` keyed off whether Booking rendered a scarcity banner and never off the number itself, so 5 rooms left could rank *more* urgent than 4. Urgency is now monotonic in the count: 1–3 → `low_availability_urgent`, 4–5 → `low_availability`, 6+ → `available`. A hidden count stays `available` and is explained by `stock_confidence`; `sold_out` is still driven only by the explicit `is_sold_out` boolean, never inferred from a missing price or count
- 🐞 **Fixed: "partially refundable" was indistinguishable from "non-refundable"** — refundability was a boolean derived from the whole row's text (`!/non.?refundable/ && /free cancellation/`), and a partially refundable rate matches neither pattern, so it silently fell through to `false`. New `cancellation_policy` field with three real states plus `unknown`, read from Booking's dedicated cancellation element (`.e2e-cancellation`) rather than the whole row. `is_refundable` is kept as a legacy boolean and is now `true` **only** for `fully_refundable` — a partial refund is not a free-cancellation guarantee
- 🐞 **Fixed: `room_capacity` was null in almost every record** — occupancy resolved through a JSON fallback into one variable, but the resolved value was never written back to the DOM-sourced variable that `room_capacity` actually reads. It now falls back to the largest capacity any rate option proves
- 🐞 **Fixed: `stock_confidence` said `hidden` while `rooms_left` held a number** — anything without a visible scarcity badge was labelled `hidden` ("no count exposed"), including rows that carried a count from the page JSON or room registry. Live output contained rows reading `rooms_left: 3` beside `stock_confidence: "hidden"` in the same row of the default view. A badge-less count is now `inferred`; `hidden` means there is genuinely no count
- ⚠️ **`room_capacity` is now documented as a lower bound.** When Booking.com doesn't expose physical occupancy directly it falls back to the largest capacity any rate option proves — and since rate options are filtered to your searched occupancy, that fallback can't exceed it. A "Quadruple Room" searched for 2 adults reports `2`. The old schema text claimed "raw maximum physical occupancy, independent of any rate option", which is no longer accurate
- 🐞 **Fixed: a failed page load wiped the entire hotel metadata object** — `document.body.innerText` was read unguarded while the identical read at the top of the same function already checked for a null body. When `Page.goto` times out the page never loads, `document.body` is `null`, and the throw lost score, stars, address and property type together, not just the review breakdown being parsed. This is why `hotel_review_score` could come back `9.4` on one run and `null` on the next for the same property
- ✅ **The startup banner now reports the real version** — it was a hardcoded `v2.4.0` string while `actor.json` said `2.5.0`, so a run log couldn't tell you which build produced it. It now reads `actor.json`
- ⚠️ **Default `proxyCountries` changed from `["FR"]` to `["US"]`** — it now matches the `USD` currency default. Booking.com shows a French visitor prices in EUR, so the old `FR` + `USD` pairing returned a *converted* figure, the very artifact the multi-country tip warns against. Runs that set `proxyCountries` explicitly are unaffected
- ✅ **`proxyCountries` accepts any country your Apify Residential proxy offers** — including `CN`. It was documented as a fixed 15-country list, but the code always passed the entry straight through, so the restriction existed only in the docs. Adds 8 currencies led by `CNY` (also `PLN`, `KRW`, `THB`, `TWD`, `CHF`, `MXN`, `ZAR`)
- ✅ **Standard Apify proxy picker** — the input now uses Apify's native proxy editor (Datacenter / Residential / Own proxies / No proxy) instead of a bare on/off toggle, so the form matches every other Actor on the platform. `proxyCountries` stays alongside it as the **multi-market override**, because the native widget takes exactly one country and comparing geo-pricing needs several. The old `useApifyProxy` boolean is still honoured, so existing saved tasks keep working
- 🐞 **Fixed: a free plan fell straight from "no residential access" to "no proxy at all"** — which on Booking.com means zero results. The fallback ladder now tries DATACENTER in between, and any downgrade is announced in the log instead of happening silently
- ✅ **Nullability corrected in the dataset schema** for every field the fix above made nullable
- ✅ **Test suite added** (`tests/`, `python3 -m pytest tests/`) — 135 tests covering the fixes above and validating the dataset schema: every view column exists, is labeled, is actually emitted by the scraper, and every view link resolves

#### Views

- ✅ **The single "Room prices" view is now three views over the same dataset** — **Rate shopping** (default, for revenue managers), **Room comparison** (for travelers), and **AI room picker** (for agents). No new dataset, no new fields, no change to a single record: each view is a projection, and the complete schema stays available under All fields, in CSV/Excel, and at the plain API items endpoint
- ✅ **Rate shopping carries the columns a rate export actually needs** — `check_out_date`, `total_stay_price`, `price_basis`, plus `proxy_country` (so multi-market runs no longer look like duplicate rows at contradictory prices) and `scrape_timestamp` (so daily exports stack into a time series)
- ✅ **Net / taxes / gross now reconcile** — Rate shopping shows `nightly_base_price`, `taxes_and_fees_total` and `nightly_total_price` together, all drawn from the same occupancy-matched rate. The legacy `price_public` / `discount_percent` are intentionally left out of that view: they come from the fixed 2-adult benchmark path, so a discount computed against `nightly_total_price` would be comparing two different rate options. Both fields remain under All fields, unchanged
- ✅ **`is_sold_out` *and* `availability_status` together** — the explicit boolean is a fact and stays; the four-tier status sits beside it, and `stock_confidence` makes a blank `rooms_left` read as `hidden` or `capped` rather than as missing data. Both appear in Rate shopping and Room comparison, so a sold-out row can never show as an unexplained blank price
- ✅ **`occupancy_match_status` is visible in Room comparison** — a blank Guests cell now has an on-screen explanation (`no_compatible_rate` means no rate proved capacity for your party, so `max_guests` is `null` by design)
- ✅ **`primary_rate_option`, `benchmark_rate_option` and `rate_options` are fully documented in the dataset schema** — every property and its nullability, including the fact that `rate_options` entries use a *different* raw shape (extra `original_price` / `taxes_and_fees` / `stay_prices`, no `is_breakfast_included`). An AI-facing view should not expose an undocumented generic object
- ✅ **Per-view links on the run's Output tab** — each audience gets a direct entry point, with the CSV/Excel downloads still carrying the full field set

</details>

<details>
<summary><strong>v2.4.0 — August 2026 — Request-aware occupancy</strong></summary>

- ✅ **Fixed occupancy matching** — `primary_rate_option` (and therefore `max_guests`) was silently benchmarked against a hardcoded 2-adult standard regardless of what you actually searched for. It's now selected against your real `adults`/`children`/`rooms` input, with the minimum required capacity split correctly across multiple rooms (4 guests across 2 rooms now correctly requires 2 guests/room capacity, not 4)
- ✅ **New `required_guests_per_room`** and **`occupancy_match_status`** (`capacity_compatible`, `uncertain_children`, or `no_compatible_rate`) — so you can see exactly how confident the occupancy match is, including an honest flag when children are in the party (child ages aren't collected, so capacity can't be fully verified for those bookings)
- ✅ **`primary_rate_option` is `null` — never a guessed fallback** — when no rate option can prove enough capacity for the request, instead of silently falling back to a 2-guest assumption
- ✅ **New `benchmark_rate_option`** — the old fixed 2-adult rate is preserved here for revenue-benchmarking use cases. The legacy `price_primary` / `price_public` fields keep their original meaning unchanged
- ✅ **Hotel metadata dataset is now run-scoped** — switched from a persistent named dataset to Apify's alias mechanism, so hotel discovery metadata never accumulates across runs

</details>

<details>
<summary><strong>v2.3.0 — August 2026 — AI-ready room data</strong></summary>

- ✅ **Fixed `has_private_bathroom`** — was only matching "private bath...", so the very common "Ensuite bathroom" facility label was silently returning `false`
- ✅ **Fixed `max_guests`** — now reflects the primary rate option actually matched to your requested occupancy, not the room's raw physical capacity. New `room_capacity` field carries the raw capacity separately
- ✅ **New `primary_rate_option`** — a deterministic object where every field (price, refundability, cancellation deadline, meal plan, taxes) comes from the *same* rate option, never mixed across different rates
- ✅ **New unambiguous price fields** — `nightly_base_price`, `nightly_total_price`, `total_stay_price`, `taxes_and_fees_total`, `price_basis`. Legacy `price` / `price_primary` / `price_public` kept for backward compatibility
- ✅ **Sold-out rows now use `null` for all price fields — never `0`**
- ✅ **New request context on every row** — `requested_adults`, `requested_children`, `requested_rooms`, `number_of_nights`
- ✅ **New `hotel_address` and `property_type`** on every room row
- ✅ **Hotel discovery metadata moved to a run-scoped dataset** (`hotel_metadata` alias) — the default room-price dataset no longer contains blank/"undefined" discovery rows. Hotel score, stars, and neighborhood remain denormalized onto every room row
- ✅ **Overview table decluttered** — removed the full `facilities` array from the default view (still available under "All fields"), kept `room_size_sqm` and `room_amenities` to avoid duplicating the same information twice

</details>

<details>
<summary><strong>v2.2.x — August 2026</strong></summary>

- ✅ Input URL sanitation — rejects non-Booking.com URLs with a clear error instead of silently returning 0 results
- ✅ Parallel hotel processing (`concurrency`, 1–5) — each hotel scraped on its own proxy session
- ✅ Fixed a proxy-disabled crash that could silently produce 0 results on a "Succeeded" run
- ✅ Run now fails loudly (instead of reporting false success) when every hotel errors out
- ✅ Added dataset output schema for a clean "Output" table view

</details>

<details>
<summary><strong>v2.2.0 — June 2026</strong></summary>

- ✅ **Multi-country proxy** — `proxyCountries` field scrapes from multiple source markets in one run; each result tagged with `proxy_country` for geo-pricing comparison
- ✅ **Wholesale rates** — `includeWholesaleRates` flag captures partner/`bbasic` rates, tagged separately as `is_wholesale`
- ✅ **Resilient registry load** — room-registry navigation now retries with IP rotation

</details>

<details>
<summary><strong>v2.1.x — March 2026</strong></summary>

- ✅ Fixed multi-room extraction — `roomId` is now the single source of truth for deduplication
- ✅ Robust fallback names for rooms with missing DOM names
- ✅ `stay_prices` (length-of-stay pricing matrix)
- ✅ `taxes_included` / `taxes_excluded` breakdown
- ✅ `book_now_pay_later` flag
- ✅ `stock_confidence` — distinguishes exact / capped / hidden signals
- ✅ Cell-based extraction for hotels with 9+ room types
- ✅ `price_public`, `room_size_sqm`, `room_view`, `has_private_bathroom`, `room_amenities`
- ✅ Genius rate detection

</details>

<details>
<summary><strong>v2.0.x and earlier</strong></summary>

- ✨ Room-by-room extraction with multiple rate options per room
- ✨ `rooms_left` + `availability_status` availability tracking
- ✨ Discount detection, meal plan and cancellation policy extraction
- ✨ Sold-out detection across date ranges
- Initial release — February 2026

</details>
