DataLens

Selecting elements (selectors, get_by_role / get_by_text)

Find exactly the elements you want with CSS selectors and Playwright's user-facing locators, narrow them with filters, and understand strict mode.

Level: Foundations · 9 min read

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

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:

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
SelectorMatches
.priceelements with class price
#product-listthe element with id product-list
.card .price.price anywhere inside .card
ul > lili 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

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

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()).

Key idea

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

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

Next: Extracting text and attributes →