Booking.com Scraping: Availability as a Calendar
Model availability as a date-keyed calendar with room and rate dimensions, record the query each rendered page projects, and keep five states per cell.
BotBrowser Team
Want the structured docs for Network?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
Scraping Booking.com availability is not a document-extraction problem, because an availability page is not a document. It is a projection of a query: the dates, the occupancy, the room and the rate you asked for decide which calendar cells exist on the page at all. BotBrowser documents geo-aligned egress and profile-backed locale values, so a point-of-sale comparison can declare the region and language it represents. It does not provide availability data, does not hold inventory, does not reserve a room, and does not guarantee that a query returns the same calendar twice. Prefer an official partner or affiliate availability feed where one exists.
TL;DR
- An availability page is a projection of its query. Record the query or the page cannot be interpreted later.
- Key a calendar by date, not by page. A three-night stay is three date rows, and the checkout date is exclusive.
- Room and rate are dimensions on a row, not separate calendars. Two rates on the same date are two cells, not two observations of one cell.
- Use five states per cell: observed available, observed closed, not in the window, absent from the response, and unaccepted. A missing cell is not a closed room.
- Prefer an official partner or affiliate feed where one exists, and keep the platform's terms as the authority for what may be collected.
Contents
- Availability as a calendar
- The key and the dimensions
- What the page projects from a query
- Five states per calendar cell
- Record the query with the observation
- The terms boundary
- Practical conclusion
Availability as a calendar
A lodging search answers a question about a range of dates. The range is the unit the source prices, and the individual night is the unit a calendar can key on. Getting that relationship wrong is the most common modelling error in availability work, because a page that shows "from 412" for a three-night stay looks like one value when it is a summary of three rows that may each carry different availability.
The checkout date is exclusive. A stay from 2026-12-10 to 2026-12-13 occupies the nights of the 10th, 11th and 12th, and a guest checks out on the 13th. Treating the checkout date as a fourth night produces a calendar that is off by one for every stay, and the error is invisible until someone compares a two-night and a three-night booking of the same room.
Availability also is not a property of the property as a whole. A hotel can have rooms free while a specific room type is sold out, and it can have a room type free while the rate that includes breakfast is not. The calendar therefore needs at least two dimensions before a cell means anything.
The key and the dimensions
The key is the date plus the thing being observed. The dimensions describe how that observation was qualified. Keeping them separate is what allows two captures to be compared without silently mixing a room type, a rate plan or a point of sale.
| Part | Content | Why it cannot be folded into a page-level value |
|---|---|---|
date_key | ISO date of the night, in the property's zone | Two nights are two observations, not one |
room_code | Room or unit type as the source names it | A sold-out suite does not close the hotel |
rate_code | Rate plan as the source names it | A closed rate does not close the room |
occupancy | Adults and children the query requested | Occupancy changes which inventory is offered |
A key of (date, room, rate, occupancy) is enough to compare two observations of the same thing. A key of (date) alone merges a suite and a standard room into one cell, and the resulting series will show a room "reopening" every time the cheapest category changes. A key of (page) merges three nights into one value and loses the ability to say which night changed.
The property's time zone belongs in the key as well, but as a stated property of the calendar rather than as a separate dimension. A night is a local concept, and a capture taken across a daylight-saving boundary without recording the zone will produce two rows that look like one duplicated night.
Occupancy deserves a separate note because it changes which inventory exists rather than only how it is priced. A query for two adults may return a room type that a query for four adults cannot see, and the same physical room can appear as two different offerings with different rate codes. Recording the occupancy beside the room and rate means a later comparison can tell whether a change came from the inventory or from the question that was asked.
Minimum-stay rules are the quietest dimension of all. A property can require two nights over a weekend, so a single-night query returns nothing while the room is genuinely free for a two-night stay. A calendar that records the single-night query as observed_closed has recorded a policy outcome as an availability fact. Where the page states a minimum stay, record it as a dimension or as a note; where it does not, treat a zero-result single night as an observation with a stated query rather than as a closure.
Currency and point of sale sit alongside the four fare inputs rather than inside the calendar key. A rate code can exist in several currencies and under several point-of-sale rules, and the amount will differ while the availability does not. Keep the currency and point of sale in the record so a price comparison is not mistaken for an availability comparison, and so a currency switch does not look like a room reopening.
What the page projects from a query
The MDN URL API documentation describes URL as the interface that parses and exposes the components of an address, including its search parameters. That is the part of the browser surface this problem actually needs: a rendered availability page is selected by the parameters in its address, so the address is evidence about the page rather than decoration around it.
Two different addresses can describe the same stay. A date range can be written as two parameters, as a single range parameter, or as a path segment, and a query may add tracking parameters that do not change the result. Normalising the query is therefore the first step in making two captures comparable, and it has to happen before the page is fetched rather than after.
// Constructed fixture: the same stay written two ways, then a changed check-in.
const raw = [
'https://example.test/hotel/alpha?checkin=2026-12-10&checkout=2026-12-13&adults=2&tracking=abc',
'https://example.test/hotel/alpha?stay=2026-12-10_2026-12-13&adults=2',
'https://example.test/hotel/alpha?checkin=2026-12-11&checkout=2026-12-14&adults=2',
];
const KEEP = ['checkin', 'checkout', 'adults', 'children', 'room', 'rate'];
const lines = raw.map(input => {
const url = new URL(input);
const stay = url.searchParams.get('stay');
const params = new Map();
if (stay) {
const [checkin, checkout] = stay.split('_');
params.set('checkin', checkin);
params.set('checkout', checkout);
}
for (const key of KEEP) {
const value = url.searchParams.get(key);
if (value) params.set(key, value);
}
const canonical = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
return `canonical=${canonical} key=${params.get('checkin')}..${params.get('checkout')}`;
});
console.log(lines.join('\n'));
The constructed output below shows the first two addresses collapsing to one canonical query, and the third staying distinct because its dates differ.
canonical=adults=2&checkin=2026-12-10&checkout=2026-12-13 key=2026-12-10..2026-12-13
canonical=adults=2&checkin=2026-12-10&checkout=2026-12-13 key=2026-12-10..2026-12-13
canonical=adults=2&checkin=2026-12-11&checkout=2026-12-14 key=2026-12-11..2026-12-14
The tracking parameter disappears in the first two lines, which is the point: two captures that differ only by a campaign tag describe the same stay and should compare as one. The third line keeps its own key, because a shifted check-in is a different question even when the number of nights matches.
Three normalisation rules make that behaviour reliable. Sort the retained parameters by name so the canonical form does not depend on the order a site happened to use. Drop parameters whose values do not affect the result, and keep an explicit list of the ones that do, so a new tracking parameter cannot silently create a new series. Represent the date range in exactly one way, converting a single range parameter into two explicit dates, so a site's choice of encoding never leaks into the key.
There is also a boundary the normaliser cannot cross. Two addresses that look different can still describe the same stay through a redirect, and two addresses that look identical can resolve differently because of a cookie or an account state. The canonical query is therefore a statement about what was requested, not a guarantee about what was rendered. Record both the canonical query and the final address after any redirect, and treat a divergence between them as a finding rather than as noise.
Five states per calendar cell
Availability work goes wrong most often at the boundary between "not available" and "not observed". A binary available/closed model forces every missing cell into one of those two answers, and both choices are wrong for a cell the query never asked about.
| State | What was observed | What it supports |
|---|---|---|
observed_available | The query returned an offer for this date, room and rate | The cell was available in that query at that time |
observed_closed | The query returned an explicit sold-out or closed signal | The cell was closed in that query at that time |
not_in_window | The query window did not include this date | Nothing about the cell; the window was bounded |
absent_in_window | The window covered the date, but the response listed no cell | Nothing about the cell; the response omitted it |
unaccepted | No usable response: challenge, error, timeout, parser miss | Nothing about the cell; the observation failed |
The two absent states save a series from inventing closures, and they describe different failures. If the capture only requested three nights, the fourth night is not_in_window: outside the bounded window, and not closed. If the window covered the date but the response listed no cell, that is absent_in_window: inside the window, unlisted, still not closed. Recording either as closed makes the property look like it lost availability that it never reported on.
The unaccepted state protects the opposite error. A challenge page, a 500 and a parse failure all produce no cells, and treating them as empty availability turns an infrastructure problem into a supply observation. Keep them in their own state so a later reader can see how much of the calendar was actually observed.
The MDN Performance API documentation describes browser APIs for measuring timing and resource loading. That matters here only for the boundary between an accepted page and a slow one: a capture that gave up before the calendar rendered has to be recorded as unaccepted, not as a property with no rooms. Recording the time the page took to settle beside the state makes that distinction auditable.
A separate condition sits outside the five states and should not be folded into them: the calendar was observed, but the observation belongs to a different scope than the series claims. A capture taken with a corporate rate code, a member login or a promotional campaign can show availability that the public series cannot, and mixing the two produces rows that look comparable and are not. Record the scope alongside the query, and start a new series when the scope changes.
The five states also need a rule for revision. A cell observed as available on Monday and closed on Tuesday is a transition, not a contradiction, and the record should keep both rows with their observation times rather than overwriting the first. Overwriting loses the transition and makes it impossible to tell a genuine change from a correction, which is exactly the distinction a later reviewer needs when a series is challenged.
Finally, keep the observation granularity honest. A page may render a month view where only some dates carry cells, and a capture that records the whole month as observed has over-claimed. Record which dates were actually present in the response, even when the answer is "the response listed three of the thirty-one dates", and record those dates as absent_in_window rather than as closed.
Record the query with the observation
A capture record for availability has to carry the query, because the page cannot be interpreted without it. The fields below are the minimum that makes two captures comparable.
| Field | Content | Why it is in the record |
|---|---|---|
canonical_query | Normalised query from the step above | Defines which calendar was asked for |
property_ref | Source-scoped identifier for the property | Separates two properties with similar names |
date_key | ISO date of the night | Keys one row per night |
room_code | Room or unit type as the source names it | Separates a suite from a standard room |
rate_code | Rate plan as the source names it | Separates a flexible rate from a prepaid one |
state | One of the five states | Prevents absence being read as closure |
observed_at | Capture time in UTC | Orders observations and bounds the run |
settle_ms | Time the page took to settle, when measured | Distinguishes a slow page from a missing one |
Two fields deserve a note. property_ref should be a source-scoped identifier rather than a name, because two properties in the same city can share a name and a chain can rename one of them. settle_ms is optional but useful: a capture that recorded a state after 40 milliseconds probably read a skeleton rather than a calendar, and that is worth knowing before the row is trusted.
The fare capture model applies the same discipline to a single fare rather than a calendar: the four inputs are recorded per capture because a comparison without them measures the environment. Availability needs that plus the date key, because a stay spans several rows that can each change independently.
The terms boundary
A booking page sits behind a platform whose terms decide what may be collected. That is not a technical constraint that a better browser configuration removes; it is the condition under which the data may be used at all. Where an official partner feed, an affiliate interface or a licensed distribution channel exists, it gives the same availability with a defined schema and a stated right to use it.
RFC 9110 defines HTTP semantics including caching and the Vary header, which tells a cache which request fields influenced the representation. That is the mechanism a page uses to say that its content depends on more than its path, and it is a useful reminder that an availability response is negotiated rather than fixed. It says nothing about whether a particular site permits collection, and no header makes a terms question disappear.
The real-estate listing model draws the same line for property data: a listing occurrence, the physical property and an offer are distinct, and a missing card is not a sale. Availability has the same shape, with a date range instead of a property, and the same rule applies: an observation is not a conclusion.
Two practical boundaries follow. First, observation is not reservation: loading a calendar and recording what it showed is a different act from holding a room, and the second is out of scope entirely. Second, a difference between two captures is an observation, not evidence of a policy violation by the platform. Those conclusions belong to whoever holds the inventory and the agreement.
FAQ
Is a three-night stay one availability value?
No. It is three date rows, and each can carry a different state. A page that summarises the stay is showing a projection, so the summary has to be decomposed before it can be compared night by night.
Does a missing calendar cell mean the room is unavailable?
No. Either the window did not cover the date (not_in_window), or it covered it and the response listed no cell (absent_in_window), or the response was not accepted (unaccepted). Keep all three separate from observed_closed so none is read as a closure.
Do room type and rate belong in the key?
They belong in the key together with the date, because a sold-out suite does not close the hotel and a closed rate does not close the room. Folding them into one page-level value merges observations that should stay separate.
Does BotBrowser provide availability data or book rooms?
No. It provides documented browser inputs, including geo-aligned egress and profile-backed locale values, so a comparison can declare its region. It does not supply availability, hold inventory, reserve a room or guarantee a result.
Sources
- MDN URL API
- MDN Performance API
- RFC 9110 HTTP semantics
- BotBrowser GeoIP database
- BotBrowser profile configuration
Practical conclusion
Model availability as a calendar keyed by date with room, rate and occupancy as dimensions, and treat the checkout date as exclusive. Normalise the query before fetching and record it with every capture, because the page is a projection of that query rather than a document that stands alone. Keep five states per cell so absence and failed observation never become closures. Where an official partner or affiliate feed exists, use it, and let the platform's terms decide what the browser run may collect.
This article is part of the guide: Proxy Configuration Overview: A Browser Network Decision Map
Related Articles
Flight Price Scraping: Hold Four Inputs
NetworkScraping Rate Limits: 429, Retry-After and Concurrency
NetworkScraping CAPTCHA: Diagnose a Challenge Mid-Run
PlatformDate-Time Formatting and Time-Zone Portability in Web Applications
NetworkProxy Rotation for Web Scraping: Session Boundaries
NetworkCloudflare and DataDome: Scraping Response Signals
Run the same check in BotBrowser
Load a profile, keep the signals you just read about consistent inside the browser, and confirm the result on your own machine before a rollout.