DEV Community

Cover image for Booking.com hotel prices for a whole city and exact dates, without a browser (Python + n8n)
Tedj MEABIOU
Tedj MEABIOU

Posted on

Booking.com hotel prices for a whole city and exact dates, without a browser (Python + n8n)

Booking.com prices are the reference rate for most hotel inventory, and every Booking page answers a script with an AWS WAF challenge, so a typical booking.com scraper drives a headless browser through the results. The results page loads its list from a search call that needs no browser. This is how to get booking.com prices for a whole destination, on exact dates and for your party, as rows, and how to keep a hotel rate scraper running every morning.

1. One request, one row per property

The Booking.com Scraper on Apify takes destinations as typed into Booking's search box, or property URLs, names and ids, plus a stay (checkIn, nights, adults, childrenAges, rooms, currency), and returns one hotel row per property.

curl -X POST "https://api.apify.com/v2/acts/kestrel~booking-prices-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"locationQueries": ["Lisbon"], "maxHotelsPerQuery": 100, "checkIn": "6 weeks", "nights": 2, "adults": 2, "currency": "EUR", "sortBy": "price"}'
Enter fullscreen mode Exit fullscreen mode

A hotel row:

{ "type": "hotel", "hotel_id": "536251", "name": "Memmo Alfama - Design Hotels", "url": "https://www.booking.com/hotel/pt/memmo-alfama.html",
  "property_type": "hotel", "stars": 4, "review_score": 9.3, "review_count": 894, "city": "Lisboa", "country_code": "pt",
  "display_location": "Santa Maria Maior, Lisbon", "distance_from_center": "0.8 km from downtown",
  "check_in": "2026-09-28", "check_out": "2026-09-29", "nights": 1, "adults": 2, "currency": "EUR",
  "price_total": 415.09, "price_nightly": 415.09, "price_display": "€ 415.09", "hotel_currency": "EUR",
  "taxes_included": true, "free_cancellation": true, "breakfast_included": true,
  "room_name": "Superior Double Room with Terrace", "rooms_listed": 3, "sold_out": false, "rank": 1, "query": "Memmo Alfama Lisbon" }
Enter fullscreen mode Exit fullscreen mode

price_total is the lowest total for the stay and party, price_nightly that divided by nights; taxes_included, taxes_fees_excluded and charges_note say what it leaves out. free_cancellation, no_prepayment and breakfast_included say whether two rates are comparable; room_name is the room behind the price; latitude and longitude place it.

2. Why no browser is needed

Every Booking.com page sits behind AWS WAF. The results page, once loaded, calls a GraphQL operation named FullSearch on Booking's /dml/graphql endpoint, 100 properties per page, and that call is served without the WAF token — on residential IPs. A datacenter address gets the same 202 challenge as a page, so the default proxy is Apify's RESIDENTIAL group. The WAF also reads the TLS handshake, so the transport presents Chrome's fingerprint rather than Python's.

Destinations go through Booking's autocomplete, also outside the WAF. hotelNames and startUrls (a URL carries no id) take the same path, hotelIds skips it, and a property seen twice — in two destinations, or as a URL and its id — is billed once.

3. Filters before billing, sold-out rows free

minStars is passed to Booking as its own star filter, so those properties are never fetched. maxPrice drops rows whose nightly rate is above the line before billing. Sold-out properties arrive free with sold_out: true, sold_out_message, alternative_check_in and alternative_check_out; with maxPrice set they are dropped.

{ "locationQueries": ["Algarve"], "maxHotelsPerQuery": 200, "checkIn": "2026-10-10", "nights": 7, "adults": 2, "childrenAges": [5, 9], "rooms": 1, "currency": "GBP", "sortBy": "review_score", "minStars": 4, "maxPrice": 180 }
Enter fullscreen mode Exit fullscreen mode

sortBy decides completeness: price, review_score, stars and distance are deterministic; the default popularity is personalised and reshuffles between pages.

4. Python: a city's price ladder, then a comp set with rooms

import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
def rows(inp, kind="hotel"): return [r for r in client.dataset(client.actor("kestrel/booking-prices-scraper").call(run_input=inp)["defaultDatasetId"]).iterate_items() if r["type"] == kind]

city = rows({"locationQueries": ["Lisbon"], "maxHotelsPerQuery": 100, "checkIn": "6 weeks", "nights": 2, "adults": 2, "currency": "EUR", "sortBy": "price"})
priced = [r for r in city if r["price_total"] is not None]
print(len(priced), "priced,", len(city) - len(priced), "sold out;", sum(r["price_nightly"] <= 120 and r["free_cancellation"] for r in priced), "under 120 EUR with free cancellation")

comp = rows({"hotelIds": ["536251", "2251985", "1471925"], "checkIn": "30 days", "nights": 1, "adults": 2, "currency": "USD", "includeRooms": True}, kind="room")
for r in sorted(comp, key=lambda r: r["price_nightly"]): print(r["hotel_name"], r["room_name"], r["meal_plan"], r["price_nightly"], r["free_cancellation_until"], r["only_x_left"])
Enter fullscreen mode Exit fullscreen mode

The first run is the cheapest 100 places in Lisbon for a weekend and how many sit under a budget with free cancellation; raise maxHotelsPerQuery to the total in the free status row and the ladder is the whole city. The second is a comp set by id with includeRooms: one room row per room and rate, with meal plan and cancellation deadline, so a non-refundable room-only rate is not mistaken for undercutting. For hotel price comparison across sites, the Hotel Rate Parity Checker reads Google's view of the same stay, every booking site side by side.

5. Hotel price monitoring on a schedule (n8n)

checkIn takes relative dates — "30 days", "6 weeks", "tomorrow" — so a scheduled run prices the same lead time every day and rows line up on hotel_id and check_in. There is no hotel template in the n8n/ folder of kestrel-actors-examples yet; copy the Google Flights one: a 07:00 Schedule trigger, an HTTP Request to the run-sync endpoint with the section 1 body, a Code node keeping yesterday's price_nightly per hotel_id in workflow static data, an IF on the drop, Telegram or Slack. Filter type = hotel and sold_out = false first.

6. Cost and limits

  • $0.004 per priced hotel row, $0.002 per room row. A 100-hotel city sweep is one request and $0.40; a daily comp set of 10 hotels with about 60 room rows is $0.16 a day. Sold-out properties, status rows, duplicates and anything a filter removes are free.
  • Prices are for the party you ask for; Genius member rates are not visible; the row says whether the total includes taxes.
  • The actor reads the publicly visible prices and property facts that Booking.com shows every anonymous visitor for the dates and party you specify — no login, no personal data, nothing behind an account. Prices are facts, not copyrighted works. Whether your use complies with Booking's terms and with the laws where you operate is your responsibility; keep the request rate reasonable, do not resell Booking's data as your own, and attribute where you publish comparisons.

That is the feed: one search call outside the WAF, filters before billing, a relative date on a schedule. Full reference on the actor page: apify.com/kestrel/booking-prices-scraper.

Every actor's inputs, output fields, a sample row and its honest limits are documented at mtedj.github.io/kestrel-actors-examples, including a side-by-side comparison of every review scraper — what each source really carries and where its ceiling is.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.