# 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:** 312 total users, 52 monthly users, 94.7% runs succeeded, 10 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $5.00 / 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.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

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

Extract **complete room-level pricing** from any Booking.com hotel — every room type, every rate option, every date — up to 365 days ahead.

Most Booking.com scrapers on Apify Store return one price per hotel. This **Booking.com hotel price scraper** goes deeper: it opens each property page and extracts granular room-by-room data — prices, rate options, availability signals, meal plans, discounts, facilities, and sold-out detection. Whether you search for a *Booking.com price tracker*, a *Booking.com scraper for hotel prices*, or a *hotel room price API*, this Actor covers all three: one tool, room-level output.

**One run = complete pricing visibility for your competitive set.**

💡 Paste hotel URLs, set your date range, hit Start. Results ready in JSON, CSV, Excel, or HTML.

***

### 🎯 Who is this Booking.com scraper for?

- **Revenue managers** monitoring competitor hotel pricing daily
- **Hotel operators** tracking market positioning at the room-type level
- **Travel tech companies** feeding structured Booking.com pricing data into dashboards or algorithms
- **Market analysts** studying seasonal hotel demand and pricing trends
- **Consultants** building competitive intelligence reports for hospitality clients

Search terms this Actor answers: *Booking.com price scraper*, *Booking.com room price tracker*, *hotel price scraper*, *hotel room availability scraper*, *Booking.com API alternative*, *hotel competitor rate tracking*.

***

### 📥 Input

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `hotelUrls` | array | ✅ | — | Booking.com hotel URLs (direct mode) |
| `searchUrl` | string | ❌ | — | A Booking.com **search results** URL — scrape every hotel matching your filters instead of listing URLs one by one |
| `searchText` | string | ❌ | — | Free-text destination search (e.g. `"riad with pool in fes"`) — the Actor builds the Booking.com search URL for you |
| `daysAhead` | integer | ❌ | 10 | Number of days to scrape (1–365) |
| `startDate` | string | ❌ | Today | Start date in `YYYY-MM-DD` format |
| `currency` | string | ❌ | USD | 23 currencies: USD, EUR, GBP, CNY, MAD, AED, SAR, CAD, AUD, JPY, KRW, CHF, PLN, BRL, INR, SGD, HKD, TWD, THB, MXN, ZAR, COP, IDR |
| `adults` / `children` / `rooms` | integer | ❌ | 2 / 0 / 1 | Occupancy for the price search |
| `includeSoldOut` | boolean | ❌ | true | Include sold-out dates in results |
| `includeWholesaleRates` | boolean | ❌ | false | Capture partner/wholesale rates (`bbasic`), tagged separately as `is_wholesale` |
| `proxyCountries` | array | ❌ | `["US"]` | **Multi-market override.** Leave empty to use the single country from `proxyConfiguration`. Fill it to scrape the same hotels **once per country** and compare geo-pricing — each result is tagged with `proxy_country`. Any code your Apify 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, with a warning in the log |
| `concurrency` | integer | ❌ | 3 | How many hotels to scrape in parallel (1–5). Each hotel gets its own proxy session |
| `delayBetweenRequests` | integer | ❌ | 3 | Seconds between page loads (anti-blocking) |
| `proxyConfiguration` | object | ❌ | Residential | **Standard Apify proxy picker** (Datacenter / Residential / Own proxies / No proxy). Residential is strongly recommended — Booking.com blocks datacenter and shared IPs within a request or two. The country selected here is used only when `proxyCountries` is empty |
| `useApifyProxy` | boolean | ❌ | true | *Deprecated, hidden from the form.* `proxyConfiguration` replaces it and is read first. Still honoured when `proxyConfiguration` is absent, so saved tasks and API callers sending this key keep working — including `false` to run with no proxy |
| `maxProperties` | integer | ❌ | 50 | Cap on how many hotels to take from `searchUrl` / `searchText` discovery |
| `propertyType` | string | ❌ | — | Filter discovery by property type. Applies to destination search only, not to `searchUrl` |
| `minReviewScore` / `maxReviewScore` | number | ❌ | 0 / 0 | Filter discovered hotels by review score (0–10). `0` on either side means "no limit" |
| `includeLosAnalysis` | boolean | ❌ | false | Extract multi-night pricing to detect length-of-stay discounts — adds `los_prices` and `has_los_discount` to every row |
| `tieredCadence` | boolean | ❌ | false | Cost saver: scrape near dates daily, mid dates every 2 days, far dates every 3. Cuts cost ~50% — best for your own ongoing monitoring rather than a one-off export |
| `webhookUrl` | string | ❌ | — | Slack/Discord webhook for error alerts |

#### Example input

```json
{
  "hotelUrls": [
    "https://www.booking.com/hotel/ma/savoy-le-grand.html",
    "https://www.booking.com/hotel/ma/longue-vie-hotels.html"
  ],
  "daysAhead": 30,
  "currency": "USD",
  "adults": 2,
  "rooms": 1,
  "includeSoldOut": true,
  "useApifyProxy": true,
  "proxyCountries": ["FR", "US", "GB"]
}
```

💡 **Tip:** You only need the base hotel URL — query parameters like `?checkin=...` are automatically handled. Works with any Booking.com property worldwide: hotels, riads, apartments, resorts, hostels, guesthouses.

💡 **Multi-country tip:** Booking.com applies geo-pricing based on visitor location. Scraping from multiple `proxyCountries` (e.g. `["US", "GB", "FR"]`) reveals how the same room is priced for different source markets. Keep `currency` fixed across countries so the difference you see is the geo-pricing cohort, not a currency conversion.

💡 **Why `US` + `USD` by default:** they match. Booking.com shows a French visitor prices in EUR, so a `FR` proxy asking for `USD` returns a *converted* number — exactly the conversion artifact the tip above warns about. Pair your proxy country with its native currency (`FR`→`EUR`, `GB`→`GBP`, `CN`→`CNY`) when you want the price a local actually sees.

⚠️ **About the proxy:** Residential is the default for a reason — Booking.com actively rate-limits and blocks datacenter/shared IPs. Turning it off to save cost will very likely produce 0 results on any run beyond a couple of pages.

***

### 📤 Output

Each result represents **one room type for one date**. A hotel with 8 room types scraped across 30 days produces ~240 results — a complete pricing matrix.

#### Three views, one dataset

The room dataset ships with three table views. They are **projections over the exact same records** — no view filters, reduces, or duplicates the data. Pick the tab that matches what you're doing:

| View | Who it's for | What it shows |
|------|--------------|---------------|
| **Rate shopping** *(default)* | Revenue managers, competitor monitoring | 21 columns: price per room, per date, per proxy country — net rate, taxes and gross side by side, plus sold-out state, availability, cancellation terms and the capture timestamp so runs stack into a time series |
| **Room comparison** | Travelers picking a room | 23 columns: room attributes a guest actually chooses on — size, bed, view, private bathroom, amenities, review score — next to price and cancellation terms, with sold-out state shown *before* the price so a blank is explained |
| **AI room picker** | AI agents and automations | 41 columns: full provenance and request context — hotel ID, hotel URL, proxy country, capture time, requested occupancy, room ID, canonical prices, `price_basis`, `occupancy_match_status`, availability, `stock_confidence` and the occupancy-matched `primary_rate_option` — without the `facilities` / `rate_options` bulk that blows an LLM context window |

**Nothing is hidden.** The complete schema — `rate_options`, `benchmark_rate_option`, occupancy context, the full `facilities` list, hotel context, all legacy price fields — is always available under the **All fields** tab, in the CSV/Excel exports, and at the plain API items endpoint. Add `?view=rate_shopping`, `?view=room_comparison` or `?view=ai_room_picker` to that endpoint to get a specific projection instead:

```
https://api.apify.com/v2/datasets/<datasetId>/items?view=ai_room_picker
```

<details>
<summary><strong>Feeding the AI room picker to an LLM (click to expand)</strong></summary>

The point of this view is that a full run does not fit in a context window. A 60-room × 30-day run is ~1,800 rows; at the full schema that is `facilities` (30–40 strings) and every `rate_options` entry per row. The `ai_room_picker` projection keeps the decision fields and drops the bulk.

```bash
curl -s "https://api.apify.com/v2/datasets/$DATASET_ID/items?view=ai_room_picker&token=$APIFY_TOKEN" \
  > rooms.json
```

Then give the model the rows plus the fields that keep it honest:

> Here are room options as JSON. Recommend the best room for 2 adults on 2026-09-01, balancing price and cancellation flexibility.
>
> Rules you must follow:
>
> - `nightly_total_price` is the comparable price. If it is `null`, the room has no bookable rate for this party — do not recommend it and do not guess a price.
> - Trust `occupancy_match_status`. `no_compatible_rate` means the room does not fit; `uncertain_children` means capacity could not be fully verified.
> - `price_basis` tells you exactly what the price covers. Quote it when you justify a recommendation.
> - `cancellation_policy` (`fully_refundable` / `partially_refundable` / `non_refundable` / `unknown`) and `free_cancellation_until` describe the *same* rate as the price (`primary_rate_option.block_id`). Never mix them with another row's terms. Prefer `cancellation_policy` over the legacy `is_refundable` boolean, which is `false` for a partially refundable rate.
> - If `availability_status` is `sold_out`, the room is unavailable regardless of any other field.

Those five rules map onto real failure modes: a `null` price read as "cheap", a room recommended for a party it cannot hold, and refund terms borrowed from a different rate. Every field they reference is in the view.

</details>

One note on the Rate shopping view specifically: the legacy `price_public` / `discount_percent` fields are deliberately **not** in it. They come from the fixed 2-adult benchmark path, so placing them beside `nightly_total_price` — which is occupancy-matched — would invite a discount calculation across two different rate options. Both remain available under All fields, and they will return to the view once they are derived from the same selected primary block.

<details>
<summary><strong>Full output field reference (click to expand)</strong></summary>

#### Output fields

💡 **New in v2.4.0:** `max_guests` and `primary_rate_option` are now matched against your *actual* `adults`/`children`/`rooms` input (not a hardcoded 2-adult standard) — with an honest `occupancy_match_status` flag and `null` instead of a guessed fallback when nothing fits.

| Field | Type | Description |
|-------|------|-------------|
| `hotel_url` / `hotel_name` | string | Source URL and the raw Booking.com page title (e.g. `Fashion Boutique Hotel (Hotel) (Morocco) deals`) |
| `hotel_name_clean` | string | The real property name (e.g. `Fashion Boutique Hotel`), taken from schema.org JSON-LD / `utag_data` where available, otherwise the raw title with Booking's own generated qualifiers stripped. A legitimate parenthetical like `(Adults Only)` is preserved |
| `hotel_address` | string | Full address as shown on Booking.com |
| `property_type` | string | Hotel / Riad / Apartment / Resort / etc. |
| `hotel_review_score` / `hotel_review_count` / `hotel_star_rating` / `hotel_neighborhood` | number / integer / integer / string | Hotel-level context, denormalized onto **every** room row — no join needed |
| `scrape_timestamp` / `proxy_country` | string | When and from which source market this was scraped |
| `check_in_date` / `check_out_date` / `number_of_nights` | string / string / integer | Stay dates. This Actor scrapes one night per record, so `number_of_nights` is normally `1` |
| `requested_adults` / `requested_children` / `requested_rooms` | integer | The occupancy this record was searched for (from the run input) |
| `required_guests_per_room` | integer | Minimum per-room capacity derived from the occupancy fields above, split evenly across `requested_rooms` |
| `occupancy_match_status` | string | `capacity_compatible`, `uncertain_children` (party includes children — ages aren't collected, so treat `max_guests` with caution), or `no_compatible_rate` (no rate proved enough capacity — `primary_rate_option`/`max_guests` are `null`, never guessed) |
| `room_id` / `room_name` / `room_name_clean` / `bed_type` | — | Room identity |
| `max_guests` | integer | Occupancy of the **primary matched rate** (the one a guest with your requested occupancy would book). `null` when `occupancy_match_status` is `no_compatible_rate` — not the room's raw physical capacity |
| `room_capacity` | integer | Room's maximum occupancy. When Booking.com doesn't expose it directly, this falls back to the largest capacity any rate option proves — a **lower bound**, not the true physical maximum. Since rate options are filtered to your searched occupancy, that fallback can't exceed it (a "Quadruple Room" searched for 2 adults may report `2`). Read it as "at least this many" |
| `room_size_sqm` / `room_view` / `has_private_bathroom` | — | Room attributes |
| `room_amenities` | array | Premium amenities only (balcony, minibar, spa bath, etc.) — the "Key amenities" column in the **Room comparison** and **AI room picker** views |
| `facilities` | array | Complete facilities list (30–40+ items). Available under "All fields" and in the JSON/API, left out of all three curated views since it duplicates `room_size_sqm` / `has_private_bathroom` / `room_amenities` |
| `currency` | string | Price currency |
| `nightly_total_price` | number | **Use this for price comparison.** What the guest pays online for one night — taxes already baked in. `null` when sold out or `occupancy_match_status` is `no_compatible_rate` |
| `nightly_base_price` | number | Room rate before taxes. `null` when Booking.com doesn't expose a tax breakdown — never guessed |
| `total_stay_price` | number | `nightly_total_price × number_of_nights` |
| `taxes_and_fees_total` | number | Full tax burden for the stay (prepaid + payable at property) |
| `price_basis` | string | Machine-readable summary of what the price fields represent, e.g. `1_night_2_guests_taxes_included_in_total`, or `sold_out` |
| `price` / `price_primary` / `price_public` / `original_price` / `discount_percent` | number | **Legacy, fixed 2-adult benchmark** — unchanged meaning from v2.2.x, kept for backward compatibility and revenue benchmarking. **`null` on sold-out rows — never `0`.** For the occupancy YOU searched for, use `nightly_total_price` / `primary_rate_option` instead |
| `is_sold_out` | boolean | The authoritative sold-out flag. Never inferred from a missing price or a missing count |
| `availability_status` | string | `sold_out` / `low_availability_urgent` (1–3 left) / `low_availability` (4–5) / `available` (6+, or count hidden). Monotonic in the count as of v2.5.0 |
| `rooms_left` / `stock_confidence` | integer / string | The count, and how far to trust it: `exact` (explicit scarcity message), `capped` (10 = Booking's display ceiling), `hidden` (no count shown) |
| `meal_plan` / `is_breakfast_included` | string / boolean | Meal plan **of the primary matched rate**. `null` when no rate matched your occupancy |
| `is_refundable` / `free_cancellation_until` | boolean / string | Cancellation terms **of the primary matched rate** — as of v2.5.0 this is the rate you would actually book, not "any rate for this room". `null` when no rate matched your occupancy |
| `cancellation_policy` | string | `fully_refundable` / `partially_refundable` / `non_refundable` / `unknown`, for the primary matched rate. **Use this rather than the boolean** — `is_refundable` is legacy and is `true` only for `fully_refundable`, so a partially refundable rate reads as `false` there |
| `book_now_pay_later` | boolean | Deferred payment **on the primary matched rate**. `null` when no rate matched your occupancy |
| `primary_rate_option` | object | **Request-aware.** Every field inside comes from the *same* rate option (`block_id`), selected to match your requested occupancy. `null` when sold out or `occupancy_match_status` is `no_compatible_rate` |
| `benchmark_rate_option` | object | Same shape as `primary_rate_option`, but always the fixed 2-adult standard rate — for revenue-benchmarking comparisons, not for "what should this guest book" |
| `rate_options` / `rate_options_count` | array / integer | All pricing tiers found for this room |
| `los_prices` / `has_los_discount` | object / boolean | Multi-night prices keyed by length of stay, and whether a longer stay is proportionally cheaper. **Only present when `includeLosAnalysis: true`** — absent otherwise |

#### Where's the hotel discovery metadata?

Full hotel-level metadata (review breakdown, description, complete hotel facilities list) is written to a **separate, run-scoped `hotel_metadata` dataset** (via Apify's storage alias mechanism), not mixed into the room-price dataset. This keeps every row in the default "Output" tab a real, priceable room — no more blank "undefined" rows from discovery snapshots — and the metadata never accumulates across runs. The hotel context that actually matters for comparing rooms (score, stars, neighborhood, address) is already on every room row above.

#### primary\_rate\_option structure

```json
{
  "block_id": "160633804_419828306_2_1_0",
  "max_guests": 2,
  "price": 220,
  "public_price": 245,
  "is_refundable": true,
  "free_cancellation_until": "2026-09-10",
  "meal_plan": "breakfast",
  "is_breakfast_included": true,
  "book_now_pay_later": false,
  "is_genius": false,
  "is_wholesale": false,
  "taxes_included": 15,
  "taxes_excluded": 5
}
```

💡 **Note:** `rate_options` (the full array) only includes rates that **match your search parameters** (e.g., searching for 2 adults filters out 1-person-only rates). Partner/wholesale rates (`bbasic`) are excluded by default — enable `includeWholesaleRates` to capture them.

#### Example output

A **3-adult** search, chosen to show the three distinctions that trip people up:

1. `hotel_name` is Booking's raw generated title; `hotel_name_clean` is the real property name.
2. `primary_rate_option` is the **3-guest** rate you would actually book (€265, non-refundable), while `benchmark_rate_option` is the fixed **2-adult** rate (€220, refundable) — genuinely different blocks.
3. The legacy `price` / `price_primary` / `price_public` fields (220 / 220 / 245) follow the 2-adult benchmark, **not** the €265 you were quoted. `nightly_total_price` is the one that answers your search.

Note that the top-level `is_refundable: false` and `meal_plan` come from the *primary* block, not from the refundable benchmark rate — that is the v2.5.0 fix.

```json
{
  "hotel_url": "https://www.booking.com/hotel/ma/savoy-le-grand.html",
  "hotel_name": "Savoy Le Grand Hotel Marrakech (Hotel) (Morocco) deals",
  "hotel_name_clean": "Savoy Le Grand Hotel Marrakech",
  "booking_hotel_id": "160633804",
  "hotel_address": "Avenue Mohammed VI, Hivernage, 40000 Marrakech",
  "property_type": "Hotel",
  "hotel_review_score": 9.1,
  "hotel_review_count": 842,
  "hotel_star_rating": 5,
  "hotel_neighborhood": "Hivernage",
  "scrape_timestamp": "2026-08-24T10:00:00Z",
  "proxy_country": "FR",
  "check_in_date": "2026-09-01",
  "check_out_date": "2026-09-02",
  "number_of_nights": 1,
  "requested_adults": 3,
  "requested_children": 0,
  "requested_rooms": 1,
  "required_guests_per_room": 3,
  "occupancy_match_status": "capacity_compatible",
  "room_id": 160633804,
  "room_name": "Prestige Double Room with Pool View",
  "room_name_clean": "Prestige Double Room with Pool View",
  "bed_type": "1 extra-large double bed",
  "max_guests": 3,
  "room_capacity": 3,
  "room_size_sqm": 43,
  "room_view": "Pool view",
  "has_private_bathroom": true,
  "room_amenities": [
    "balcony",
    "air conditioning",
    "soundproofing",
    "terrace",
    "minibar"
  ],
  "currency": "EUR",
  "nightly_total_price": 265.0,
  "nightly_base_price": 233.2,
  "total_stay_price": 265.0,
  "taxes_and_fees_total": 37.8,
  "price_basis": "1_night_3_guests_taxes_included_in_total",
  "price": 220,
  "price_primary": 220,
  "price_public": 245,
  "original_price": null,
  "discount_percent": null,
  "is_sold_out": false,
  "availability_status": "low_availability_urgent",
  "rooms_left": 3,
  "stock_confidence": "exact",
  "min_stay": 1,
  "meal_plan": "Breakfast included",
  "is_breakfast_included": true,
  "is_refundable": false,
  "free_cancellation_until": null,
  "book_now_pay_later": false,
  "taxes_included": 31.8,
  "taxes_excluded": 6.0,
  "primary_rate_option": {
    "block_id": "160633804_419828306_3_1_0",
    "max_guests": 3,
    "price": 265.0,
    "public_price": 295.0,
    "is_refundable": false,
    "free_cancellation_until": null,
    "meal_plan": "Breakfast included",
    "is_breakfast_included": true,
    "book_now_pay_later": false,
    "is_genius": false,
    "is_wholesale": false,
    "taxes_included": 31.8,
    "taxes_excluded": 6.0
  },
  "benchmark_rate_option": {
    "block_id": "160633804_419828306_2_1_0",
    "max_guests": 2,
    "price": 220.0,
    "public_price": 245.0,
    "is_refundable": true,
    "free_cancellation_until": "2026-08-30",
    "meal_plan": "Breakfast included",
    "is_breakfast_included": true,
    "book_now_pay_later": false,
    "is_genius": true,
    "is_wholesale": false,
    "taxes_included": 26.4,
    "taxes_excluded": 5.0
  },
  "rate_options_count": 3
}
```

💡 The price fields reconcile: `nightly_base_price` + `taxes_and_fees_total` = `nightly_total_price` + `taxes_excluded` → 233.20 + 37.80 = 265.00 + 6.00. This identity is asserted in the test suite.

</details>

***

### 🔍 Understanding key fields

#### price vs. price\_primary vs. price\_public

All three are **legacy fields computed against a fixed 2-adult benchmark**, regardless of the occupancy you searched for. They are kept unchanged for backward compatibility.

| Field | What it is | When to use |
|-------|-----------|-------------|
| `price` | Absolute cheapest option (may be 1-person or restricted) | Quick market scan |
| `price_primary` | Best price for standard 2-guest occupancy | A fixed 2-adult benchmark across hotels |
| `price_public` | Non-Genius public rate at 2-adult occupancy (strips Genius discounts) | Comparing like-for-like against a published rack rate |
| `nightly_total_price` | **The rate matched to YOUR requested occupancy** | Everything else — this is the one to compare on |

💡 **Which should you actually use?** If you searched for the occupancy you care about, use **`nightly_total_price`** (and `primary_rate_option` for its full context). Reach for `price_public` only when you deliberately want a *fixed 2-adult* yardstick that is comparable across properties and unaffected by Genius discounts (10–20%).

⚠️ **Do not mix the two families.** `price_public` and `discount_percent` come from the 2-adult benchmark path; `nightly_total_price` comes from your occupancy-matched rate. Computing `(price_public − nightly_total_price) / price_public` compares two *different rate options* and produces a meaningless discount. `discount_percent` is already calculated for you — against `original_price`, within the benchmark family. This is exactly why both are left out of the Rate shopping view; they will return to it once they are derived from the same selected primary block.

#### proxy\_country — geo-pricing comparison

Booking.com prices the same room differently depending on the visitor's location. The `proxy_country` field tells you which source market each result was scraped from. Running multiple `proxyCountries` in one job lets you compare cohorts side by side — e.g. how a French visitor, a US visitor, and a UK visitor each see the same property.

#### availability\_status

Since v2.5.0 the tier depends **only on the room count**, so it is monotonic — 4 rooms left is never reported as less urgent than 5. (Before v2.5.0 urgency keyed off whether Booking.com happened to render a scarcity banner, which produced exactly that inversion.)

| Status | Rule | Revenue insight |
|--------|------|-----------------|
| `sold_out` | `is_sold_out` is true, or a confirmed zero count | Peak demand — opportunity to optimize your own rates |
| `low_availability_urgent` | 1–3 rooms left | High demand — act on pricing |
| `low_availability` | 4–5 rooms left | Demand is building |
| `available` | 6+ rooms left, **or the count is hidden** | Normal conditions, or simply no signal — check `stock_confidence` before reading anything into it |

⚠️ `sold_out` is never inferred from a missing price or a missing count — it comes only from the explicit `is_sold_out` flag. And whether Booking.com renders a scarcity banner now affects `stock_confidence` only (how much to trust the number), never the urgency tier.

#### stock\_confidence

| Value | Source | Reliability |
|-------|--------|-------------|
| `exact` | DOM scarcity badge visible on page, carrying a number | High — Booking.com shows this to create urgency |
| `capped` | `rooms_left = 10` from internal JSON | Medium — actual stock is ≥ 10, Booking caps its own counter |
| `inferred` | A count came from the page JSON or the room registry, but no scarcity badge was shown for it | Medium — the number is real but Booking didn't surface it |
| `hidden` | `rooms_left = null` — no count from any source | None — treat availability as unknown, not comfortable |

⚠️ Before v2.5.0, `inferred` was only used on sold-out rows, so any row with a count but no badge was labelled `hidden` — producing output that read `rooms_left: 3` beside `stock_confidence: "hidden"` in the same row. `hidden` now means there is genuinely no count.

#### facilities vs. room\_amenities

`facilities` is the **complete list** — every feature Booking.com shows for the room (30–40+ items). `room_amenities` is **premium features only** — the differentiators that affect pricing (balcony, minibar, soundproofing, spa bath).

***

### ⚠️ Limitations & known caveats

Stated plainly, because the Actor's whole contract is "never guess" — knowing where the data stops is part of trusting the data that's there.

- **One night per record.** Each row prices a single night (`number_of_nights` is normally `1`). Multi-night rates are not modelled unless you enable `includeLosAnalysis`, which adds `los_prices`.
- **Child ages are not collected.** When `children > 0`, true rate capacity cannot be fully verified, and `occupancy_match_status` honestly reports `uncertain_children` rather than pretending otherwise.
- **`rooms_left` is capped at 10 by Booking.com**, not by this Actor. A `10` usually means "10 or more" — that is what `stock_confidence: capped` is telling you. A `null` means the count is hidden, not that inventory is comfortable.
- **`nightly_base_price` is `null` when Booking.com exposes no tax breakdown.** It is never back-computed from an assumed tax rate.
- **Booking.com A/B tests prices and layouts.** Two runs minutes apart from the same market can legitimately differ. `scrape_timestamp` and `proxy_country` are on every row so you can tell a real price move from a cohort difference.
- **`price` / `price_primary` / `price_public` / `original_price` / `discount_percent` are a fixed 2-adult benchmark**, not your requested occupancy. See *Understanding key fields*.
- **Sold-out rows carry fewer fields.** Static room attributes (`bed_type`, `room_size_sqm`, `room_view`, `room_amenities`) are not currently emitted on sold-out rows, so those columns render blank there. `is_sold_out` and `availability_status` are shown before the price in both room-facing views so a blank is never unexplained.
- **A proxy is effectively required.** Without `useApifyProxy`, Booking.com blocks most sessions after 1–2 requests.

**Fields under investigation.** These came in as a bug report from a diverged copy of this scraper and are **not confirmed** here — Booking.com's markup could not be inspected live during the fix session, so they were deliberately left unpatched rather than changed on a guess:

- `hotel_neighborhood` — reported as always null. One of this Actor's selectors is a hashed CSS-module class (`span.f419a93f12`), which is exactly the kind that stops matching when Booking redeploys. The breadcrumb trail is a likely sturdier source. Needs a live listing to confirm before changing.
- `min_stay` — reported as always null, but may simply be correct: most dates have no minimum-stay restriction. Needs a listing that visibly shows one.
- `book_now_pay_later` — reported as always false. The strict `=== 1` JSON comparison has been widened to also accept `true` / `"1"` (a change that can only ever turn a false into a true), but whether the underlying key is right is unverified.
- `taxes_and_fees` **inside individual `rate_options` entries** — reported as always null, likely a stale DOM selector. Low impact: the top-level `taxes_and_fees_total` is computed from the JSON-backed `taxes_included` / `taxes_excluded` and is unaffected.

***

### 💡 Tips for best results

- **Start with 7–30 days** to validate your setup, then scale to 365
- **Keep `useApifyProxy: true`** — without it, Booking.com blocks most runs after 1–2 requests
- **Schedule daily runs** with Apify Scheduler — hotel prices change constantly
- **Monitor up to 10 hotels per run** for optimal speed and reliability
- **Compare on `nightly_total_price`** — it matches the occupancy you searched for. Use the legacy `price_public` only when you want a fixed 2-adult yardstick, and never compute a discount across the two families (see *Understanding key fields*)
- **Compare `proxyCountries`** to see geo-pricing differences across your source markets — keep `currency` fixed for a clean comparison

***

### 🔗 Integrations

| Integration | Use case |
|-------------|----------|
| **Apify Scheduler** | Automate daily/weekly Booking.com price monitoring |
| **Webhooks** | Get notified when a run completes |
| **API** | Pull data programmatically (see API tab) |
| **Google Sheets** | Direct export for quick analysis |
| **n8n / Make / Zapier** | No-code automation pipelines |
| **JSON / CSV / Excel / HTML** | Download in any format |

***

### 🚀 Use cases

**Daily competitor monitoring** — Track what your competitive set charges across every room type, every day, using this Booking.com hotel price tracker as a standing job.

**Revenue management** — Feed room-level market data into pricing decisions. Compare your rates against competitors using matching rate options (same occupancy, same cancellation policy, same meal plan).

**Geo-pricing analysis** — Scrape the same properties from multiple `proxyCountries` to see how Booking.com prices rooms for visitors from different source markets.

**Room-type matching** — Use `facilities`, `room_size_sqm`, and `room_view` to match comparable rooms across different hotels.

**Seasonal trend analysis** — Scrape 365 days to visualize demand curves, identify shoulder seasons, and plan promotions around events and holidays.

**Portfolio benchmarking** — Manage multiple properties? Compare each one against its local market at the room-type level.

**Travel platform data feeds** — Power booking engines, metasearch tools, or dashboards with structured, validated Booking.com pricing data.

***

### ❓ FAQ

**Does this Booking.com scraper work with any property?**
Yes — any hotel, resort, riad, apartment, or boutique property listed on Booking.com, in any country. Works with small riads (5 rooms) and large hotels (100+ rooms).

**What's the difference between this and other Booking.com price scrapers?**
Most Booking.com scrapers on Apify Store return one listing-level price per hotel (like a search-results snapshot). This Actor is room-level: every room type, every rate option, every date, with sold-out detection and geo-pricing — built for revenue management, not just a price check.

**What currencies are supported?**
23 currencies, including USD, EUR, GBP, **CNY**, JPY, KRW, CHF, PLN, MAD, AED, SAR, INR, SGD, HKD, TWD, THB, IDR, BRL, MXN, ZAR. Keep it fixed across `proxyCountries` so what you're comparing is the geo-pricing cohort, not a currency conversion.

**What does `rooms_left: null` mean?**
It means **unknown**, not "plenty available". Booking.com simply isn't exposing a stock number for this room — `stock_confidence` will read `hidden`. The room is still bookable (that's what `is_sold_out: false` tells you), but don't infer a comfortable inventory level from a `null`. Likewise `rooms_left: 10` usually means "10 or more" — Booking.com caps its own counter, which is why `stock_confidence` reads `capped` there.

**Why are sold-out dates valuable?**
Sold-out dates reveal peak demand periods. If a competitor is fully booked, that's a high-demand window where you can optimize your own rates.

**What are Genius rates and why does `price_public` matter?**
Booking.com Genius gives 10–20% discounts to frequent bookers. `price_public` always shows the non-Genius price at a fixed 2-adult occupancy, which makes it a stable yardstick across properties. Note it is a *legacy benchmark* field: it ignores the occupancy you searched for, so it is not interchangeable with `nightly_total_price` and the two must never be subtracted from one another. If you want Genius status on the rate you'd actually book, read `primary_rate_option.is_genius`.

**What are `bbasic` rates and can I include them?**
Booking.com sometimes shows "Tarif Prestataire" (partner/wholesale rates) from third-party resellers. Skipped by default; set `includeWholesaleRates: true` to capture them, tagged with `is_wholesale: true`.

**Can I scrape prices as they appear in China / Hong Kong / Poland / anywhere else?**
Yes. `proxyCountries` is not a fixed whitelist — the code you enter is passed straight to Apify Residential proxy, so any country it offers works, `CN` included. `CNY` is available as a currency too. If a country isn't on your Apify plan the run falls back to an un-targeted residential IP and says so in the log, rather than failing.

**Do I need a proxy?**
Yes, for anything beyond a quick test. `useApifyProxy: true` is the default and strongly recommended — Booking.com blocks datacenter/shared IPs quickly.

**How often should I run this?**
For active revenue management, daily runs are recommended — Booking.com prices and availability change frequently, sometimes multiple times a day.

**Can I scrape more than 10 hotels?**
For optimal reliability, each run handles up to 10 hotels directly, or use `searchUrl`/`searchText` with `maxProperties` for broader discovery. For very large sets, schedule multiple runs or batch via the Apify API.

***

### 📝 Changelog

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

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

#### Correctness fixes

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

#### Views

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

</details>

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

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

</details>

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

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

</details>

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

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

</details>

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

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

</details>

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

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

</details>

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

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

</details>

***

### 💬 Support & feature requests

Have a question, found a bug, or want a new feature? → **Open an issue** on the Issues tab.

Want a custom solution for your revenue management needs? → Reach out via Issues.

***

Built by **NoraView Intelligence** — AI-powered revenue management for independent hotels.

Booking.com price scraper · room-level price tracker · hotel rate scraper · sold-out and availability tracking · Genius rate detection · geo-pricing across multiple countries · CSV, JSON, HTML, Excel output.

# 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.

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

Number of days to scan forward.

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

Detect which rooms are sold out. Essential for demand analysis and revenue management.

## `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.

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

Number of rooms.

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

Standard Apify proxy picker. Residential is strongly recommended — Booking.com blocks datacenter and shared IPs within a request or two. 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 to scrape at the same time (per country). Higher = faster runs, but more RAM/CPU and a heavier hit on your proxy pool. Each hotel uses its own proxy IP, so this is safe up to 5.

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

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

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

Extract multi-night pricing to detect LOS discounts.

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

Capture partner/wholesale rates (Hotelbeds, Expedia, etc). Important for Asian markets. Tagged separately as is\_wholesale, never mixed with retail rates.

## `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.

## `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,
  "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

## `roomComparisonTable` (type: `string`):

No description

## `aiRoomPickerJson` (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/99hiSvf5w9KsSrXgM/openapi.json
