Handling anti-bot challenges with Playwright
A competitor booking engine that renders its rate calendar with JavaScript and gates it behind a bot check will defeat a plain HTTP scraper every time. When a request-based fetch is not enough, a headless browser driven by Playwright can render the page, wait for the rates to load, and back off politely when a challenge appears. This guide covers that fallback path within Competitor Rate Scraping Pipelines, part of the Data Ingestion & OTA API Integration Workflows pillar. The goal is reliable extraction that respects target sites and commercial agreements — not evasion for its own sake.
Prerequisites
- Python 3.11+ and
playwright(withplaywright install chromium) asynciofor concurrency- A target registry of competitor URLs and the room categories to extract
- Backoff and retry primitives consistent with Rate Limiting & Retry Strategies
- A normalization step downstream, as in Normalizing scraped rates to a canonical schema
Step 1 — Launch a realistic browser context
Anti-bot systems fingerprint headless defaults. Launch a context with a real viewport, locale, and user agent so the page renders the way a genuine visitor’s would, and reuse the context across requests to the same domain to keep session cost down.
from __future__ import annotations
import asyncio
import logging
from playwright.async_api import async_playwright, Browser, BrowserContext
logger = logging.getLogger("scrape.browser")
async def new_context(browser: Browser) -> BrowserContext:
return await browser.new_context(
viewport={"width": 1366, "height": 900},
locale="en-US",
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0 Safari/537.36"),
)
Step 2 — Render, wait for real content, and detect challenges
Navigate, then wait for the specific element that holds the rate rather than a fixed sleep — waiting on content is both faster and more robust. If a challenge page appears instead, detect it and signal a back-off rather than scraping garbage.
class ChallengeDetected(Exception):
pass
async def fetch_rate_html(context: BrowserContext, url: str,
rate_selector: str) -> str:
page = await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=20_000)
title = (await page.title()).lower()
if any(sig in title for sig in ("just a moment", "verify", "are you human")):
raise ChallengeDetected(url)
await page.wait_for_selector(rate_selector, timeout=15_000)
return await page.inner_html(rate_selector)
finally:
await page.close()
Step 3 — Back off on challenges instead of hammering
A detected challenge is a signal to slow down, not to retry immediately. Apply exponential backoff and, after repeated challenges, route the target to a manual-review queue so a human can decide whether continued access is appropriate.
async def fetch_with_backoff(context: BrowserContext, url: str, selector: str,
max_attempts: int = 3) -> str | None:
for attempt in range(1, max_attempts + 1):
try:
return await fetch_rate_html(context, url, selector)
except ChallengeDetected:
wait = 2 ** attempt
logger.warning("challenge on %s (attempt %d); backing off %ds",
url, attempt, wait)
await asyncio.sleep(wait)
logger.error("giving up on %s after %d challenges; routing to review",
url, max_attempts)
return None
Backing off and escalating to review, rather than retrying aggressively, keeps the pipeline within acceptable-use norms and avoids the IP reputation damage that makes every future request harder.
Verification and testing
Playwright ships a test server pattern; here a local HTML fixture confirms the selector-wait and challenge detection without hitting a real site.
import asyncio
async def _run(html_path: str, selector: str) -> str:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
context = await new_context(browser)
result = await fetch_rate_html(context, f"file://{html_path}", selector)
await browser.close()
return result
def test_extracts_rate_block(tmp_path) -> None:
fixture = tmp_path / "rates.html"
fixture.write_text('<div id="rate">180</div>')
html = asyncio.run(_run(str(fixture), "#rate"))
assert "180" in html
Common pitfalls and edge cases
- Fixed sleeps.
sleep(5)is both slow and flaky; wait on the rate selector instead. - Leaking pages. Not closing pages exhausts browser memory over a long run; always close in a
finally. - Ignoring challenges. Scraping a challenge page yields junk that poisons normalization; detect and back off.
- One context per request. Recreating the browser context per URL is expensive; reuse per domain.
- Terms of service. Headless rendering does not override a site’s terms; keep targets within agreed commercial boundaries and honor review escalations.
Related
- Competitor Rate Scraping Pipelines — the parent cluster and its request-based transport layer.
- Normalizing scraped rates to a canonical schema — what turns the extracted HTML into comparable records.
- Rate Limiting & Retry Strategies — the backoff patterns this fallback reuses.