Tutorial8 min read

HTML Scraping With CSS Selectors and XPath

Short answer

HTML scraping means downloading a page's HTML, parsing it into a tree of elements (the DOM), and picking out the data with CSS selectors or XPath: div.price or //div[@class="price"] for a price, a[href] for links, the main content element for article text. Use CSS selectors for most fields and XPath when you need to match by text or walk up the tree; avoid regular expressions on whole pages. When an HTML scraper needs many IPs, ProxyField residential proxies plug into any HTTP client from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.

residential IPs
412M+
from, traffic never expires
$1.95/GB
Easy Trial
1 GB for $2.95
On this page 13 sections
  1. How HTML scraping works
  2. A minimal HTML scraper
  3. CSS selectors for web scraping
  4. XPath for web scraping
  5. CSS selectors or XPath?
  6. Scrape links and URLs from a page
  7. Scrape text and article content
  8. Scrape HTML tables
  9. Data hidden in the HTML: JSON-LD and embedded JSON
  10. Should you use regex for web scraping?
  11. Common HTML scraping problems
  12. Fetching HTML at volume: proxies
  13. HTML Scraping With CSS Selectors and XPath FAQ

How HTML scraping works

  1. Fetch

    The page with an HTTP client (Requests, httpx, fetch, curl) or a browser if the page needs JavaScript.

  2. Parse

    The HTML into a DOM tree with a parser (Beautiful Soup, lxml, parsel, Cheerio, jsoup, rvest).

  3. Select

    The elements that hold your data with CSS selectors or XPath.

  4. Extract

    Text, attributes (href, src, data-*) or tables.

  5. Clean

    The values (trim, convert prices and dates, make URLs absolute) and store them.

The DOM (Document Object Model) is the tree a parser builds: the <html> element at the root, <head> and <body> below it, and every tag, attribute and text node as a branch. Selectors are paths through that tree. Parsers repair broken HTML the way browsers do, so missing closing tags rarely stop you.

Check first that the data is in the HTML. View the page source (Ctrl+U). If what you need is missing, the page renders it with JavaScript; look for its JSON request in the Network tab or render the page in a browser (how to scrape dynamic web pages).

A minimal HTML scraper

Python
# pip install requests beautifulsoup4 lxml
import requests
from bs4 import BeautifulSoup

html = requests.get("https://quotes.toscrape.com/", timeout=30).text
soup = BeautifulSoup(html, "lxml")
for q in soup.select("div.quote"):
    print(q.select_one("small.author").get_text(strip=True), "-", q.select_one("span.text").get_text(strip=True)[:50])

The same in Node.js with Cheerio (1.2.0, MIT), which gives you a jQuery-like API over parsed HTML:

JavaScript
// npm i cheerio   (Node.js 18+ has fetch built in)
import * as cheerio from 'cheerio';
const $ = cheerio.load(await (await fetch('https://quotes.toscrape.com/')).text());
$('div.quote').each((_, el) => console.log($(el).find('small.author').text(), '-', $(el).find('span.text').text().slice(0, 50)));

CSS selectors for web scraping

CSS selectors are the same patterns stylesheets use, and every parser supports them. They are short, readable and fast.

SelectorMatchesExample
tagElements by tagh1, table
.classElements with a class.price, div.product
#idThe element with an id#main
parent childDescendants at any depthdiv.quote span.text
parent > childDirect children onlyul.menu > li
[attr], [attr="v"]Attribute present or equala[href], input[name="q"]
[attr^="v"], [attr$="v"], [attr*="v"]Starts with, ends with, containsa[href^="https"], img[src$=".png"]
:nth-child(n), :first-childPosition among siblingstr:nth-child(2) td
A + B, A ~ BNext sibling, any later siblingh2 + p
:not(sel)Exclude matchesa:not(.ad)

To get an attribute in Beautiful Soup, select the element and read tag["href"]; parsel and Scrapy also accept a::attr(href) and span::text pseudo-elements. Test a selector in the browser console with document.querySelectorAll("div.quote span.text") before you put it in code.

Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.

XPath for web scraping

XPath is a query language for XML and HTML trees. It is longer than CSS, but it can do two things CSS cannot: match elements by their text, and move up to a parent or ancestor.

XPathMatches
//div[@class="quote"]div elements whose class is exactly quote
//div[contains(@class, "quote")]Class attribute contains quote
//a/@hrefThe href attributes of all links
//span[@class="text"]/text()The text nodes inside the span
//a[contains(text(), "Next")]Links whose text contains "Next"
//th[text()="Price"]/following-sibling::tdThe cell next to the "Price" header
//span[@class="price"]/ancestor::div[1]The nearest div above the price
(//table)[2]//trRows of the second table on the page
//a[normalize-space()="Next"]Exact text, ignoring extra spaces
Python
# pip install requests lxml
import requests
from lxml import html as lh
tree = lh.fromstring(requests.get("https://quotes.toscrape.com/", timeout=30).content)
print(tree.xpath('//small[@class="author"]/text()')[:3])
print(tree.xpath('//li[@class="next"]/a/@href'))

Case-insensitive text matching needs a trick in XPath 1.0, which most parsers implement: translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"). If an XPath with text() returns nothing although you see the text, the text is probably split across child elements; use contains(., "word") (the dot means all text inside the element) instead.

CSS selectors or XPath?

FeatureCSS selectorsXPath
ReadabilityShort and familiarLonger
Match by textNoYes (contains(text(), ...))
Move to parent or ancestorNoYes (.., ancestor::)
SupportEvery parser and browser toollxml, parsel, Scrapy, Selenium, Playwright; not Beautiful Soup
SpeedFastFast; the difference is rarely what slows a scraper

Use CSS by default and XPath for the cases CSS cannot express. parsel, the library behind Scrapy's selectors (1.12.1), lets you mix both on the same object, and lxml translates CSS to XPath through cssselect.

Scrape text and article content

For the text of one element, use get_text(" ", strip=True) in Beautiful Soup or html_text2() in rvest. For the main text of an article, without menus, ads and footers:

  1. Use the page's own structure

    Many sites wrap the article in <article> or <main>; select that and remove script, style, nav and aside inside it.

  2. Use an extraction library

    When you scrape many different sites. trafilatura (2.3.1, Apache-2.0) is a Python package and command-line tool that extracts the main text and metadata such as title, author and date from a page.

Python
# pip install trafilatura
import trafilatura
downloaded = trafilatura.fetch_url("https://example.com/some-article")
print(trafilatura.extract(downloaded, output_format="markdown", with_metadata=True))

Scrape HTML tables

For a real <table>, let a library do the work: pandas.read_html(io.StringIO(html)) returns every table as a DataFrame, and rvest's html_table() does the same in R (web scraping in R). Tables built from <div> grids need row and cell selectors. More on exporting tables to spreadsheets: web scraping in Excel.

Data hidden in the HTML: JSON-LD and embedded JSON

Before you write selectors, search the page source for application/ld+json and for large JSON blobs in <script> tags. Many product, article and event pages carry structured data in schema.org JSON-LD, and many JavaScript frameworks embed the page's data as JSON (for example a __NEXT_DATA__ script). Parsing that JSON is more stable than parsing the visible layout.

Python
import json
# soup: the parsed page from the first example
for s in soup.select('script[type="application/ld+json"]'):
    data = json.loads(s.string or "{}")
    print(data.get("@type"), data.get("name"))

Should you use regex for web scraping?

Not for parsing HTML: a regular expression cannot follow nested tags, attribute order or the broken markup that parsers repair, so it breaks on small layout changes. Regex is the right tool after the parser, on the text you extracted: prices (r"[\d,.]+"), dates, IDs in URLs, phone formats, or a value inside a <script> block that no selector reaches.

Python
import re
price_text = "Price: $1,299.00 (incl. VAT)"
price = float(re.search(r"[\d,]+\.\d{2}", price_text).group().replace(",", ""))

Common HTML scraping problems

  • The selector works in the browser but not in the script: the browser shows the DOM after JavaScript; your script sees the raw HTML. Compare with the page source.
  • Garbled characters: the page's encoding was guessed wrong. Pass the bytes (r.content) to the parser, or set r.encoding before reading r.text.
  • Fields shift between rows: select within each item card, not across the whole page, so a missing field becomes empty instead of misaligning rows.
  • Classes like css-1x2y3z change on every deploy: select by stable attributes (data-testid, itemprop, aria-label), by structure, or by nearby text with XPath.
  • Parsing is slow at volume: switch Beautiful Soup to the lxml parser, or use lxml or selectolax directly (Python libraries for web scraping).

Parsing and scraping are separate steps; the difference is explained in parsing vs scraping.

Fetching HTML at volume: proxies

Parsing is local, but fetching happens on the target's servers, and sites limit how many requests one IP may send. When a scraper fetches thousands of pages from one site, route the HTTP client through rotating proxies, so each request leaves from a new IP:

Python
import requests

url = "https://quotes.toscrape.com/"
proxy = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
html = requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=30).text

<login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. ProxyField residential proxies give each request a new IP or keep a sticky session from 30 s to 36 h, over HTTP, HTTPS or SOCKS5, with unlimited concurrent threads. Raw HTML is typically small, so a GB of traffic covers thousands of pages. They cost $2.95/GB for 1 GB and fall to $1.95/GB from 1,000 GB, with no subscription; traffic never expires, and all purchases are final. Setup for every Python client: Python proxy setup. Start with residential proxies and the Easy Trial: 1 GB for $2.95.

HTML Scraping With CSS Selectors and XPath FAQ

What is HTML web scraping?

ProxyField residential proxies, from $1.95/GB, fetch pages for any HTML scraper; HTML web scraping itself means downloading a page's HTML, parsing it into a DOM tree, and extracting data from it with CSS selectors or XPath. It works whenever the data is in the HTML the server sends, which is faster and lighter than rendering the page in a browser.

How do I parse HTML when web scraping?

Pass the HTML to a parser, such as Beautiful Soup or lxml in Python, Cheerio in Node.js, jsoup in Java or rvest in R, then query the tree it builds with CSS selectors or XPath. Parsers repair broken markup the way browsers do, so you rarely need to clean the HTML first.

How do I use CSS selectors for web scraping?

Inspect the element in your browser, note its tag, class or attributes, and write a selector such as div.product span.price. Test it in the console with document.querySelectorAll(), then use it with soup.select(), response.css() or $() in your scraper.

Which is better for web scraping, XPath or CSS selectors?

CSS selectors for most fields, because they are shorter and supported everywhere. XPath when you need to match an element by its text or move up to a parent, which CSS cannot do.

How do I use regex for web scraping?

Use it on text after the parser has extracted it: prices, dates, IDs or values inside a script block. Do not parse whole HTML pages with regex, because nested and changing markup breaks the patterns.

How do I scrape text from a website?

Select the element that holds the text and read it with get_text(" ", strip=True) in Beautiful Soup or html_text2() in rvest. For the main text of articles across many sites, an extraction library such as trafilatura removes menus, ads and footers for you.

What is DOM scraping?

Extracting data from the DOM, the tree of elements a parser or browser builds from HTML. A plain parser gives you the DOM of the HTML the server sent; a headless browser gives you the DOM after the page's JavaScript has run.

Why does my XPath text query return no results even though the text is visible?

Usually because the text is split across child elements or surrounded by whitespace, so text()="..." does not match. Use contains(., "word") or normalize-space(), and check that the text is in the raw HTML and not added by JavaScript.

Can CSS selectors do everything XPath can?

No. CSS selectors cannot match an element by its text or select a parent or ancestor, and XPath can do both. For everything else, such as tags, classes, attributes and positions, CSS selectors are enough.

Show 1 more questionShow fewer questions

How do I get the HTML of a web page for scraping?

Request the URL with an HTTP client, such as requests.get(url).text in Python or fetch(url) in Node.js, and check that the data you need is in the response. If it is missing, the page builds it with JavaScript, and you need its JSON request or a headless browser.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.