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

Scrape and track Booking.com hotel prices by room type and date, up to 365 days ahead. Extract rate options, availability, sold-out dates, meal plans, discounts, refundable rates, and geo-pricing. Built for hotel revenue management and competitive rate analysis. Export JSON, CSV, Excel, or HTML.

- **URL**: https://apify.com/noraview/booking-price-scraper.md
- **Developed by:** [HotelSignals Lab](https://apify.com/noraview) (community)
- **Categories:** Travel, Automation, AI
- **Stats:** 338 total users, 33 monthly users, 95.4% runs succeeded, 10 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $3.50 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 🏨 Booking.com Room-Level Hotel Price Scraper

**One row per room type, per night** for every property you give it: the price, what that price covers, the booking terms, and whether the room can still be booked.

Put your hotel and its competitors in one run and it also **ranks you against them night by night**, remembers yesterday, and can **email you the morning read**.

It never guesses. A value it cannot read from Booking.com is `null`, never a number that looks right. "Sold out" comes from Booking.com's own flag, never from a missing price.

***

### ⚡ Quick start

1. **Add the hotels.** Put your hotel and 3–10 competitors in **Hotel URLs**. The plain page URL is enough, no dates needed. Paste your own URL again in **My hotel URL**.
2. **Choose the search.** How many nights ahead (7–14 covers the window most pricing decisions are made in), the number of adults, and the currency.
3. **Run it, then schedule it daily at the same hour.** Use *Actor → Schedule*, or the **Schedule this run** bar above your results. The change columns and the competitor moves compare each run with the previous one, so the first run has nothing to compare with.

```json
{
  "hotelUrls": [
    { "url": "https://www.booking.com/hotel/au/flag-motor-lodge.html" },
    { "url": "https://www.booking.com/hotel/au/your-competitor-1.html" },
    { "url": "https://www.booking.com/hotel/au/your-competitor-2.html" }
  ],
  "myHotelUrl": "https://www.booking.com/hotel/au/flag-motor-lodge.html",
  "daysAhead": 10,
  "adults": 1,
  "currency": "AUD",
  "digestEmail": "you@example.com"
}
```

⚠️ **Put your hotel and its competitors in the same run.** The ranking compares the properties inside one run. Scrape them one at a time and every rank, median and gap comes back empty.

***

### ⭐ Start here: the 12 fields that matter most

Every example below comes from a real run on four Perth airport motels, 25 September 2026, one adult, prices in AUD.

| Field | What it tells you | Example |
|---|---|---|
| `nightly_total_price` | The price Booking.com shows for the rate that fits your party. **Compare on this one.** | `217` |
| `lowest_bookable_rate` | The cheapest rate your party can book for this room, whatever its terms: the price a guest sees first. | `213`, beside a selected `235` on the same room |
| `tax_clarity_label` | Whether that price is the final cost. Only ✅ means final. | `⚠️ Additional taxes reported` |
| `availability_label` | Availability at a glance, with a colour. | `🔴 Only 2 rooms left` · `⚫ Sold out` |
| `rooms_left` + `stock_confidence` | Rooms left, only when Booking.com states it, and how far to trust that number. | `2` + `exact` · `null` + `hidden` |
| `booking_terms_summary` | Cancellation, breakfast and payment in one line. | `Free cancellation · Breakfast not included · Pay now` |
| `rate_selection_reason` | Why this rate was picked, and which cheaper one was not. | `…a cheaper non-refundable rate (180) was available but not selected…` |
| `price_move_label` | What the price did since the last run. | `▲ +191 (+82.68%)` |
| `is_my_hotel` | `true` on every row of your own property. | `true` |
| `lead_in_rate` *(Market by date)* | A property's cheapest bookable rate that night. The ranking is built on it. | `217` |
| `compset_rank` / `gap_to_market_median` *(Market by date)* | Where the property sits that night: 1 = cheapest. | `2` of `4` · `-50` |
| `demand_pressure` *(Market by date)* | How much of the property is sold out that night, as a level. | `high` — *"5 of 6 room types unavailable"* |

***

### 📦 What you get

| Output | Where to find it | One record per | Use it for |
|---|---|---|---|
| **Room rows** | Output tab: *Rate shopping*, *Rate shopping V2*, *Full detail*, CSV, Excel | room type × night × proxy country | Every price, term and availability, room by room |
| **Market by date** | Output tab: *Market by date* (`market_summary` dataset) | property × night | Your rank, the median, the gaps, and the demand read |
| **Daily digest** | Email or webhook, and `DIGEST` (text) / `DIGEST_DATA` (JSON) in the run's key-value store | run | The morning read: one screen of text |
| **Hotel metadata** | `hotel_metadata` dataset | property × run | The full hotel snapshot: reviews, description, every facility |
| **Run report** | `RUN_REPORT` in the run's key-value store, and the run's status line | run | What worked, what did not, and whose fault it was |

The room rows come in four views of the same data:

| View | Fields | For | Through the API |
|---|---|---|---|
| **Rate shopping** | 25 | The working screen: price, tax, terms, availability | `?view=rate_shopping` |
| **Rate shopping V2** | 52 | The daily screen: adds the movement since the last run, the lead-in rate and how many rooms are still bookable | `?view=rate_shopping_v2` |
| **AI input (compact)** | 41 | Handing the data to an AI model | `?view=ai_input` |
| **Full detail** | 104 | Every field, in reading order | `?view=full_detail` |

Add the view to the dataset's items URL, for example `…/datasets/<id>/items?view=rate_shopping_v2`. The CSV and Excel downloads carry every field, with the rate plans flattened into columns.

***

### 📖 Field reference

Every field, grouped by the question it answers. **Empty when…** says when a field is `null` and why. `null` always means *not known*, never 0, never "no".

#### Room rows: which room, which night

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `hotel_name_clean` | The property's name, without the extras Booking.com puts in its page title. | `Flag Motor Lodge` | Never |
| `is_my_hotel` | `true` on every row of the property you gave as `myHotelUrl`, `false` on the others. | `true` | `myHotelUrl` was left empty. *Not yours* and *you never said which one is yours* are different answers |
| `check_in_date` | The night this row prices (YYYY-MM-DD). | `2026-09-30` | Never |
| `check_out_date` | The morning after. Every row prices one night. | `2026-10-01` | Never |
| `lead_time_days` | Nights between the run and arrival, counted from the hotel's own date. `0` = tonight. Segment on this before comparing anything. | `5` | The date could not be read |
| `day_of_week` | Arrival weekday. | `Wed` | The date could not be read |
| `is_weekend` | Arrival on a Friday or Saturday, the hotel weekend. Use `day_of_week` where your market's weekend differs. | `false` | The date could not be read |
| `room_name_clean` | The room's name, without the bed phrase and deal tags, so the same room reads the same across nights. | `Standard Room` | Never |
| `room_id` | Booking.com's id for the room type. Stable across runs. | `117422001` | Booking.com did not expose it |

#### Room rows: the price ⭐

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `nightly_total_price` ⭐ | The price Booking.com shows for the selected rate, as a guest sees it (whole number). Tax that Booking.com puts **inside** the price is included; tax it charges **on top** is not. Read `tax_clarity_label` beside it. | `217` | Sold out, or no rate fits your party (`availability_label` says which) |
| `nightly_total_price_exact` | The same rate, unrounded, from Booking.com's own data. Use it when cents matter. | `217.27` | Same as above |
| `lowest_bookable_rate` ⭐ | The cheapest rate your party can book for this room tonight, whatever its terms. Same tax basis as `nightly_total_price`. They differ when a cheaper rate is non-refundable or is priced for more guests. Rates too small for your party are never counted. | `213` (Sanno, King Room, Fri 02 Oct, where the selected rate was `235`) | Sold out, or no rate fits your party |
| `lowest_bookable_terms` | The terms of that cheapest rate. | `Non-refundable · Breakfast not included · Pay now` | Same as above |
| `nightly_base_price` | The room rate without tax. Only filled when Booking.com states the tax inside the price; never worked out from an assumed rate. | `null` (tax charged on top in these runs) | Booking.com states no included tax |
| `taxes_and_fees_total` | All the tax Booking.com reports for the night: inside the price plus on top. | `21.73` | Booking.com states no tax |
| `tax_clarity_label` ⭐ | Whether the price is final. **✅ Taxes included**: every reported charge is inside the price. **⚠️ Part included, more on top**: add `taxes_excluded` for the real cost. **⚠️ Additional taxes reported**: tax is charged on top and the amount is known. **⚠️ Final cost not confirmed**: no breakdown was stated. | `⚠️ Additional taxes reported` | Sold out |
| `primary_tax_label` | The main tax line of the selected rate, in Booking.com's words. | `10 % Tax` | No tax line on the rate |
| `total_stay_price` | `nightly_total_price` × `number_of_nights`. With one night per row, the same number. | `217` | Same as `nightly_total_price` |
| `currency` | The currency of every price on the row. | `AUD` | Never |
| `price_basis` | What the price fields represent, as a short machine-readable token. | `1_night_2_guests_taxes_not_confirmed` · `sold_out` | Never |
| `is_genius_rate` | `true` when the price is a Genius (member) rate, not the public price. Never compare your public rate with a competitor's Genius rate. | `false` | No rate selected |
| `original_price` | The struck-through price, when Booking.com shows one on the room. | `null` | No strikethrough shown |
| `discount_percent` | The discount against `original_price`, computed on the legacy `price`. Do not recompute it against `nightly_total_price`. | `null` | No strikethrough shown |

#### Room rows: why this rate

A room usually has several rate plans. The row reports one, picked by fixed rules: it must fit your party, a refundable rate beats a cheaper non-refundable one, and among equals the cheapest wins.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `rate_selection_reason` ⭐ | Why this rate was picked, in plain words, including the cheaper rate it did not take. Written by the same rules that pick the rate; never by an AI. | `Selected the refundable rate compatible with 1 guest(s) per room; a cheaper non-refundable rate (180) was available but not selected, since a refundable rate at the same capacity is preferred.` | No rate could be selected |
| `occupancy_match_status` | How the rate was matched to your party: `capacity_compatible` (it fits), `uncertain_children` (children in the party, whose counting Booking.com does not state), `no_compatible_rate` (nothing fits). | `capacity_compatible` | Never |
| `max_guests` | The capacity of the selected rate: what it will take, not what you asked for. | `2` | No rate fits your party |
| `required_guests_per_room` | Your party split across your rooms: the capacity a rate must have to count. | `1` | Never |
| `rate_options_count` | How many rate plans Booking.com offers for this room tonight. `1` = the room is not fenced at all. | `1` | Never (0 when sold out) |
| `rate_spread` | Dearest minus cheapest rate plan for this room: how widely the property fences its rates. `0` = several plans at the same price. | `null` (one plan only) | The room has only one rate plan |
| `refundable_premium` | Cheapest refundable rate minus cheapest non-refundable rate: what flexibility costs here. | `null` | The room does not offer both |
| `primary_rate_option` | The selected rate as one object. Every value in it comes from the same Booking.com rate: price, public price, capacity, cancellation, free-cancellation date, meal plan, breakfast, pay later, Genius, wholesale, taxes and their breakdown. | `{ "price": 217, "max_guests": 2, … }` | Sold out, or no rate fits your party |
| `benchmark_rate_option` | The same object for a fixed 2-adult rate, whatever your search. For benchmarking only; not for "which room should this guest book". | `{ … }` | Same as above |
| `rate_options` | Every rate plan found for the room, in the page's raw shape, to inspect the alternatives. Also carries each plan's strikethrough price and tax. | `[ { … }, { … } ]` | Empty list when sold out |

#### Room rows: the booking terms

All of these describe the selected rate, the same one `nightly_total_price` comes from.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `booking_terms_summary` ⭐ | Cancellation, breakfast and payment in one line. A condition that is not known is left out, never written as "No". *Breakfast extra* (the page sells it) is told apart from *Breakfast not included*. | `Free cancellation · Breakfast not included · Pay now` | No rate selected |
| `cancellation_policy` | `fully_refundable`, `partially_refundable`, `non_refundable` or `unknown`. | `fully_refundable` | No rate selected |
| `is_refundable` | `true` only for `fully_refundable`. A partial refund is not free cancellation. | `true` | No rate selected |
| `free_cancellation_until` | The date in *"Free cancellation before …"*: cancel before it and the guest pays nothing. | `2026-09-29` | Non-refundable, or no date printed |
| `free_cancellation_days_before_arrival` | The same deadline as days before arrival: the policy itself, comparable across properties and dates. | `1` | No free-cancellation date |
| `meal_plan` | The meal line of the rate, in Booking.com's words. | `Good breakfast AUD 12` | The rate carries no meal line |
| `is_breakfast_included` | Whether breakfast is in the price. | `false` | No rate selected |
| `book_now_pay_later` | Whether the guest can book now and pay later. | `false` | No rate selected |
| `min_stay` | Minimum nights for the rate. **Not available today**: the number is not on the property page. | `null` | Always, for now |

#### Room rows: availability and stock

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `is_sold_out` | Booking.com's own sold-out flag for this room and night. | `false` | Never |
| `availability_status` | `available`, `low_availability` (4–5 left), `low_availability_urgent` (1–3 left) or `sold_out`. It only moves on a count Booking.com states, so it reads `available` whenever no count is shown. | `available` | Never |
| `availability_label` ⭐ | The same fact for a human: a colour and words. Also says when a room is on sale but has no rate for your party, and why a row has no data. | `🔴 Only 2 rooms left` · `⚫ Sold out` · `⚪ No rate for 1 guest` · `⚠️ Not scraped — the page could not be read` | Never |
| `rooms_left` ⭐ | Rooms left, **only** when Booking.com prints *"Only N left"*. `null` means unknown, not plenty. | `2` | Booking.com shows no count (the usual case) |
| `stock_confidence` ⭐ | How far to trust `rooms_left`: `exact` (Booking.com's own badge), `hidden` (on sale, no count shown), `inferred` (sold-out rows), `capped` (10, Booking.com's display ceiling). On the Perth run: 15 exact, 50 hidden, 175 inferred. | `exact` | Never |
| `bookable_now_label` | How many of this room a guest can still book, said no stronger than the page proves. `4 left`: Booking.com's badge, or a quantity list that stops below another room's. `10+`: at least that many. | `2 left` · `10+` | Sold out, or no quantity list on the page |
| `max_rooms_per_booking` | The top of this room's quantity list: the most a guest can put in one booking now. It can be the property's own ceiling rather than the stock; `bookable_now_label` says which. | `8` | No quantity list on the page |
| `max_nights_bookable` | Booking.com's own count of this room bookable at once, from the page's rate data. **Rooms, not nights**: the name is historical. Prefer `max_rooms_per_booking` and `bookable_now_label`. | `2` | Not in the page's rate data |
| `los_prices` | Booking.com's price for booking 1, 2, 3… of this room at once, for this night (the quantity list). Only with `includeLosAnalysis`. Not length-of-stay prices. | `{"1": 130, "2": 260, …}` | `includeLosAnalysis` is off |
| `has_los_discount` | `true` when booking several of this room costs less per room than one. Only with `includeLosAnalysis`. | `false` | `includeLosAnalysis` is off |

#### Room rows: what changed since the last run

These compare each room-night with the previous run of the same job. On the first run there is nothing to compare, so they read `✦ New` or `null`. All of them are empty when `trackPriceChanges` is off.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `price_move_label` ⭐ | The move at a glance: an arrow and its size. | `▲ +191 (+82.68%)` · `– Unchanged` · `✦ New` | Sold out now or at the last run: no price to compare |
| `price_direction` | The same as a token: `up`, `down`, `unchanged` or `new`. | `up` | Same as above |
| `price_change` | Today's `nightly_total_price` minus the last one. | `191` | First sighting (never `0`, which would say the price held) |
| `price_change_pct` | The change as a percentage of the last price. | `82.68` | Same as above |
| `previous_price` | The price at the last capture. | `231` | First sighting |
| `previous_captured_at` | When that last capture was. | `2026-09-24` | First sighting |
| `days_since_previous_capture` | Days since that capture. **Read it before `price_change`**: a move over five days is not a daily move. `0` on a second run the same day. | `1` | First sighting |
| `days_since_price_change` | How long the price had held up to this run. A competitor that has not moved in twelve days is a finding. | `3` | First sighting |
| `first_seen_price` | The first price ever recorded for this room-night. | `217` | Never priced yet |
| `first_seen_at` | When that first price was recorded. | `2026-09-22` | Never priced yet |
| `availability_change` | `new`, `unchanged`, `became_sold_out` or `came_back`. A room selling out often moves before any price does. | `came_back` | Price tracking off, or error rows |
| `days_sold_out` | How long the room has been off sale. On the run it comes back, how long it was gone. **On a come-back row the price is compared with the last price before the sell-out**, so the baseline is this many days older. | `3` | On sale and never sold out |
| `sold_out_episodes` | How many separate times this room-night has gone off sale. 3 or more: booked and cancelled repeatedly, or opened and closed by hand. | `1` | Price tracking off |

#### Room rows: the room

These are read from the room's own listing. Sold-out rows carry fewer of them.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `room_name` | The room's name exactly as Booking.com shows it. | `Standard Room` | Never |
| `bed_type` | The beds, as Booking.com lists them. | `1 extra-large double bed` | No bed line for the room |
| `room_capacity` | How many the room takes. When the page does not say, the largest capacity a rate proves: read it as *at least*. | `2` | Not stated and no rate proves it |
| `room_size_sqm` | Size in square metres. | `16` | Not stated |
| `room_view` | The view. | `Garden view` | Not stated |
| `has_private_bathroom` | Private or ensuite bathroom. | `true` | Not stated |
| `room_amenities` | The amenities that change the price: balcony, minibar, spa bath… | `["air conditioning"]` | Empty list when none |
| `facilities` | The room's full facility list, often 30–40 items. | `["Air conditioning", "Ensuite bathroom", "Flat-screen TV", "Free WiFi", …]` | Empty list when none |
| `photo_count` | How many photos Booking.com holds for this room type. | `7` | The page exposes none (never a false 0) |
| `room_photo_url` | Booking.com's first photo of the room, at the size Booking.com serves. No image is downloaded. | `https://cf.bstatic.com/xdata/images/hotel/max1024x768/116836127.jpg?k=…` | No photo exposed |

#### Room rows: the hotel

The same on every row of a property. The full snapshot is in the Hotel metadata dataset.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `hotel_name` | The name as Booking.com's page title gives it. Use `hotel_name_clean` for grouping. | `Flag Motor Lodge (Motel) (Australia) deals` | Never |
| `hotel_url` | The property page that was scraped. | `https://www.booking.com/hotel/au/flag-motor-lodge.html` | Never |
| `booking_hotel_id` | Booking.com's id for the property. | `1174220` | Not exposed |
| `hotel_review_score` | Review score, 0–10. | `7.5` | Not shown |
| `hotel_review_count` | Reviews behind that score. | `4265` | Not shown |
| `hotel_star_rating` | Official star rating. | `3` | No stars shown |
| `hotel_neighborhood` | The area, from Booking.com's location data. Best-effort outside big cities. | `Rivervale` | Not found |
| `hotel_address` | The full address. | `129 Great Eastern Highway, Rivervale, 6103 Perth, Australia` | Not shown |
| `property_type` | Hotel, motel, riad, apartment… | `Motel` | Not stated |
| `hotel_latitude` | Latitude. | `-31.9526` | Not exposed |
| `hotel_longitude` | Longitude. Also used to find the hotel's own "today". | `115.9159` | Not exposed |
| `hotel_checkin_from` | Check-in start time as Booking.com states it. Often not captured on live runs: read `null` as *not captured*. | `2:00 PM` | Not captured |
| `hotel_sustainability_certified` | Booking.com's Travel Sustainable badge. | `true` | Hotel details could not be read |

#### Room rows: the search and the run

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `requested_adults` | The adults this row was searched for. | `1` | Never |
| `requested_children` | The children this row was searched for. | `0` | Never |
| `requested_children_ages` | The children's ages sent to Booking.com. | `[4, 9]` | No ages given |
| `requested_rooms` | The rooms this row was searched for. | `1` | Never |
| `number_of_nights` | Nights covered by the row. Always 1. | `1` | Never |
| `proxy_country` | The country the page was loaded from. Booking.com prices by visitor country, so compare rows from the same country. | `US` | Never |
| `scrape_timestamp` | When the run started (UTC). | `2026-09-25T12:59:15Z` | Never |
| `sort_key` | hotel · date · room · room id, lowercased. Sort on it and any two exports line up row for row. | `flag motor lodge\|2026-09-30\|standard room\|117422001` | Never |

<details>
<summary><strong>Room rows: error and diagnostic rows</strong></summary>

A page that could not be read still gets a row, so a gap never hides. `availability_label` says what happened.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `record_type` | What the row is when it is not a priced room: `error` or `proxy_required`. | `error` | Priced rooms |
| `status` | `error`, or `no_rooms` when the page loaded but showed no rooms. | `error` | Priced rooms |
| `error_type` | Whose failure: `proxy` (never reached Booking.com), `page` (Booking.com answered, the page could not be read), `browser` (our browser died). | `page` | Priced rooms |
| `error_message` | The technical message. | `Page.goto: Timeout 45000ms exceeded` | Priced rooms |
| `received_search_url` | A `searchUrl` that was rejected as invalid. | `https://www.google.com/travel/…` | Only on that error |

</details>

<details>
<summary><strong>Room rows: legacy fields, kept for older integrations</strong></summary>

These come from an older fixed 2-adult benchmark. They stay so existing integrations keep working. **Never subtract them from `nightly_total_price`**: they may be a different rate plan.

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `price` | The cheapest rate plan on the room, whatever its capacity or terms. | `176` | Sold out |
| `price_public` | The non-Genius public price at a fixed 2 adults. | `176` | Sold out |
| `price_primary` | The price at a fixed 2 adults. | `176` | Sold out |
| `taxes_included` | Tax inside the selected rate's price. See `taxes_and_fees_total`. | `null` | None stated |
| `taxes_excluded` | Tax charged on top of the selected rate's price. | `17.64` | None stated |

</details>

#### Market by date: one row per property, per night

Built from the room rows of the same run, with no extra requests. With a single property in the run, every comparison field is `null`: rank 1 of 1 is not information.

The examples are Flag Motor Lodge on Wed 30 Sep, from the same Perth run.

**Which property, which night**

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `record_type` | Always `market_summary`. | `market_summary` | Never |
| `hotel_name_clean` | The property. | `Flag Motor Lodge` | Never |
| `hotel_url` | Its page. | `https://www.booking.com/hotel/au/flag-motor-lodge.html` | Never |
| `booking_hotel_id` | Booking.com's id for it. | `1174220` | Not exposed |
| `is_my_hotel` | `true` for your property. | `true` | `myHotelUrl` empty |
| `check_in_date` | The night. | `2026-09-30` | Never |
| `check_out_date` | The morning after. | `2026-10-01` | Never |
| `lead_time_days` | Nights between the run and arrival. | `5` | Date unreadable |
| `day_of_week` | Arrival weekday. | `Wed` | Date unreadable |
| `is_weekend` | Friday or Saturday arrival. | `false` | Date unreadable |
| `requested_adults` | The party searched. | `1` | Never |
| `requested_children` | Children searched. | `0` | Never |
| `requested_rooms` | Rooms searched. | `1` | Never |
| `proxy_country` | The country the prices were seen from. | `US` | Never |
| `currency` | The currency of every price. | `AUD` | Never |
| `scrape_timestamp` | When the run started (UTC). | `2026-09-25T12:59:15Z` | Never |
| `sort_key` | For stable sorting across exports. | `flag motor lodge\|2026-09-30\|\|` | Never |

**The property's own night**

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `lead_in_rate` ⭐ | The cheapest rate your party can book at the property that night, across its rooms and whatever the terms. The ranking is built on it. | `217` | Nothing bookable |
| `median_rate` | The median `nightly_total_price` of its rooms on sale. | `217` | Nothing bookable |
| `highest_rate` | Its most expensive room on sale. | `217` | Nothing bookable |
| `rate_range` | `highest_rate` − `lead_in_rate`: how far it spreads its rooms that night. | `null` (one room on sale) | Fewer than two rooms on sale |
| `refundable_premium_median` | The median cost of flexibility across its rooms. | `null` | No room offers both |
| `taxes_on_top` | `true` when every rate on sale adds tax on top of the price shown, `false` when every rate has it inside. Compare properties on the same basis. | `true` | The rates disagree or do not say |

**Demand**

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `room_types_offered` | Room types the property lists at all. | `6` | Sold-out tracking off |
| `room_types_available` | Room types bookable that night for your party. | `1` | Never |
| `room_types_sold_out` | Room types listed but not bookable. | `5` | Sold-out tracking off |
| `sold_out_ratio` | Sold out ÷ offered: how much of the property is gone. The honest demand signal. | `0.833` | Sold-out tracking off |
| `rooms_length_restricted` | Room types on sale that are down to their last room. The name is historical. | `0` | Never |
| `restricted_ratio` | That count ÷ rooms on sale. Reported, but does not move `demand_pressure`. | `0.0` | Nothing on sale |
| `demand_pressure` ⭐ | `sold_out`, `high` (half or more gone), `building` (some gone) or `normal` (none). Never derived from a room count. | `high` | Sold-out tracking off |
| `demand_pressure_reason` | The numbers behind the level, in words. | `5 of 6 room types unavailable` | No room types at all |

**Against the others that night**

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `compset_size` | Properties with a bookable rate that night. The comparison is only this wide. | `4` | Only one property in the run |
| `compset_rank` ⭐ | This property's place by `lead_in_rate`, 1 = cheapest. A sold-out property takes no rank. | `2` | Sold out, or no one to compare with |
| `market_lead_in_rate` | The cheapest `lead_in_rate` that night. | `180` | No one to compare with |
| `market_median_rate` | The median `lead_in_rate` of every property that night, **yours included**. The daily digest compares you with your competitors only (`317` that night). | `267` | No one to compare with |
| `gap_to_market_median` ⭐ | This property minus that median. Positive = above the market. | `-50` | Sold out, or no one to compare with |
| `gap_to_cheapest` | This property minus the cheapest. | `37` | Same as above |
| `price_position` | `cheapest`, `below_median`, `at_median`, `above_median`, `most_expensive`, or `sold_out`. | `below_median` | No one to compare with |

**Coverage: did every property load?**

| Field | What it tells you | Example | Empty when… |
|---|---|---|---|
| `compset_expected` | Properties the run asked for. | `4` | Never |
| `compset_observed` | Properties that produced data that night. Fewer means one failed, and its absence moves the median. | `4` | Never |
| `compset_coverage` | Observed ÷ expected. Below 1.0, every market figure on the row is built on an incomplete set. | `1.0` | Never |
| `coverage_warning` | A sentence naming how many properties are missing. | `null` | The set is complete |

#### Hotel metadata: one row per property, per run

| Field | What it tells you | Example |
|---|---|---|
| `record_type` | Always `discovery`. | `discovery` |
| `hotel_url` | The property page. | `https://www.booking.com/hotel/au/flag-motor-lodge.html` |
| `hotel_name` | Its name, as the page title gives it. | `Flag Motor Lodge (Motel) (Australia) deals` |
| `scrape_timestamp` | When the run started (UTC). | `2026-09-25T12:59:15Z` |
| `proxy_country` | The country it was loaded from. | `US` |
| `hotel_metadata` | The full snapshot: review score, count and breakdown, photo count, neighbourhood, address, property type, description, **every hotel facility**, star rating, Booking.com id, coordinates, check-in time, sustainability badge. | `{ "review_score": 7.5, "hotel_facilities": [ … ], … }` |

#### The daily digest, line by line

The morning read of a run, built only from that run's own figures. This is the real one from the Perth run of 25 September, shortened:

```text
Booking.com rate shop · Fri 25 Sep
4 properties · 10 nights (Fri 25 Sep – Sun 04 Oct) · 1 adult · AUD
Prices are before tax, as Booking shows them from US: the guest pays the tax on top.

👉 Wed 30 Sep: you are 100 under your competitors' median (217 vs 317).

YOUR HOTEL — Flag Motor Lodge
• Cheapest on 0 of 4 ranked nights; most expensive on 1.
• Above the market Sat 03 Oct: 199 vs competitors' median 181 (+18).
• Below the market Wed 30 Sep: 217 vs competitors' median 317 (−100).
• You are sold out: Fri 25 Sep, Sat 26 Sep, Sun 27 Sep, Mon 28 Sep, Tue 29 Sep, Thu 01 Oct.

NEXT NIGHTS — lead-in (cheapest bookable rate)
• Mon 28 Sep · cheapest 180 (ibis Budget Perth Airport) · competitors' median 272 · you: sold out · 1 of 4 sold out
• Wed 30 Sep · cheapest 180 (ibis Budget Perth Airport) · competitors' median 317 · you 217 (#2 of 4)

NIGHTS TO WATCH — the market is filling
• Sun 27 Sep: 3 of 3 competitors sold out

COMPETITOR MOVES SINCE THE LAST RUN
🟢 Great Eastern Motor Lodge · Fri 25 Sep: Superior Double or Twin Suite back on sale at 317 (249 before it sold out)
⚫ Sanno Marracoonda Perth Airport Hotel · Fri 25 Sep: King Room sold out

Run: ✅ 40 of 40 pages, 240 rows.
```

| Part | What it says |
|---|---|
| **Header** | The hotel's own date, how many properties and nights, the party and the currency. A line under it says when the prices are before tax. |
| **👉 The one line** | 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 your competitors, otherwise furthest over. |
| **Your hotel** | Your position against your **competitors' median**, your own rate left out: a median that includes you moves when you move. It needs two competitor rates. |
| **Next nights** | The next 7 nights: the cheapest property, the competitors' median, your rate and rank, and how many are sold out. A night with a property that could not be read says so before its numbers. |
| **Nights to watch** | Nights where half or more of your competitors have nothing left to sell. |
| **Competitor moves** | Price moves, sell-outs and rooms back on sale since the last run. A room back on sale is shown as a return, with the price it had before it sold out. |
| **⚠️ Run** | Only when pages failed: how many, which hotel and which nights, and what to do. |

***

### 🔍 Reading it honestly

- **`null` is never 0.** An empty price, count or tax means Booking.com did not state it. It is never filled with a guess.
- **The price is what Booking.com shows from the proxy country.** Booking.com shows US visitors the price **before tax**, with the tax on top. Elsewhere it depends on the country and the property. On the Perth runs every rate was 10 % under what the guest pays (217 shown, 239 paid). `tax_clarity_label` on each row and `taxes_on_top` on each night say which one you are looking at.
- **Booking.com prices by visitor country.** The same room can cost more or less for a French visitor than for a US one. Compare rows from the same `proxy_country`, with the same `currency`. To see prices closer to what local guests see, load the pages from the hotel's own country, in its currency.
- **One night per row.** Multi-night stays are not quoted.
- **Genius rates are flagged, not hidden.** `is_genius_rate` tells you when a price is a member rate.
- **A sold-out room still has a row**, marked ⚫, so a filling date never looks cheaper than it is. With `includeSoldOut` off, those rows and the demand read disappear.
- **Two runs minutes apart can differ.** Booking.com tests prices and layouts on its visitors. Every row carries its capture time and country.
- **Known gaps.** `min_stay` is always `null` (the number is not on the page). `hotel_checkin_from` is often not captured. `hotel_neighborhood` is best-effort outside big cities.

***

### 📥 Input

One of `hotelUrls`, `searchUrl` or `searchText` is required.

**What to scrape**

| Field | Default | What it does |
|---|---|---|
| `hotelUrls` | — | Booking.com hotel pages. The plain URL is enough; dates in the URL are ignored. |
| `myHotelUrl` | — | Your own property, also kept in `hotelUrls`. Every row of yours gets `is_my_hotel: true` and the digest ranks you. |
| `searchUrl` | — | A Booking.com search-results URL with your filters. Every hotel found is scraped. |
| `searchText` | — | A free-text destination, such as `riad with pool in fes`. Used only when the two above are empty. |
| `maxProperties` | `50` | The most hotels to take from a search. |
| `propertyType` | — | Filter a `searchText` search by type. |
| `minReviewScore` | `0` | Skip hotels below this score. `0` = no limit. |
| `maxReviewScore` | `0` | Skip hotels above this score. `0` = no limit. |

**Dates and party**

| Field | Default | What it does |
|---|---|---|
| `startDate` | today | First check-in, `YYYY-MM-DD`. Empty = today **at each hotel**. Leave it empty on a schedule: a fixed date slides into the past. |
| `daysAhead` | `3` | How many nights to price (1–365). Each night is one page per hotel, so this drives time and cost. |
| `adults` | `2` | Adults in the party. It decides which rate is selected. |
| `children` | `0` | Children in the party. |
| `childrenAges` | — | One age per child (0–17). Booking.com prices a family by age. |
| `rooms` | `1` | Rooms. Capacity is checked per room. |
| `currency` | `USD` | One of 23 currencies, the same for every price. |

**What to keep**

| Field | Default | What it does |
|---|---|---|
| `includeSoldOut` | `true` | Keep a row for every sold-out room. Needed for the demand read. Every row is a result you pay for, and on busy dates most rows can be sold out. |
| `trackPriceChanges` | `true` | Compare with the last run and fill the change fields. No extra requests. |
| `historyStoreName` | `booking-price-history` | Leave it as it is. Give two jobs different names only if their history must not mix. Renaming starts the history over. |
| `includeLosAnalysis` | `false` | Adds `los_prices` and `has_los_discount` (booking several of a room at once). No extra page loads. |
| `includeWholesaleRates` | `false` | Also capture partner and wholesale rates, tagged apart from retail. |
| `tieredCadence` | `false` | Near nights daily, mid nights every 2 days, far nights every 3 days. About half the cost for long horizons. |

**Digest and alerts**

| Field | Default | What it does |
|---|---|---|
| `digestEmail` | — | Email the daily digest, through Apify's Send Email Actor. Some Apify plans cannot start it: use the webhook below instead. |
| `digestWebhookUrl` | — | Slack or Discord webhook: the digest as a message. Any other URL (n8n, Make, Zapier): JSON `{"text": …, "digest": {…}}`. |
| `webhookUrl` | — | Slack or Discord webhook for error alerts. |

**Proxy and speed**

| Field | Default | What it does |
|---|---|---|
| `proxyConfiguration` | Residential | The Apify proxy picker. Booking.com blocks datacenter IPs sooner, and more so on larger runs. |
| `proxyCountries` | `["US"]` | The countries to load the pages from. Several countries = the same hotels once per country, tagged with `proxy_country`. |
| `useApifyProxy` | `true` | Old field, hidden from the form. `proxyConfiguration` is read first. |
| `concurrency` | `3` | Hotels scraped at once (1–5), each in its own browser. Lower it if rows come back *"our browser died"*. |
| `delayBetweenRequests` | `3` | Seconds between two nights of the same hotel. |

***

### 📅 Scheduling and history

The change fields and the competitor moves compare each run with the previous one. The previous prices live in a named key-value store, **`booking-price-history`**, in your Apify Storage.

- **Do not delete that store.** It is the memory. Delete it and every row reads `✦ New` again until the history rebuilds. Deleting a run's dataset changes nothing: the next run never reads it.
- **Run at the same hour every day.** A second run on the same day compares with the earlier one, so it shows only a few hours of moves.
- **A room-night is known by** hotel + night + room + party + currency + proxy country. Change the party, the currency or `historyStoreName` and every row starts again as `✦ New`. Adding a hotel or a country starts only that one. Changing `daysAhead`, `startDate`, `concurrency` or the toggles changes nothing.
- **A page that fails keeps its older baseline.** The next run compares with the last time it was read, and `days_since_previous_capture` says how long ago that was.
- **The store cleans itself.** An entry is dropped once its night has passed, or after 30 days without being seen.

***

### 📬 Getting the digest

- **By email:** fill `digestEmail`. On plans where Apify refuses to start its Send Email Actor from inside a run, the log says *"Digest email NOT sent"*.
- **Anywhere, on any plan:** put an n8n, Make or Zapier webhook in `digestWebhookUrl` and send the email from there. The JSON carries the text and every figure in structured form.
- **Always:** the digest is also saved with the run, as `DIGEST` (text) and `DIGEST_DATA` (JSON), and linked on the Output tab.

***

### 🧯 When a run goes wrong

Every run writes a report, `RUN_REPORT`, into its key-value store, and its headline becomes the run's status line.

| `verdict` | Meaning |
|---|---|
| `complete` | Every page returned data. |
| `partial` | Some nights failed. **Every row you got is real.** The report lists the hotels and nights to re-run, and the digest names them. |
| `failed` | Nothing came back. The headline names the cause and `what_to_do` gives the fix. |
| `crashed` / `nothing_attempted` | The run stopped before scraping, or the input had nothing to scrape. |

A row that could not be read says why in `availability_label`, and `error_type` says whose failure it was: `proxy`, `page` or `browser`.

<details>
<summary><strong>Every row is an error: proxy troubleshooting</strong></summary>

If every row says `error_type: proxy`, nothing reached Booking.com, and your hotel URLs and dates are not the problem. Check, in this order:

1. **Residential proxy data quota** (Apify Console → Billing). The most common reason a run that worked yesterday fails today.
2. **The country.** A single country in `proxyCountries` can be empty for your account when a wider choice is not. Try removing it.
3. **The plan.** Residential proxy is not on the free plan. Datacenter works for small runs; Booking.com blocks it sooner.
4. **Concurrency.** Each hotel in parallel holds its own IP. Lower `concurrency` to 1 or 2 on a thin pool.

The run tries your proxy settings first, then wider ones, and uses the first that really connects. A failed tunnel is retried on up to two new IPs before an error row is written.

</details>

<details>
<summary><strong>Checking an export, and testing extraction offline</strong></summary>

```bash
## read an export the way a careful reader would: holes in the grid, labels that
## contradict their price, a property that lost its history…
python3 tools/audit_export.py export.csv

## run the Actor's real extraction against a page saved from your browser
pip install playwright && playwright install chromium
python3 tools/offline_extract.py my-saved-page.html
```

`audit_export.py` exits with code 1 when something is broken, so it fits in a script.

</details>

***

### 💰 Pricing

You pay per result: **from $5.00 per 1,000 results**. Your plan's price is on the Pricing tab. Every row is a result, sold-out rows included.

The Perth example, 4 motels × 10 nights, wrote 240 rows. To pay for fewer rows:

- price fewer nights (`daysAhead`);
- turn on `tieredCadence` for long horizons;
- turn off `includeSoldOut`, knowing you lose the demand read.

***

### 🤖 Use it from Claude or ChatGPT

With the [Apify MCP server](https://mcp.apify.com), an AI assistant can run this Actor and read its results. Add `noraview/Booking-price-scraper` to the MCP server's tools in your AI client, with your own Apify API token, and keep that token private.

When you hand it the results, point it at the **AI input (compact)** view, and tell it: *my hotel is the row where `is_my_hotel` is true*.

***

### ❓ FAQ

**Which price should I compare on?**
`nightly_total_price` for a like-for-like comparison, `lowest_bookable_rate` for the price a guest sees first. Read `tax_clarity_label` before treating either as final.

**Why is the price different from what I see on Booking.com?**
Usually one of four things: you see it from another country (`proxy_country`), with tax included (`tax_clarity_label`), as a Genius member (`is_genius_rate`), or in another currency.

**Why are so many rows sold out?**
Because the rooms are. On busy nights most room types of a small property are gone, and those rows are the demand signal. Turn off `includeSoldOut` if you only want what is on sale.

**What does `rooms_left: null` mean?**
Unknown, not plenty. Booking.com shows a count only when it wants to push a booking.

**How often should I run it?**
Once a day, at the same hour. `tieredCadence` keeps long horizons affordable.

**Can it scrape more than 10 hotels?**
Yes. Use `searchUrl` or `searchText` with `maxProperties`. Around 10 per run is the sweet spot for speed and reliability.

***

### 📝 What's new

The last three releases. **[Full changelog →](CHANGELOG.md)**

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

- 🐞 **Your hotel was compared with a median that included your own rate.** On the Perth run of 24 Sep the email read *"18 under the market median (245 vs 263)"*; the three competitors' median was **281**, a gap of **36**. The digest now compares you with your competitors' median, your rate left out
- ⚠️ **The email now says when prices are before tax.** Booking.com shows US visitors the rate before tax. The digest says so under its header, from the new `taxes_on_top` in Market by date
- 🐞 **A room back on sale read as a price jump** (*"+110.4% (173 → 364)"* for a room that was sold out at the last run). It now reads *"back on sale at 364 (173 before it sold out)"*
- 🐞 **Pages that could not be read are named** in the run block, on the night's line and in your own section

</details>

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

- 🐞 With Track Sold-Out Rooms off, `room_types_offered`, `room_types_sold_out`, `sold_out_ratio` and `demand_pressure` are now left empty instead of reading "normal"
- ℹ️ The input states the trade-off: sold-out rows are results you pay for, and they carry the demand read

</details>

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

- ✅ **👉 One line first**, under the header: the strongest signal of the run
- 🐞 **Nights to watch** now lists only nights where half or more of your competitors have nothing left to sell
- 🐞 The digest carries **the hotel's date**, not the server's

</details>

***

### 💬 Support

A question, a number that looks wrong, or an idea? Open an issue on the Issues tab. Every message is read.

Built by **HotelSignals Lab**.

# Changelog

This Actor's version history is a separate document: https://apify.com/noraview/booking-price-scraper/changelog.md

# Actor input Schema

## `searchText` (type: `string`):

e.g. 'riad with pool in fes'. Used only if searchUrl and hotelUrls are empty. Builds the Booking search URL automatically.

## `searchUrl` (type: `string`):

Paste a Booking.com search results URL with all your filters applied (stars, meal plan, distance, etc). Must be a booking.com link — Google Travel, Traveloka, and other sites are not supported.

## `hotelUrls` (type: `array`):

Add Booking.com hotel URLs here to scrape specific hotels directly. If provided, destination mode is skipped.

## `maxProperties` (type: `integer`):

Maximum number of hotels to scrape from search results.

## `startDate` (type: `string`):

Check-in date. Leave empty for today — today at each hotel, so a run after midnight in Perth or Sydney starts on their new day instead of asking for a night that has already passed. Leave it empty on a daily schedule; a fixed date slides into the past.

## `daysAhead` (type: `integer`):

Number of days to scan forward. 7-14 covers the booking window most decisions are made in; 30+ shows the season taking shape. Each day is one page per hotel, so this is also the setting that drives run time and cost.

## `includeSoldOut` (type: `boolean`):

Detect which rooms are sold out. Essential for demand analysis and revenue management. A competitor's sold-out room tells you more than their price: it is the date filling up, and it usually happens before anyone reprices. With this on, a room that cannot be booked still gets a row, marked ⚫ Sold out, instead of vanishing from the comparison and quietly making the market look cheaper than it is. Cost: 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). Switching this off cuts those rows — and with them the demand read: a sold-out room simply disappears, and market\_summary leaves sold\_out\_ratio and demand\_pressure empty rather than guess.

## `trackPriceChanges` (type: `boolean`):

Adds Move, Δ Rate, Δ %, Days at this price and Availability change to every row — what the price and the availability did since the last run. Costs no extra requests: the last price of each room-night is kept between runs and compared on the next one. Turn this off only if you want pure snapshots with no memory. With it on you stop finding out three days late that a competitor cut 12%, and you can tell an actively yielded rate from one nobody has touched in a fortnight.

## `myHotelUrl` (type: `string`):

Optional. Paste YOUR property's URL here, and keep it in the hotel list above as well. Every row of yours is then marked is\_my\_hotel: true — so you can put your own rates at the top of the screen, and tell an AI "my hotel is the row where is\_my\_hotel is true" instead of letting it guess from a name. Any form of the URL works: query strings, check-in dates and language suffixes (.fr.html) are ignored. Leave it empty if you are watching a market rather than pricing a property.

⚠️ Whether or not you fill this in, put your hotel AND its competitors in the SAME run. The ranking compares properties inside one run, so scraping them one at a time leaves compset\_rank, market\_median\_rate and every gap column empty — prices with nothing to compare them to.

## `historyStoreName` (type: `string`):

Leave this empty. It fills itself with 'booking-price-history' and you never have to think about it. Change it only when you run two separate monitoring jobs that must not mix their history — a different compset, a different currency, a different occupancy — and give each job its own name. Changing this name on an existing job starts its history over from zero.

## `digestEmail` (type: `string`):

One or more email addresses, separated by commas. Sent through Apify's own Send Email Actor, on the account that runs this Actor (a fraction of a cent per run). Some Apify plans cannot start another public Actor from inside a run — the log then says "Digest email NOT sent". The reliable way to get it by email on any plan: a Make, Zapier or n8n webhook in the field below, with an email step behind it.

## `digestWebhookUrl` (type: `string`):

A Slack incoming-webhook URL or a Discord webhook URL gets the digest as a message. Any other URL (Make, Zapier, n8n, your own endpoint) receives JSON: {"text": …, "digest": {…}} with every figure in structured form. Separate from the error-alert webhook under Alerts.

## `currency` (type: `string`):

Output currency for all prices. The scraper handles all currency formats automatically (Indian Lakh, European thousands, etc).

## `propertyType` (type: `string`):

Filter by property type (only applies when using destinationId, not searchUrl).

## `minReviewScore` (type: `number`):

Minimum review score (0-10). Example: 8.5

## `maxReviewScore` (type: `number`):

Maximum review score (0-10). Leave 0 for no limit.

## `adults` (type: `integer`):

Number of adults.

## `children` (type: `integer`):

Number of children. Fill in their ages below too — Booking.com prices a family stay by each child's age.

## `childrenAges` (type: `array`):

One entry per child, age 0-17 at check-in (e.g. 4 and 9). Booking.com needs every child's age to price a family stay: infants are often free and teenagers usually count as adults. Sets the number of children when the two disagree. Leave empty for adults-only searches.

## `rooms` (type: `integer`):

Number of rooms.

## `proxyConfiguration` (type: `object`):

Standard Apify proxy picker. Every request leaves from one IP, and Booking.com does block datacenter and shared IPs — the risk grows with the number of properties, dates and parallel hotels, so a large or frequent run wants RESIDENTIAL. A small daily run often works without any proxy at all, and that is cheaper; the sign to switch it on is rows coming back '⚠️ Not scraped — Booking.com served a block page'. The country chosen here is used only when 'Proxy Countries' below is empty; set that field to scrape the same hotel from several source markets in one run.

## `useApifyProxy` (type: `boolean`):

Deprecated and hidden from the form — 'Proxy configuration' above replaces it and is read first. Kept so saved tasks and API callers that send this key keep working: when proxyConfiguration is absent from the input, this boolean still decides whether a RESIDENTIAL proxy is used.

## `proxyCountries` (type: `array`):

OPTIONAL. Leave empty to use the single country from 'Proxy configuration' above. Fill it to scrape the same hotels once PER country and compare geo-pricing — each result is tagged with proxy\_country. Accepts any 2-letter code your Apify Residential proxy offers (not a fixed list): FR, US, GB, DE, ES, IT, NL, BE, PL, CH, CN, HK, SG, JP, KR, AE, SA, MA, IN, ID, AU, CA, BR… A code your plan lacks falls back to a weaker proxy and says so in the log. Keep the same currency across countries for a clean cohort comparison.

## `concurrency` (type: `integer`):

How many hotels are scraped at the same time, per country. Each one runs its own browser on its own proxy IP, so this multiplies both speed and memory: 3 hotels in parallel is three browsers in the same container. Raise it for a faster run on a small compset. Lower it to 1 or 2 if a run comes back with rows marked '⚠️ Not scraped — our browser died mid-run' — that is the container running out of memory, and fewer browsers fixes it.

## `delayBetweenRequests` (type: `integer`):

Wait time between each date scrape (per hotel). Higher = safer but slower.

## `includeLosAnalysis` (type: `boolean`):

Adds los\_prices: what Booking.com charges for booking 1, 2, 3… of each room at once for the night, and has\_los\_discount when booking several is cheaper per room. These are Booking's quantity dropdown prices, not multi-night quotes. No extra page loads.

## `includeWholesaleRates` (type: `boolean`):

Capture partner/wholesale rates (Hotelbeds, Expedia, etc). Important for Asian markets. Tagged separately as is\_wholesale, never mixed with retail rates. This is the rate a bed bank is reselling a competitor at — often well under their own published price, and what your guest actually compares you against on those channels.

## `tieredCadence` (type: `boolean`):

Scrape near dates daily, mid dates every 2 days, far dates every 3 days. Reduces cost ~50%. Best for your own monitoring. The logic is that a date 90 days out rarely moves overnight while next week does. One catch if you also track price changes: it makes the gap between captures uneven, so read the Days since last check column before calling a far-date move a daily one.

## `webhookUrl` (type: `string`):

Slack/Discord webhook for error alerts.

## Actor input object example

```json
{
  "hotelUrls": [
    {
      "url": "https://www.booking.com/hotel/ma/fashion-boutique.fr.html",
      "method": "GET"
    }
  ],
  "maxProperties": 50,
  "daysAhead": 3,
  "includeSoldOut": true,
  "trackPriceChanges": true,
  "historyStoreName": "booking-price-history",
  "currency": "USD",
  "minReviewScore": 0,
  "maxReviewScore": 0,
  "adults": 2,
  "children": 0,
  "rooms": 1,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "useApifyProxy": true,
  "proxyCountries": [
    "US"
  ],
  "concurrency": 3,
  "delayBetweenRequests": 3,
  "includeLosAnalysis": false,
  "includeWholesaleRates": false,
  "tieredCadence": false
}
```

# Actor output Schema

## `rateShoppingTable` (type: `string`):

No description

## `rateShoppingV2Table` (type: `string`):

No description

## `marketByDate` (type: `string`):

One row per property per night: compset\_rank, lead\_in\_rate, market\_median\_rate, gap\_to\_market\_median and demand\_pressure. Filled when the run holds two or more properties.

## `dailyDigest` (type: `string`):

Your position night by night, the nights the market is filling, competitor moves since the last run, and the run's own health — in one screen of text.

## `fullDetailTable` (type: `string`):

No description

## `downloadCsv` (type: `string`):

No description

## `downloadExcel` (type: `string`):

No description

## `totalResults` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "hotelUrls": [
        {
            "url": "https://www.booking.com/hotel/ma/fashion-boutique.fr.html",
            "method": "GET"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    },
    "proxyCountries": [
        "US"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("noraview/booking-price-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "hotelUrls": [{
            "url": "https://www.booking.com/hotel/ma/fashion-boutique.fr.html",
            "method": "GET",
        }],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
    "proxyCountries": ["US"],
}

# Run the Actor and wait for it to finish
run = client.actor("noraview/booking-price-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "hotelUrls": [
    {
      "url": "https://www.booking.com/hotel/ma/fashion-boutique.fr.html",
      "method": "GET"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "proxyCountries": [
    "US"
  ]
}' |
apify call noraview/booking-price-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,noraview/booking-price-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/1yurDJUcsLRpJIH9v/builds/abcs0wdNUeKcfeegk/openapi.json
