Pinpoint Data Extraction#

Once you have the HTML, you need a precise way to say "these elements, not those". BeautifulSoup (bs4) is the standard Python HTML library, and its CSS selector support (provided by the soupsieve package, installed alongside it) is the most readable and transferable way to do it. The same selector you test in the browser console works in BeautifulSoup, in parsel and Scrapy, in Playwright, and in lxml via cssselect.

select() and select_one()#

  • soup.select_one("selector") returns the first match, or None.
  • soup.select("selector") returns a list of every match, possibly empty.

Both also work on any element, so card.select_one(".price") searches only inside card. Scoping every field lookup to its container is the single most important habit: it guarantees that the price you read belongs to the same product as the title you read.

Essential Syntax#

Selector Matches
div every div element
.price elements whose class list contains price
#main-title the element with id="main-title"
div.card h2 h2 anywhere inside a div.card (descendant)
ul > li li that are direct children of a ul
h3 + span the span immediately after an h3 (adjacent sibling)
h3 ~ span any later sibling span of an h3
a[href] a elements that have an href at all
a[href^="https"] href starts with https
a[href$=".pdf"] href ends with .pdf
[class*="price"] class attribute contains the substring price
[data-state="sold" i] attribute match, case-insensitive
li:nth-of-type(2) the second li among its siblings
div.card:not(.sold-out) cards without the sold-out class
div.card:has(.badge) cards that contain a .badge somewhere inside

.price matches class="price stock-in" because class selectors test membership in the space-separated list. [class="price"] would not match it, since attribute equality compares the whole string.

A Worked Example#

from bs4 import BeautifulSoup

html_doc = '''
<div id="product-list">
    <div class="item" data-id="1">
        <h3 class="title"><a href="/item/1">Laptop Pro</a></h3>
        <span class="price stock-in">$1,200</span>
        <span class="badge">Sale</span>
    </div>
    <div class="item" data-id="2">
        <h3 class="title"><a href="/item/2">Wireless Mouse</a></h3>
        <span class="price stock-out">$45</span>
    </div>
    <div class="item" data-id="3">
        <h3 class="title"><a href="/item/3">USB-C Hub</a></h3>
    </div>
</div>
'''

soup = BeautifulSoup(html_doc, "html.parser")

def text_or_none(parent, selector):
    el = parent.select_one(selector)
    return el.get_text(strip=True) if el else None

rows = []
for prod in soup.select("#product-list > .item"):
    link = prod.select_one("h3.title a")
    rows.append({
        "id": prod.get("data-id"),
        "name": link.get_text(strip=True),
        "url": link.get("href"),
        "price": text_or_none(prod, "span.price"),
        "in_stock": prod.select_one(".price.stock-in") is not None,
    })

for r in rows:
    print(r)
print("on sale:", [c["data-id"] for c in soup.select(".item:has(.badge)")])

Expected output:

{'id': '1', 'name': 'Laptop Pro', 'url': '/item/1', 'price': '$1,200', 'in_stock': True}
{'id': '2', 'name': 'Wireless Mouse', 'url': '/item/2', 'price': '$45', 'in_stock': False}
{'id': '3', 'name': 'USB-C Hub', 'url': '/item/3', 'price': None, 'in_stock': False}
on sale: ['1']

Notice the third product. It has no price, and without the text_or_none helper, prod.select_one("span.price").text raises AttributeError: 'NoneType' object has no attribute 'text' and kills the whole run on one incomplete card. Decide per field whether missing means "store None" or "this record is broken, log it", and code that decision explicitly.

.price.stock-in (no space) means one element with both classes. .price .stock-in (with a space) means a .stock-in inside a .price. That one space is a frequent bug.

Pseudo-Classes Worth Knowing#

  • Positional: li:nth-of-type(odd), tr:nth-child(n+2) (every row except the first, handy for skipping header rows), li:last-child.
  • Negation: a:not([rel="nofollow"]), div.card:not(.ad) to drop sponsored slots.
  • Relational: :has() selects a parent by what it contains, for example tr:has(td.label) or dd:has(> a). soupsieve supports it, so you do not need a Python loop to walk upwards.
  • Text matching: soupsieve adds a non-standard :-soup-contains("In stock") that matches elements whose text contains a string. It is BeautifulSoup-only and will not work in the browser console, so use it sparingly.

A label-value pattern is where relational and sibling selectors earn their keep:

specs = BeautifulSoup('''
<dl class="specs">
  <dt>Brand</dt><dd>Acme</dd>
  <dt>Weight</dt><dd>1.2 kg</dd>
</dl>''', "html.parser")

weight = specs.select_one('dt:-soup-contains("Weight") + dd')
print(weight.get_text(strip=True))   # 1.2 kg

print({dt.get_text(strip=True): dt.find_next_sibling("dd").get_text(strip=True)
       for dt in specs.select("dl.specs dt")})
# {'Brand': 'Acme', 'Weight': '1.2 kg'}

Anchoring on the label text ("Weight") instead of position (dd:nth-of-type(2)) survives the day the site adds a new spec row above it.

Writing Selectors That Survive Redesigns#

Selectors break when they depend on things the site changes freely. Rank your hooks by how long they tend to live:

  1. Data and semantic attributes: [data-testid="price"], [itemprop="price"], [data-sku]. These exist for code, so developers avoid changing them.
  2. Meaningful IDs and class names: #product-list, .product-card.
  3. Structure anchored on a stable parent: #reviews li > p.
  4. Position: div:nth-child(3) > span:nth-child(2). Brittle; any inserted element shifts it.
  5. Generated class names: .css-1x9a2kd, .Card_title__3fGhQ. These change on every build. If you must, use a prefix match such as [class^="Card_title"], which survives the hash suffix changing.

Selectors copied from DevTools ("Copy selector") are usually level 4 or 5, and they often contain tbody elements the server never sent. Treat them as a starting point to simplify, not a finished answer.

Debugging a Selector#

When a selector returns nothing or the wrong element, work through this in order:

  1. Is the data in the raw HTML? Search the saved response for the visible text. If it is absent, the page is rendered client-side; see JavaScript-rendered pages.
  2. Test it in the browser. In the console, $$(".product .price").length runs querySelectorAll and shows the count. If the browser finds 24 and Python finds 0, the difference is the raw HTML versus the live DOM, or a parser repair difference.
  3. Count, then narrow. len(soup.select(".price")) tells you if the selector is too broad (48 matches for 24 products usually means a strikethrough "was" price is also matched) or too narrow.
  4. Run it against saved samples. Keep a few saved pages per template and run your selectors against all of them in a test. The layout drift detection lesson turns this into monitoring.

A selector that silently returns empty data is worse than a crash, because a crash announces itself. Add a check that fails loudly when a page yields zero records or a required field is None on most rows.

CSS or XPath?#

CSS selectors cover most extraction. XPath is stronger when you need to move upwards from a node, select by exact text across the standard, or pick text nodes directly. Many scrapers use CSS for containers and fall back to XPath for the handful of awkward fields; parsel lets you mix both on the same document.

Practice#

Take a saved listing page and write a selector set that extracts id, title, URL, and price for every item. Then edit the saved HTML by hand to simulate a redesign: rename one generated class, insert an extra element at the top of each card, and remove the price from one item. Your extractor should still return correct values for every field that remains and None for the removed price, without raising.