Every release of the Booking.com Room-Level Price Tracker, newest first.
The three most recent also appear in the README.
v2.16.5 — September 2026 — The digest compares you with your competitors, and says when prices are before tax
- 🐞 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
v2.16.4 — September 2026 — Fix: with sold-out tracking off, the market read "every room type available"
- 🐞 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
v2.16.3 — September 2026 — The digest leads with the one line that matters
- ✅ 👉 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
v2.16.2 — September 2026 — Fix: a night that had already passed at the hotel read "sold out"
- 🐞 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
v2.16.1 — September 2026 — Fix: a digest email that was never sent was logged as sent
- 🐞 "📬 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
v2.16.0 — September 2026 — The daily digest, and "nights" that were rooms all along
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
v2.15.0 — September 2026 — Cancellation dates, rooms still bookable, and the rate a guest actually sees first
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
v2.14.2 — September 2026 — Every room keeps its line, and "your hotel" moved into Price tracking
- ✅ 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
v2.14.1 — September 2026 — Fix: the Output tab sat at 0 while the run worked
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
v2.14.0 — September 2026 — The dataset reads the same every run
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
v2.13.10 — September 2026 — A column header that contradicted its own row
- 🐞 "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
v2.13.9 — September 2026 — The original Rate shopping is back, and the rebuilt one has a name
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
v2.13.8 — September 2026 — The proxy warning said more than the evidence does
- 📖 "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, 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
v2.13.7 — September 2026 — The auditor catches a window that stopped moving
- ✅ 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
v2.13.6 — September 2026 — "One property only" now reaches the run card
- ⚠️ 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
v2.13.5 — September 2026 — The auditor reads CSV, and a sold-out night stops vanishing
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
v2.13.4 — September 2026 — A tool that reads the export so you do not have to
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
v2.13.3 — September 2026 — A run with no crash, and the four things it showed
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
v2.13.2 — September 2026 — The crash was ours, and it was in the image blocker
🔴 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
v2.13.1 — September 2026 — A run came back half empty and could not say why
🔴 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
v2.13.0 — September 2026 — `rooms_left` settled, with the page as evidence
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
v2.12.2 — September 2026 — Room identity verified, and the scarcity badge caught again
- ✅ 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
v2.12.1 — September 2026 — Four defects a live run of the new build exposed
- 🐞 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
v2.12.0 — September 2026 — Which hotel is yours, and a stable row order
- ✅ 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
v2.11.0 — September 2026 — Coverage, crash recovery, and an AI handoff
- 🐞 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
v2.10.0 — September 2026 — Five things a live-data audit proved wrong
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
v2.9.6 — September 2026 — Fix: every row was being rejected by the dataset schema (again)
🔴 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
v2.9.5 — September 2026 — Documenting the movement columns, and a tax flag you cannot miss
-
✅ 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
v2.9.4 — September 2026 — Rate shopping trimmed to what gets read
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
v2.9.3 — September 2026 — A screen you can read at a glance
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
v2.9.2 — September 2026 — One screen, built for rate shopping only
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
v2.9.1 — September 2026 — Fix: every row was being rejected by the dataset schema
🔴 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
v2.9.0 — September 2026 — Price movement between runs
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
v2.8.1 — September 2026 — Rate shopping column order, from how the job is actually done
- ✅ 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
v2.8.0 — September 2026 — The three things a rate shop could not see
- 🐞 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
v2.7.0 — September 2026 — The rate shop, and a Rate shopping view you can actually read
- ✅ 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
v2.6.0 — September 2026 — Signals a revenue manager can act on
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
v2.5.3 — September 2026 — `rooms_left` was never a room count
🔴 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
v2.5.2 — September 2026 — Every run explains itself
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
v2.5.1 — September 2026 — Proxy failures stop eating whole runs, and rate data stops crossing blocks
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.
- 🐞 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
- 🐞 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 "Seating 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
- ✅ 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
- ✅
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
v2.5.0 — August 2026 — Three focused views + correctness fixes
⚠️ 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".
- 🐞 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
- ✅ 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
v2.4.0 — August 2026 — Request-aware occupancy
- ✅ 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
v2.3.0 — August 2026 — AI-ready room data
- ✅ 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
v2.2.x — August 2026
- ✅ 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
v2.2.0 — June 2026
- ✅ 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
v2.1.x — March 2026
- ✅ 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
v2.0.x and earlier
- ✨ 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