Playwright & Selenium#

When there’s genuinely no API behind the page, drive a real browser β€” and drive it so it waits for content instead of guessing.

⏱ ~9 min read Β· ~15 min hands-on πŸ”— needs: Hidden JSON APIs Β· HTTP clients

Browser automation is the heavyweight option: it renders JavaScript, executes the page’s own code, and sees exactly what a user sees. It’s also 10–100Γ— slower than an HTTP request and far more fragile. Use it after you’ve checked for a hidden JSON API, not before.

Try it in 5 minutes#

Playwright ships its own browsers, so setup is two commands:

uv run --with playwright playwright install chromium
# /// script
# requires-python = ">=3.12"
# dependencies = ["playwright>=1.40"]
# ///
"""Scrape a JS-rendered page with Playwright.

Setup: uv run --with playwright playwright install chromium
Run:   uv run scroll_scrape.py
"""

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/js/")  # quotes rendered by JavaScript

    page.wait_for_selector(".quote")  # wait for content, never sleep()
    quotes = [
        {"text": q.inner_text(), "author": q.get_attribute("data-author")}
        for q in page.query_selector_all(".quote span.text")
    ]
    print(f"{len(quotes)} quotes")
    print(quotes[0]["text"])
    browser.close()

βœ… quotes.toscrape.com/js/ renders entirely in JavaScript β€” httpx alone returns an empty shell, but the browser sees all ten.

Playwright or Selenium?#

PlaywrightSelenium
WaitingAuto-waits for elements to be actionableManual WebDriverWait / explicit waits
Setupplaywright install fetches matched browsersManage driver binaries yourself
SpeedFaster; one protocol, async-nativeSlower; more moving parts
EcosystemNewer, excellent docsOlder, vast legacy corpus, wide language support

Default to Playwright for new work. Learn Selenium when you inherit it β€” the concepts transfer directly.

Selectors that survive a redesign#

The single biggest cause of “my scraper broke overnight” is a brittle selector. Prefer, in order:

  1. Test/data attributes β€” [data-testid="price"]. Put there deliberately; rarely churn.
  2. Semantic roles / text β€” page.get_by_role("button", name="Next"). Reads like intent.
  3. Stable IDs β€” #search-results.
  4. Structural CSS β€” .col-md-8 > div:nth-child(3). Last resort; breaks on any layout tweak.

Generated class names (.css-1x2y3z, Tailwind soups) change on every build. Never anchor to them.

Never sleep() β€” wait for a condition#

page.wait_for_selector(".quote")            # an element exists
page.wait_for_load_state("networkidle")     # network has settled
page.get_by_role("button", name="Next").click()   # auto-waits for actionable

A fixed time.sleep(3) is simultaneously too slow (usually) and too short (occasionally) β€” the worst of both. Condition-based waits are faster and more reliable.

βš–οΈ A browser executes the site’s JavaScript and looks exactly like a user. That doesn’t change what you’re permitted to collect β€” Legal & Ethical Scraping still applies, and browsers make it easy to hammer a site by accident. Pair with Rate Limits.

When it fails#

SymptomCauseFix
TimeoutError waiting for a selectorElement is in an iframe, or never appearspage.frame_locator(...); verify the selector in DevTools
Works headed, fails headlessSite detects headless, or layout differsTry headless=False; see Anti-bot Patterns
Empty text from a real elementRead before hydration finishedWait on the content, not just the node
Random flakinesssleep()-based timingReplace with wait_for_selector / expect
Painfully slow at scaleLoading images, fonts, adsBlock them β†’ Playwright Advanced

Your turn (β‰ˆ15 min)#

  1. Run scroll_scrape.py. Then fetch the same URL with plain httpx and confirm the quotes are absent β€” that contrast is the whole reason browsers exist.
  2. Switch to headless=False and watch it run.
  3. Rewrite the extraction using page.get_by_role/get_by_text instead of CSS classes.
  4. Add pagination: click “Next” until it disappears β†’ Pagination & Infinite Scroll.

Checklist#

  • I check for a JSON API before reaching for a browser.
  • I can launch Playwright, navigate, wait for a selector, and extract text.
  • I prefer data-testid / roles over generated class names.
  • I never use sleep() for synchronisation.
  • I know why a page can be empty in httpx but full in a browser.

Go deeper#