Booking.com serves over 28 million listings and is one of the most valuable sources of hotel review data on the internet. It's also one of the hardest sites to scrape. I spent a week figuring out why standard approaches fail and how to get the data anyway. Here's everything I learned.
Why headless Chromium fails immediately
The first blocker is AWS WAF sitting in front of Booking.com's CDN. It fingerprints incoming browser connections at the TLS handshake level — JA3/JA4 signatures, HTTP/2 header ordering, cipher suite preferences — and Chromium's fingerprint is on the blocklist. You get a 403 before the page even loads.
The fix is camoufox, a Firefox fork that patches the browser's fingerprinting surface. Firefox's TLS fingerprint passes the WAF check where Chromium's doesn't. But camoufox alone isn't enough — Booking.com's WAF also checks whether the browser is running in a headless environment. The solution is Xvfb (X Virtual Framebuffer): spin up a virtual display, launch Firefox inside it, and the browser believes it's rendering to a real screen.
import subprocess
display = subprocess.Popen(['Xvfb', ':99', '-screen', '0', '1280x720x24'])
os.environ['DISPLAY'] = ':99'
# now launch camoufox — it sees a real display
This combination passes the WAF challenge and gets you the page load.
The second blocker: the browser context kill switch
You've passed the WAF. The page starts loading. And then, about 18–30 seconds later, the browser context closes. Booking.com's client-side JavaScript runs fingerprinting checks after the page hydrates and closes the connection if it doesn't like what it sees.
My first approach was to wait for JS hydration and parse the DOM. That doesn't work — the window closes before hydration finishes. My second attempt was to intercept XHR/fetch calls for the review API. Those calls never fire either.
The key insight came from looking at what actually arrives in the HTTP response.
The real data is in the initial HTML response
Booking.com uses a micro-frontend architecture called Capla, built on Apollo Client. When the server renders the page, it embeds the full Apollo GraphQL cache as JSON inside <script type="application/json"> tags in the HTML. This includes hotel metadata, amenities, pricing, photos — and reviews.
The response body is about 1.7MB. Your browser downloads it in full the moment the server responds, before any JavaScript runs, before any anti-bot checks fire. The data you want is already there.
The trick is to capture it. Playwright's response.body() lets you read the raw HTTP response body from inside a network event listener:
raw_html_bodies = []
async def on_response(response):
if "booking.com/hotel/" in response.url and response.status == 200:
try:
body = await response.body()
body_str = body.decode("utf-8", errors="replace")
if len(body_str) > 20_000: # skip tiny redirect responses
raw_html_bodies.append(body_str)
except Exception:
pass
page.on("response", on_response)
await page.goto(url)
# don't wait for page load — use the captured body immediately
html = max(raw_html_bodies, key=len)
You register the listener before navigation, collect the bodies as they arrive, then take the largest one. By the time the anti-bot kill switch fires, you already have everything you need.
Parsing the Apollo cache
The Apollo cache is a flat object where keys are GraphQL entity identifiers like FeaturedReview:123456 and values are the actual objects. References between objects use {"__ref": "TypeName:id"} pointers.
def _parse_capla_json_scripts(html: str) -> list:
results = []
pos = 0
while True:
start = html.find('<script', pos)
if start == -1:
break
tag_end = html.find('>', start)
tag_text = html[start:tag_end + 1]
pos = tag_end + 1
if 'application/json' not in tag_text:
continue
close = html.find('</script>', pos)
blob = html[pos:close].strip()
pos = close + 9
try:
results.append(json.loads(blob))
except Exception:
pass
return results
def _build_apollo_cache(parsed_scripts: list) -> dict:
cache = {}
for blob in parsed_scripts:
if not isinstance(blob, dict):
continue
for k, v in blob.items():
# Apollo cache keys look like "TypeName:id"
if isinstance(v, dict) and ":" in k and v.get("__typename"):
cache[k] = v
return cache
To find the actual reviews, filter cache entries by __typename. Booking.com's Capla schema uses FeaturedReview for the reviews embedded in the SSR response. Each object has positiveText, negativeText, score, roomType (as a __ref pointer), completedAt (Unix timestamp), and traveller info.
REVIEW_TYPENAMES = frozenset({
"FeaturedReview", "GuestReview", "PropertyReview", "Review",
})
reviews = [
v for k, v in apollo_cache.items()
if v.get("__typename") in REVIEW_TYPENAMES
and (v.get("positiveText") or v.get("negativeText"))
]
The roomType ref resolves by looking up the key in the same cache:
def resolve_ref(val, cache):
if isinstance(val, dict) and "__ref" in val:
return cache.get(val["__ref"])
return val
room_obj = resolve_ref(review.get("roomType"), cache)
room_name = room_obj.get("name") if room_obj else None
What you get
Each hotel page embeds approximately 10 featured reviews — Booking.com's curated selection of the most recent and highest-quality reviews shown to prospective guests. Getting the full review history (thousands of reviews per hotel) would require hitting Booking.com's internal GraphQL API, which is a different problem.
For competitive intelligence, sentiment analysis, and reputation monitoring use cases, the featured reviews are the most useful anyway — they're the ones influencing booking decisions.
The full implementation is available as a ready-to-run actor on Apify: Booking.com Hotel Reviews Scraper. Pass one or more hotel URLs, get a clean dataset of reviewer name, score, positive/negative text, room type, trip purpose, and date. Priced at $1 per 1,000 reviews extracted.
Summary
| Problem | Solution |
|---|---|
| AWS WAF blocks Chromium | Use camoufox (Firefox) + Xvfb virtual display |
| Anti-bot JS closes browser in 18–30s | Capture raw HTTP response body before JS runs |
| Data isn't in DOM, no API calls fire | Parse Apollo/Capla SSR JSON cache from <script> tags |
roomType is a __ref pointer |
Build a flat cache lookup and resolve references |
completedAt is a Unix timestamp |
datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") |
The pattern — WAF bypass with a fingerprint-clean browser, capture before hydration, parse SSR cache — applies to any site built on Apollo Client with server-side rendering. More and more modern travel and e-commerce sites use this stack, so it's a pattern worth knowing.
Top comments (0)