The idea
Every scraper boils down to two steps: find elements, then read them. Finding is the fragile part: if your selector breaks when the site changes a CSS class, the scraper breaks. Playwright gives you two kinds of locators:
- CSS selectors, which describe the page's structure (
.product-card .price); - user-facing locators, which describe what a person sees (
get_by_role("button", name="Search")).
Locators are recipes, not results
To get anything out of a page, you first have to point at it: "the product cards", "the price inside this card". In Playwright, you do that with a locator. Here's one for every product card on the listing page:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://nusantara-gadget.test/products")
cards = page.locator(".product-card")
print(cards)
print(cards.count())
browser.close()
<Locator locator(".product-card")>
12
page.locator(...) doesn't search anything yet; it's a recipe for finding elements. The search happens when you act on it (count(), inner_text(), click()), and Playwright re-runs it each time, so a locator never goes stale when the page changes.
CSS selectors you'll actually use
The card markup on the products page looks like this:
<article class="product-card" data-id="1" data-category="phones">
<h3 class="title"><a href="/product/1">Galaxy A55 5G</a></h3>
<p class="brand">Samsung</p>
<p class="price" data-price="5999000">Rp 5.999.000</p>
...
<span class="stock in-stock">In stock</span>
</article>
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://nusantara-gadget.test/products")
print(page.locator(".product-card .title").first.inner_text()) # descendant
print(page.locator('[data-category="laptops"]').count()) # attribute
print(page.locator(".product-card:has(.sold-out)").count()) # has a child
print(page.locator("#product-list > article").count()) # id + direct child
print(page.locator('.product-card[data-id="3"] .price').inner_text())
browser.close()
Galaxy A55 5G
4
2
12
Rp 13.999.000
| Selector | Matches |
|---|---|
.price | elements with class price |
#product-list | the element with id product-list |
.card .price | .price anywhere inside .card |
ul > li | li that are direct children of ul |
[data-id="3"] | attribute equals a value |
.card:has(.sold-out) | cards containing a .sold-out element |
Prefer stable hooks: ids, data-* attributes and meaningful class names (.price) survive redesigns better than long paths like div > div:nth-child(3) > p.
User-facing locators
These find elements the way a person (or a screen reader) would describe them, so they keep working when the HTML is restructured:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://nusantara-gadget.test/products")
print(page.get_by_role("heading", level=1).inner_text())
print(page.get_by_role("link", name="Next").get_attribute("href"))
print(page.get_by_placeholder("Search products").count())
print(page.get_by_text("Sold out").count())
print(page.get_by_role("link", name="iPhone 15").get_attribute("href"))
browser.close()
All products
/products?page=2
1
2
/product/3
get_by_role(role, name=...): links, buttons, headings, textboxes, checkboxes, rows... Thenameis the visible text (or label). This is Playwright's recommended first choice.get_by_text(text): any element whose text containstext(exact=Truefor whole-text matches).get_by_label(text),get_by_placeholder(text): form fields.get_by_test_id(id): elements withdata-testid="...", when developers add test hooks.
Narrowing down: filter, nth, chaining
Often a selector matches too much. You want only the Apple products, or the price inside one particular card, or just the first result. Locators can be narrowed step by step:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://nusantara-gadget.test/products")
cards = page.locator(".product-card")
apple = cards.filter(has_text="Apple")
print(apple.count())
print(apple.locator(".title").all_inner_texts())
print(cards.filter(has=page.locator(".sold-out")).locator(".title").all_inner_texts())
print(cards.nth(2).locator(".title").inner_text(), "|", cards.last.locator(".title").inner_text())
browser.close()
2
['iPhone 15', 'MacBook Air M3']
['Redmi Note 13 Pro', 'Vivo V30']
iPhone 15 | Swift Go 14
.filter(has_text=...)keeps elements containing that text;.filter(has=locator)keeps elements containing a matching child..locator(...)on a locator searches inside each match..first,.last,.nth(i)pick one match (0-based).
Strict mode
Actions that need one element (inner_text, click, fill) refuse to guess when a locator matches several:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://nusantara-gadget.test/products")
page.locator(".price").inner_text()
Error: strict mode violation: locator(".price") resolved to 12 elements. Make the locator more specific, or use .first, .nth(i) or .all().
That error is a feature: it stops your scraper silently reading the wrong price. Make the locator specific, or use .first, .nth(i), or methods that work on many (count(), all_inner_texts(), all()).
Prefer user-facing locators (get_by_role, get_by_text, get_by_label) and stable CSS hooks (ids, data-*, meaningful classes). Narrow with filter, chaining and nth. Strict mode protects you from ambiguous matches.
Why this matters for clean code
Selectors are the contract between your scraper and a site you don't control. Keep them in one place (a dict of named selectors at the top of the file), make them describe meaning rather than layout, and a redesign becomes a two-line fix instead of a hunt through the code.
Recap
page.locator(css)is a lazy recipe, re-run on every action.- CSS:
.class,#id,a b,a > b,[attr="v"],:has(...). get_by_role,get_by_text,get_by_label,get_by_placeholder,get_by_test_id.- Narrow with
.filter(has_text=/has=), chained.locator(),.first/.last/.nth(i). - Single-element actions raise a strict mode error when several elements match.