DEV Community

dbott23
dbott23

Posted on

How Booking.com anti-bot works — and how I built a scraper that gets past it

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"))
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)