CSS selectors for scraping: the 8 that do 95% of the work

tag, .class, #id, [attr], [attr=value], descendant, child — with the failure each one causes when you get it wrong.

Selector syntax is not the hard part of scraping. Knowing which selector will quietly return the wrong rows is. Here are the eight that matter, and the trap attached to each.

1 · tag — the blunt instrument

h3        every heading on the page

Trap: a page has 40 div and 12 span. Use a bare tag only when you also scope it (inside a row container), otherwise you are guessing.

2 · .class — the workhorse

.price     any element with class="price"

Trap: class="price old-price" matches too. If the page has both a current and a struck price, this returns whichever is first in the DOM — usually the original price, not the sale price.

3 · tag.class — narrow it down

span.price

Trap: still matches multiple. Always print how many rows and how many non-empty values you got before trusting the output.

4 · #id — unique, when the page is honest

#main

Trap: duplicated ids are invalid HTML and extremely common. If #price appears three times, your selector silently returns the first.

5 · [attr] — has the attribute

a[href]         links that actually link somewhere

Trap: a[href="#"] and a[href="javascript:void(0)"] also pass. Filter the value, not the presence.

6 · [attr=value] — exact match

div.card[data-sku]

Trap: attribute matching is case-sensitive and exact. rel="nofollow noopener" does not equal [rel="nofollow"].

7 · descendant (A B) — anywhere below

div.list h3

Trap: matches any depth. A nested table inside a card carries its own h3, and you get extra rows. This is the single most common source of "why do I have 47 rows instead of 9".

8 · child (A > B) — one level only

div.list > div.card

This is the one you want for row containers. It excludes the wrapper that reuses the same class (see the scraping guide) and it excludes nested repeats.

What is deliberately not in this list

SelectorWhy it is skipped
:nth-child(n)breaks the moment the page adds a banner row
:not(...)usually a symptom of the container being wrong
A + B, A ~ Bsibling order is not stable on real pages
[attr^=x], [*=x]convenient, but easy to over-match

If your extraction needs these, the page structure is fighting you and it is usually faster to find a better container than a cleverer selector.

Three checks before you trust a scrape

  1. Count. document.querySelectorAll(sel).length in the browser console versus the number of rows your script produced. They must be equal.
  2. Count non-empty. Every field should be non-empty on nearly every row. A field that is empty 50% of the time is either optional or wrong.
  3. Spot-check three rows against the live page. Not the first three — the first, a middle one, and the last.

The tool

WebScrape Kit implements selectors 1 to 8 above in a single dependency-free Python file, and it runs check 2 for you: at the end it lists any field that came back empty on every row, because that is a selector bug rather than missing data. 29 USDT, offline test site included.

WebScrape Kit

Turn an HTML page into a spreadsheet with a CSS selector — one file, stdlib only, nothing to install.

Get it — 29 USDT