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?#
| Playwright | Selenium | |
|---|---|---|
| Waiting | Auto-waits for elements to be actionable | Manual WebDriverWait / explicit waits |
| Setup | playwright install fetches matched browsers | Manage driver binaries yourself |
| Speed | Faster; one protocol, async-native | Slower; more moving parts |
| Ecosystem | Newer, excellent docs | Older, 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:
- Test/data attributes β
[data-testid="price"]. Put there deliberately; rarely churn. - Semantic roles / text β
page.get_by_role("button", name="Next"). Reads like intent. - Stable IDs β
#search-results. - 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 actionableA 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#
| Symptom | Cause | Fix |
|---|---|---|
TimeoutError waiting for a selector | Element is in an iframe, or never appears | page.frame_locator(...); verify the selector in DevTools |
| Works headed, fails headless | Site detects headless, or layout differs | Try headless=False; see Anti-bot Patterns |
| Empty text from a real element | Read before hydration finished | Wait on the content, not just the node |
| Random flakiness | sleep()-based timing | Replace with wait_for_selector / expect |
| Painfully slow at scale | Loading images, fonts, ads | Block them β Playwright Advanced |
Your turn (β15 min)#
- Run
scroll_scrape.py. Then fetch the same URL with plainhttpxand confirm the quotes are absent β that contrast is the whole reason browsers exist. - Switch to
headless=Falseand watch it run. - Rewrite the extraction using
page.get_by_role/get_by_textinstead of CSS classes. - 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
httpxbut full in a browser.
Go deeper#
- Playwright Python docs β the official starting point.
- Playwright locators β the recommended selector strategy.
- Selenium docs β for inherited codebases.
- Playwright Advanced β interception, saved auth, tracing, speed.