Booking.com is the review corpus a hotel reputation feed cannot skip, and every one of its pages refuses a script: a plain HTTP request gets an AWS WAF challenge, which is why most booking.com reviews scrapers run a headless browser. The page itself loads its reviews from a JSON endpoint that answers without the challenge. This is how to read booking.com reviews as rows with no browser, no login and no API key, and what the one real trap looks like.
1. One request, one row per review
The Booking.com Reviews Scraper on Apify is a booking reviews scraper that takes property URLs, hotel names or Booking's numeric ids and returns hotel guest reviews as rows.
curl -X POST "https://api.apify.com/v2/acts/kestrel~booking-reviews-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"hotelIds": ["536251"], "maxReviewsPerHotel": 25, "languages": ["en"]}'
A review row:
{ "type": "review", "review_id": "c90f61915f999a43", "hotel_id": "536251", "hotel_name": "Memmo Alfama - Design Hotels",
"rating": 10.0, "rating_5": 5.0, "title": "Best place to stay in Lisbon!",
"positives": "Great location and amazing terrace.", "negatives": null, "text": "Great location and amazing terrace.",
"language": "en", "review_date": "2026-08-28", "check_in": "2026-08-24", "check_out": "2026-08-27", "nights": 3,
"room_type": "Premium Double or Twin Room", "traveler_type": "Couple", "traveler_type_code": "COUPLES",
"reviewer_name": "Jean", "reviewer_country": "Canada", "response": null, "fetched_at": "2026-08-29T10:12:04+00:00" }
positives and negatives are Booking's two questions kept apart, text the two joined. rating is Booking's 1–10 scale, rating_5 the same out of five. traveler_type_code is Booking's category on the booking (COUPLES, FAMILIES, GROUP_OF_FRIENDS, SOLO_TRAVELLERS, BUSINESS_TRAVELLERS), response the property's reply or null, and check_in, nights and room_type tie the score to a stay.
2. Why no browser is needed
Every Booking.com page sits behind AWS WAF: a script asking for HTML gets a 202 and a JavaScript challenge. Once a hotel page has loaded, though, it calls a GraphQL operation named ReviewList, and that call does not need the WAF token; neither does the autocomplete behind the search box. The actor makes only those two calls, on datacenter proxies.
The trap is that the WAF still reads the TLS handshake. A plain Python HTTP client in a Linux container is challenged on that endpoint too, on every proxy pool; a client that presents Chrome's TLS fingerprint gets JSON. A transport choice, not a login or a cookie. A challenged or empty answer is retried on a fresh IP and, failing that, reported as error, never no_reviews.
3. Filters before billing, hotels by name
languages, travelerType and keyword are applied by Booking's server, so what you skip is never fetched. maxRating keeps only reviews at or below a score and reads the property lowest score first, stopping at the line: 892 reviews with 24 at 6 or below cost one page, not thirty-six. requireText drops score-only reviews, about four in ten on Booking.
{ "startUrls": ["https://www.booking.com/hotel/pt/memmo-alfama.html", "https://www.booking.com/hotel/pt/the-lumiares.en-gb.html"], "maxReviewsPerHotel": 50, "reviewsSort": "most_recent", "maxRating": 6, "requireText": true }
Booking's URLs carry no numeric id, so a URL's slug is resolved through Booking's own autocomplete, held to the country in the URL; hotelNames takes the same path, and the resolved hotel_name comes back on every row. hotelIds skips the lookup; a URL, its name and its id are one property, harvested once.
4. Python: complaints by room type, then Agoda alongside
import os
from collections import Counter
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
def rows(actor, inp): return [r for r in client.dataset(client.actor(actor).call(run_input=inp)["defaultDatasetId"]).iterate_items() if r["type"] == "review"]
booking = rows("kestrel/booking-reviews-scraper", {"hotelNames": ["Memmo Alfama Lisbon"], "maxReviewsPerHotel": 0, "maxRating": 6, "requireText": True})
for (room, kind), n in Counter((r["room_type"], r["traveler_type_code"]) for r in booking).most_common(): print(f"{n:3} {room:40} {kind}")
print(sum(r["response"] is None for r in booking), "of", len(booking), "unanswered")
agoda = rows("kestrel/agoda-reviews-scraper", {"hotelIds": ["63820"], "maxReviewsPerHotel": 0, "maxRating": 6, "requireText": True})
feed = [("booking", r["rating"], r["review_date"], r["negatives"], r["response"] is not None) for r in booking]
feed += [("agoda", r["rating"], r["review_date"][:10], r["negatives"] or r["text"], r["response"] is not None) for r in agoda]
The first loop is the ops question — which room type the complaints cluster in, by guest kind — with no text model; the response count is the reputation one. The Agoda Reviews Scraper also scores 0–10 and splits negatives out, so both sites land in one feed on one scale: a voice of customer table.
5. Review monitoring on a schedule (n8n)
The Agoda template in the n8n/ folder of kestrel-actors-examples is the workflow: an 08:00 Schedule trigger, an HTTP Request to the run-sync endpoint for the 50 most recent reviews at or under 6/10 with text, a Code node that drops review_ids seen before, an IF, a Google Sheets append and a Slack message per review. Pointing it at Booking is the endpoint and the body in section 3; rating, negatives, response and review_id already share names. keyword: "breakfast" narrows the feed before a row exists.
6. Cost and limits
- $0.005 per delivered
reviewrow;hotelandstatusrows and anything a filter removes are free. The 892-review property in full: $4.46. Ten properties at 50 most recent, daily: at most $2.50 a day; the complaints version delivers a few rows, a cent or two. - Reviews arrive as written, tagged with
language, never translated; the freehotelrow lists every language with a count, so read it before choosinglanguages. - Ids carry no name, and an unknown id looks like an empty property:
no_reviews, free. - The actor reads publicly visible guest reviews through the same request Booking's own page makes, with no login and no personal accounts. Whether you may use the data for your purpose depends on your jurisdiction, on Booking.com's terms and on data-protection law (GDPR in the EU treats reviewer names as personal data) — read them and take advice before scraping at scale or republishing. Use the data for analysis and monitoring; do not repost reviews as your own content.
That is the feed: one endpoint outside the WAF, the right TLS stack to reach it, filters before billing and a schedule. Full reference on the actor page: apify.com/kestrel/booking-reviews-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 (0)