ApiSkills
Web Scraping · CSS Selector Lab

Learn to select exactly what you need.

Not every source has an API — sometimes the data only lives in a web page. Twelve challenges across five parts, from a single tag to combined real-world patterns. Type a real CSS selector, run it against live sample HTML, and see instantly what it matches — the same skill that powers extraction in n8n's HTML node and Power Query's Html.Table.

How to use this: read the task, inspect the raw HTML if you need to, then write your own selector before opening the hint. The hint teaches the underlying rule in general terms — the Solution box gives the exact answer once you're ready.

Part A · Basics

Tag, class, id — the primitives every selector is built from.

Challenge 1 · Element selector

A recipe blog post

Task A: select every paragraph in the article below — not the heading, not the link.
Task B: select every link in the article.

Homemade Masala Chai

A warming spiced tea recipe, perfect for monsoon evenings.

Serves 2. Prep time: 10 minutes. Total time: 15 minutes.

Jump to full recipe View comments
</> View raw HTML
<article>
  <h1>Homemade Masala Chai</h1>
  <p>A warming spiced tea recipe, perfect for monsoon evenings.</p>
  <p>Serves 2. Prep time: 10 minutes. Total time: 15 minutes.</p>
  <a href="#recipe">Jump to full recipe</a>
  <a href="#comments">View comments</a>
</article>
Task A — paragraphs
Task B — links
Challenge 2 · Class selector

Three product cards

Task A: select every price across all three cards.
Task B: select every badge — both the red "Sale" one and the blue "New" one — with a single selector.

Sale
Wireless Mouse
₹899
In stock
New
Mechanical Keyboard
₹3,499
In stock
USB-C Hub
₹1,199
Out of stock
</> View raw HTML
<div class="card">
  <span class="badge sale">Sale</span>
  <div class="title">Wireless Mouse</div>
  <div class="price">₹899</div>
  <div class="meta">In stock</div>
</div>
<div class="card">
  <span class="badge new">New</span>
  ...
</div>
<div class="card">
  <div class="title">USB-C Hub</div>
  ...  <!-- no badge on this one -->
</div>
Task A — prices
Task B — badges (both colours, one selector)
Challenge 3 · ID selector

A pricing panel

Task: select only the featured plan — the single plan marked with a unique id, not the other two.

Starter

₹999/mo

Enterprise

₹5,999/mo
</> View raw HTML
<div class="plan"><h3>Starter</h3>...</div>
<div class="plan featured" id="featured-plan">
  <h3>Professional</h3>
  <div class="amt">₹2,499/mo</div>
</div>
<div class="plan"><h3>Enterprise</h3>...</div>
Challenge 4 · Chaining multiple classes

Four cards, mixed states

Task: select only the card that is both on sale and currently in stock — not cards that are only one or the other.

Desk Lamp
Sale · In stock
Bluetooth Speaker
Sale · Out of stock
Table Fan
Full price · In stock
Wall Clock
Full price · Out of stock
</> View raw HTML
<div class="card sale instock"><div class="title">Desk Lamp</div>...</div>
<div class="card sale"><div class="title">Bluetooth Speaker</div>...</div>
<div class="card instock"><div class="title">Table Fan</div>...</div>
<div class="card"><div class="title">Wall Clock</div>...</div>
Part B · Attributes

Filter on values that are not visible text — links, paths, flags.

Challenge 5 · Attribute selectors

A navigation bar and a download list

Task A: in the nav bar, select only the links that actually go somewhere — ignore the two disabled, placeholder items that have no destination at all.
Task B: in the download list, select only the files whose link starts with a specific download path, ignoring the unrelated page links mixed in.

</> View raw HTML — nav bar
<a href="/pricing">Pricing</a>
<span class="disabled">Coming soon</span>
<a href="/docs">Docs</a>
<span class="disabled">Beta feature</span>
Task A — links with a real destination
</> View raw HTML — download list
<a href="/download/invoice.pdf">Invoice.pdf</a>
<a href="/blog/gst-updates">GST updates — blog</a>
<a href="/download/report-q1.xlsx">Report Q1.xlsx</a>
<a href="/contact">Contact us</a>
<a href="/download/certificate.pdf">Certificate.pdf</a>
Task B — only the download links
Part C · Grouping & Combinators

How selectors relate to each other and the page structure.

Challenge 6 · Grouping (comma)

Two small cards

Task: select every title AND every price across both cards, using a single selector (four elements total).

Desk Lamp
₹799
Table Fan
₹1,499
</> View raw HTML
<div class="card">
  <div class="title">Desk Lamp</div>
  <div class="price">₹799</div>
</div>
<div class="card">
  <div class="title">Table Fan</div>
  <div class="price">₹1,499</div>
</div>
Challenge 7 · Descendant vs. child combinator

A card with a nested title trap

This card has two elements with class .title — one is the real product title, a direct child of .card; the other is buried inside a .meta wrapper. Select only the direct-child title.

Noise Cancelling Headphones
₹6,999
Related: Wired Earphones
</> View raw HTML
<div class="card">
  <div class="title">Noise Cancelling Headphones</div>
  <div class="price">₹6,999</div>
  <div class="meta">
    Related: <span class="title">Wired Earphones</span>
  </div>
</div>
Challenge 8 · Adjacent sibling combinator

An FAQ entry

Task: select only the direct answer that comes immediately after the question — not the follow-up note paragraph below it. Neither paragraph has a distinguishing class, so you will need to select by position relative to the heading.

Do you ship internationally?

Yes, we ship to over 40 countries worldwide.

Delivery times vary by region, typically 5–10 business days.

</> View raw HTML
<div class="faq-item">
  <h4>Do you ship internationally?</h4>
  <p>Yes, we ship to over 40 countries worldwide.</p>
  <p>Delivery times vary by region, typically 5–10 business days.</p>
</div>
Part D · Pseudo-classes

Select by position among siblings, and by exclusion.

Challenge 9 · first-child / last-child

A recipe's step list

Task A: select only the first step.
Task B: select only the last step. No class distinguishes any of these — only their position in the list.

  1. Preheat the oven to 180°C.
  2. Mix the dry ingredients together in a large bowl.
  3. Fold in the wet ingredients gently, don't overmix.
  4. Bake for 25 minutes, then cool before slicing.
</> View raw HTML
<ol class="steps">
  <li>Preheat the oven to 180°C.</li>
  <li>Mix the dry ingredients together in a large bowl.</li>
  <li>Fold in the wet ingredients gently, don't overmix.</li>
  <li>Bake for 25 minutes, then cool before slicing.</li>
</ol>
Task A — first step
Task B — last step
Challenge 10 · nth-child formulas

A fee schedule table

Task: select every data row except the header that falls on an odd position counting from the header row itself — visually, rows 3 and 5 in this table (the header is row 1).

PlanSetup FeeMonthly
Starter₹0₹999
Professional₹500₹2,499
Business₹1,000₹4,499
Enterprise₹2,000₹5,999
</> View raw HTML
<table>
  <thead><tr><th>Plan</th>...</tr></thead>
  <tbody>
    <tr><td>Starter</td>...</tr>
    <tr><td>Professional</td>...</tr>
    <tr><td>Business</td>...</tr>
    <tr><td>Enterprise</td>...</tr>
  </tbody>
</table>
Challenge 11 · :not() negation

The pricing panel again

Task: select every plan except the featured one — without listing "Starter" and "Enterprise" individually.

Starter

₹999/mo

Enterprise

₹5,999/mo
</> View raw HTML
<div class="plan"><h3>Starter</h3>...</div>
<div class="plan featured"><h3>Professional</h3>...</div>
<div class="plan"><h3>Enterprise</h3>...</div>
Part E · Real World

Chain several techniques into one realistic extraction.

Challenge 12 · Capstone

A live inventory feed

Task: select only the titles of in-stock products that are not already marked "Featured" elsewhere on the page (to avoid listing the same product twice in your extraction). You will need an attribute filter, a negation, and a descendant step — all in one selector.

Wireless Mouse
₹899
In stock
Mechanical Keyboard
₹3,499
Out of stock
USB-C Hub
₹1,199
In stock · Featured above
Laptop Stand
₹1,799
In stock
</> View raw HTML
<div class="card" data-instock="true">
  <div class="title">Wireless Mouse</div>...
</div>
<div class="card" data-instock="false">
  <div class="title">Mechanical Keyboard</div>...
</div>
<div class="card" data-instock="true" data-featured="true">
  <div class="title">USB-C Hub</div>...
</div>
<div class="card" data-instock="true">
  <div class="title">Laptop Stand</div>...
</div>
Knowledge check

Quiz — 26 questions, easy to hard

Read each question, think through your answer, then expand to check. Where useful, try the selector in the matching challenge above first.

Easy
What symbol do you put in front of a class name to select it in CSS?
A period/dot. .price selects every element with class="price" (or that class among others).
What symbol do you put in front of an id to select it?
A hash. #featured-plan selects the one element with id="featured-plan".
How many elements should an id selector normally match on a well-formed page?
Exactly one. An id is meant to be unique per page — if your selector matches more than one, something on the page is not following that rule.
Does h2 as a selector match <h2> elements with any class, or only ones with no class at all?
Any class, or no class. A bare tag selector ignores attributes entirely — it matches purely on tag name.
Given <span class="price sale">, will the selector .sale match it?
Yes. An element can carry multiple classes in one space-separated attribute, and a class selector matches if the class appears anywhere in that list.
Is .card.sale (no space) the same thing as .card .sale (with a space)?
No, and mixing them up is one of the most common beginner mistakes. .card.sale means one single element must carry both classes. .card .sale means an element with class "sale" located anywhere inside an element with class "card" — two different elements, a parent and a descendant.
Medium
What is the difference between .card .title and .card > .title?
The space version (descendant combinator) matches a .title at any depth inside .card — a child, grandchild, anywhere. The > version (child combinator) only matches a .title that is a direct child, one level down.
Write a selector that matches only <span> elements with class "price" — not a <div class="price">.
span.price — chaining a tag name directly against a class (no space) narrows the match to that tag only.
What does [href^="/download/"] mean, in plain words?
Match any element whose href attribute starts with the text /download/. The ^= operator means "starts with".

Compare with $= ("ends with") and *= ("contains anywhere").

In n8n's HTML Extract node, which Return Value option would you use to get an image's file path, and why not "Text"?
Use Attribute (then specify src). "Text" pulls only the visible text content of an element — an <img> tag has no visible text, so Text returns nothing. The file path lives in the src attribute, which only "Attribute" can reach.
Why might a selector that worked last month suddenly return zero matches today?
The website's markup likely changed — a class name was renamed, or the structure was restructured in a redesign. Selectors are only as stable as the HTML they target; this is the main maintenance cost of scraping.
What does a comma do inside a selector, e.g. .title, .price?
It groups two independent selectors into one request: match anything fitting the first OR anything fitting the second. Unlike chaining, the two sides can match completely different, unrelated elements — it is not an "AND both on the same element" condition.
What's the difference between h4 + p and h4 ~ p?
h4 + p (adjacent sibling) matches only the single paragraph immediately following the h4. h4 ~ p (general sibling) matches every paragraph that comes after the h4 at the same level, not just the very next one. The plus sign is the one used most often when you need exactly one specific neighbour.
Hard
A page has a table where the header row is a <tr> inside <thead>, and data rows are separate <tr> elements inside <tbody>. Does tr:nth-child(2) count the header row when deciding what "child #2" means?
No — because <thead> and <tbody> are separate parents, each row only counts its position among siblings within its own parent. A <tr> in tbody never counts the thead row as a sibling.
You need every <a> tag whose href contains the word "sale" anywhere in the URL, not just at the start or end. Which attribute operator do you use?
*= — e.g. a[href*="sale"]. It matches the substring appearing anywhere in the attribute's value.
In Power Query's Html.Table, how do you extract an attribute value (like src) instead of the element's visible text?
Add a third item to that column's definition tuple: {"imageUrl", ".card img", each [Attributes][src]}. Without the third part, Html.Table defaults to extracting visible text.
Why is copying the browser's auto-generated "Copy selector" often a bad long-term choice, even though it works right now?
DevTools tends to generate long, positional selectors (e.g. div > div:nth-child(3) > span) tied to the page's exact current layout. A tiny redesign — one new wrapper div — breaks the whole chain. A selector built on a stable class or data-* attribute survives far more changes.
You want every product card that is currently in stock AND on sale. The card has both data-instock="true" and class="card sale". Write a selector that requires both conditions.
.card.sale[data-instock="true"] — chaining two classes with no space (.card.sale) means "has both classes", and the attribute selector adds the third condition. All three chained directly means all must be true on the same element.
Is li:first-child the same as li:nth-child(1)?
In practice, yes, when the first child of the parent happens to be an <li> — both match that same element. :first-child is really just a convenient shorthand for the specific case of :nth-child(1); if the very first child were some other tag instead, neither would match the <li>, since both require that exact position, not merely "the first li".
Write a selector that matches all .plan elements except the one with class .featured, without listing the other classes individually.
.plan:not(.featured) — the negation pseudo-class excludes anything matching the condition inside its parentheses from an otherwise normal selector.
Expert
A scraping job returns 0 rows even though the selector is correct and the page clearly shows the data in your browser. Name the most likely cause and how you'd confirm it.
The content is very likely rendered by JavaScript after the initial page load — a plain HTTP fetch (n8n's HTTP Request node, or Power Query's Web.Contents) only ever sees the raw HTML delivered on the first request, before any script runs. To confirm, use your browser's "View Page Source" (not Inspect) — if the data is missing there too, JavaScript is filling it in later, and you would need a headless-browser node instead.
Write one selector that matches only the 2nd <td> in every row of a table — the pattern you'd use to consistently pull, say, the "Monthly" column from a fee-schedule table.
table td:nth-child(2) — because within each <tr>, the <td> elements are the children being counted, and every row's 2nd cell shares that structural position.
Between these two, which is more resilient to a site redesign, and why: .product-list > div:nth-child(2) > .price or .product-card .price?
.product-card .price is far more resilient. It depends only on two class names staying consistent, regardless of how many wrapper elements sit between them or what order cards appear in. The nth-child version breaks the moment a card is added, removed, or reordered — it hard-codes a position rather than a meaning.
In n8n, you set Return Array off and your selector matches five product cards. What comes back, and what's the fix if you need all five?
Only the first matching element's value is returned — the other four are silently discarded. The fix is switching Return Array on, so the node returns one JSON entry per match instead of collapsing to the first.
A site's product cards sometimes render a "Save 10%" badge and sometimes don't. Your Text extraction for .card's full text occasionally includes the badge wording mixed into the description. Which n8n field fixes this, and how?
Use Skip Selectors, listing the badge's class (e.g. .badge). This tells the node to ignore that child element's text when assembling the Text/HTML result for the parent, so its wording no longer leaks into the surrounding extraction.
Write a selector for "the title of every in-stock card, except any card already flagged as featured" — combining an attribute check, a negation, and a descendant step in one expression.
.card[data-instock="true"]:not([data-featured="true"]) .title — the attribute selector filters to in-stock containers, :not() with an attribute condition inside it excludes the featured one, and the trailing space steps down into the title field once the right containers are isolated.