Recursive AI

CSS selectors in C#

Query HTML with CSS selectors in C#: QuerySelectorAll, :has() and other Level 4 selectors, tested in onehtml, AngleSharp and HtmlAgilityPack with Fizzler.

To use CSS selectors in C#, parse the HTML with a library that implements them and call QuerySelector for the first match or QuerySelectorAll for every match, as you would with document.querySelectorAll in a browser. The .NET base class library has no CSS selector engine, so the choice of library decides which selectors work. onehtml implements Selectors Level 4, including :has(), :is(), :where() and :not() with a list. AngleSharp has its own engine, and HtmlAgilityPack gets selectors from the separate Fizzler package. This page shows the same query in all three and a table of which selectors each one accepted when we ran them. Every sample was compiled and run on .NET 10 with onehtml 1.0.0, AngleSharp 1.8.6, HtmlAgilityPack 1.13.1 and Fizzler 1.3.1.

These selectors work on HTML you already have, such as a downloaded page, a file or a rendered template. Selenium’s By.CssSelector and Playwright’s locators take the same syntax but query a live browser, which is a different job.

The sample page

All the samples read this product list. Save it as page.html next to the project:

page.htmlHTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Spring sale</title></head>
<body>
<h2>Kitchen</h2>
<p class="intro">Small appliances, shipped free.</p>
<ul id="products">
  <li class="product" data-sku="K-100"><a href="/p/kettle">Kettle</a> <span class="price">$39.00</span></li>
  <li class="product sale" data-sku="T-200"><a href="/p/toaster">Toaster</a> <span class="price">$24.50</span> <span class="badge">-30%</span></li>
  <li class="product sold-out" data-sku="M-300"><a href="/p/mixer">Stand mixer</a> <span class="price">$189.00</span></li>
  <li class="product sale" data-sku="B-400"><a href="/p/blender">Blender</a> <span class="price">$59.00</span> <span class="badge">-20%</span></li>
  <li class="product" data-sku="G-500"><a href="https://partner.example/grinder">Coffee grinder</a> <span class="price">$45.00</span></li>
</ul>
<h3>Manuals</h3>
<p><a href="/files/kettle-manual.PDF">Kettle manual</a> · <a href="/files/mixer-manual.pdf">Mixer manual</a></p>
<form>
  <label><input type="checkbox" name="instock" checked> In stock only</label>
  <label><input type="email" name="email" placeholder="[email protected]" required> Email</label>
  <button disabled>Notify me</button>
</form>
</body>
</html>

Query HTML with CSS selectors in onehtml

TerminalNuGet
dotnet new console -o Selectors
cd Selectors
dotnet add package RecursiveAI.OneHtml --version 1.0.0
Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Selectors;
 
Document doc = Document.Parse(File.ReadAllBytes("page.html"));
Node root = doc.Root;
 
// Every match, in document order
foreach (Node item in root.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a").TextContent!;
    string price = item.QuerySelector(".price").TextContent!;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {name,-15} {price}");
}
 
// Selectors Level 4: :has(), :not() with a list, the i flag, :nth-child(... of S)
Console.WriteLine($"With a badge:     {Skus(root.QuerySelectorAll("li:has(.badge)"))}");
Console.WriteLine($"Regular price:    {Skus(root.QuerySelectorAll("li:not(.sale, .sold-out)"))}");
Console.WriteLine($"Second sale item: {Skus(root.QuerySelectorAll("li:nth-child(2 of .sale)"))}");
Console.WriteLine($"PDF links:        {root.QuerySelectorAll("a[href$='.pdf' i]").Count}");
 
// First match, or the null node when nothing matches
Node soldOut = root.QuerySelector("li.sold-out a");
Node clearance = root.QuerySelector("li.clearance");
Console.WriteLine($"Sold out: {soldOut.TextContent}; clearance found: {!clearance.IsNull}");
 
// Matches and Closest start from an element you already have
Node badge = root.QuerySelector(".badge");
Console.WriteLine($"Badge in a sale item: {badge.Matches(".sale .badge")}, item: {badge.Closest("li").GetAttribute("data-sku")}");
 
// Form state comes from the attributes
Console.WriteLine($"Checked: {root.QuerySelectorAll("input:checked").Count}, required: {root.QuerySelectorAll("input:required").Count}, disabled: {root.QuerySelectorAll(":disabled").Count}");
 
// Compile a selector once when you run it against many documents
SelectorList external = SelectorList.Parse("a[href^='https:']");
Console.WriteLine($"External link: {root.QuerySelector(external).GetAttribute("href")}");
 
static string Skus(List<Node> items) => string.Join(", ", items.Select(i => i.GetAttribute("data-sku")));
OutputConsole
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
With a badge:     T-200, B-400
Regular price:    K-100, G-500
Second sale item: B-400
PDF links:        2
Sold out: Stand mixer; clearance found: False
Badge in a sale item: True, item: T-200
Checked: 1, required: 1, disabled: 1
External link: https://partner.example/grinder
  • QuerySelectorAll returns every match in document order as a List<Node>. QuerySelector returns the first match, or the null node when nothing matches. Node is a struct, so test IsNull rather than comparing with null.
  • Both work on any element, not only the document. item.QuerySelector(".price") searches inside that one list item.
  • Matches tests an element you already hold against a selector, and Closest walks up to the nearest ancestor that matches. Together they replace most hand-written parent loops.
  • Form pseudo-classes such as :checked, :required and :disabled are decided from the attributes, because no script runs. :hover and :focus match only when you pass an interaction state.
  • SelectorList.Parse compiles a selector once. Matching against a compiled list allocates nothing, which helps when the same selector runs over thousands of pages.
  • A selector that isn’t valid throws SelectorSyntaxException. SelectorList.TryParse checks one without throwing.

The selector engine is tested against the Web Platform Tests for selectors: 2,537 cases pass, and the 43 known failures are all in tentative proposals that aren’t part of Selectors Level 4 (selectors conformance). The CSS selectors recipe covers the remaining options, and the selector reference lists every rule and limit.

CSS selectors with HtmlAgilityPack and Fizzler

HtmlAgilityPack queries with XPath. For CSS selectors, its site lists Fizzler among its third-party libraries (html-agility-pack.net), which adds QuerySelector and QuerySelectorAll as extension methods on HtmlNode through the Fizzler.Systems.HtmlAgilityPack package. Fizzler describes itself as a “W3C Selectors (Level 3) parser” (Fizzler README).

TerminalNuGet
dotnet add package HtmlAgilityPack --version 1.13.1
dotnet add package Fizzler.Systems.HtmlAgilityPack --version 1.2.1
dotnet add package Fizzler --version 1.3.1
Program.csHtmlAgilityPack 1.13.1 · Fizzler 1.3.1
using Fizzler.Systems.HtmlAgilityPack;
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("page.html");
 
// Fizzler adds QuerySelector and QuerySelectorAll as extension methods on HtmlNode
foreach (HtmlNode item in doc.DocumentNode.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a").InnerText;
    string price = item.QuerySelector(".price").InnerText;
    Console.WriteLine($"{item.GetAttributeValue("data-sku", "")}  {name,-15} {price}");
}
 
Console.WriteLine($"Not on sale: {doc.DocumentNode.QuerySelectorAll("li:not(.sale)").Count()}");
 
// Selectors Level 4 syntax is rejected when the query runs
try
{
    doc.DocumentNode.QuerySelectorAll("li:has(.badge)").ToList();
}
catch (FormatException e)
{
    Console.WriteLine($"li:has(.badge) -> {e.Message}");
}
OutputConsole
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
Not on sale: 3
li:has(.badge) -> Unknown functional pseudo 'has'. Only nth-child and nth-last-child are supported.

Two things to know before you choose this route:

  • Add Fizzler explicitly. Fizzler.Systems.HtmlAgilityPack 1.2.1, the latest release, was published on 8 April 2020 (NuGet). It depends on Fizzler 1.2.0 or later, and without the third line above NuGet restores 1.2.0. In our run, 1.2.0 rejected li:not(.sale) with “Unknown functional pseudo ‘not’”, and Fizzler 1.3.1 (NuGet, 10 July 2024) accepted it.
  • Licenses. Fizzler 1.3.1 is licensed LGPL-3.0-or-later, according to its NuGet listing. HtmlAgilityPack itself is MIT (NuGet).

CSS selectors with AngleSharp

AngleSharp has selectors built in, on IDocument and IElement, with the DOM method names. Its feature list cites W3C CSS3 Selectors (AngleSharp features).

Program.csAngleSharp 1.8.6
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
 
var parser = new HtmlParser();
using FileStream stream = File.OpenRead("page.html");
IDocument doc = parser.ParseDocument(stream);
 
foreach (IElement item in doc.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a")!.TextContent;
    string price = item.QuerySelector(".price")!.TextContent;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {name,-15} {price}");
}
 
var withBadge = doc.QuerySelectorAll("li:has(.badge)").Select(i => i.GetAttribute("data-sku"));
Console.WriteLine($"With a badge: {string.Join(", ", withBadge)}");
Console.WriteLine($"Badge item:   {doc.QuerySelector(".badge")!.Closest("li")!.GetAttribute("data-sku")}");
OutputConsole
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
With a badge: T-200, B-400
Badge item:   T-200

In AngleSharp, QuerySelector returns null when nothing matches, hence the ! operators above.

Which selectors each library accepts

We ran 16 selectors against the sample page with each library and compared the number of matches with what Chrome 149’s document.querySelectorAll returns for the same page. Each number is the count of matching elements. “FormatException” means the library threw instead of matching. The table shows only what we ran, not everything each library supports.

SelectorLevelChrome 149onehtml 1.0.0AngleSharp 1.8.6HtmlAgilityPack + Fizzler 1.3.1
#products > li25555
li.sale a12222
a[href^="https:"]31111
li:not(.sale)33333
li:nth-child(odd)3333FormatException
input:checked3111FormatException
button:disabled3111FormatException
li:not(.sale, .sold-out)4222FormatException
:is(h2, h3) + p4222FormatException
:where(h2, h3)4222FormatException
li:has(.badge)4222FormatException
li:has(> a[href^="https:"])4111FormatException
a[href$=".pdf" i]4222FormatException
li:nth-child(2 of .sale)4111FormatException
input:required4111FormatException
input:placeholder-shown4111FormatException

When Fizzler meets a pseudo-class it doesn’t know, its error message lists :first-child, :last-child, :only-child and :empty, and for functional pseudo-classes :nth-child() and :nth-last-child(). Version 1.3.1 also accepts :not() with a single selector, as the table shows. Inside :nth-child() it accepted a plain number such as 2 but rejected both odd and 2n+1. The Level column gives the first Selectors level that lists each feature.

Select elements by text

CSS has no selector for an element’s text. jQuery’s :contains() isn’t in Selectors Level 3 or Level 4: Chrome throws a SyntaxError for it, and onehtml throws SelectorSyntaxException. (AngleSharp 1.8.6 accepted it in our run.) Select by structure, then filter with LINQ on TextContent:

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("page.html"));
 
// CSS has no text selector: select by structure, then filter on TextContent
List<Node> manuals = doc.Root.QuerySelectorAll("a[href]")
    .Where(a => a.TextContent!.Contains("manual", StringComparison.OrdinalIgnoreCase))
    .ToList();
 
foreach (Node link in manuals)
{
    Console.WriteLine($"{link.TextContent} -> {link.GetAttribute("href")}");
}
 
// The same idea finds the list item whose name matches, then reads a sibling
Node mixer = doc.Root.QuerySelectorAll("#products a")
    .First(a => a.TextContent == "Stand mixer")
    .Closest("li");
Console.WriteLine($"Stand mixer: {mixer.QuerySelector(".price").TextContent}, sold out: {mixer.Matches(".sold-out")}");
OutputConsole
Kettle manual -> /files/kettle-manual.PDF
Mixer manual -> /files/mixer-manual.pdf
Stand mixer: $189.00, sold out: True

TextContent includes the text of every descendant, so filter on the most specific element you can select. Scripts and styles count as text too; the visible text article shows how to leave them out.

CSS selector or XPath?

Use CSS selectors for anything you can describe by tag, class, id, attribute or position. They are shorter, they are the query language of the web platform, and the same string works in the browser’s DevTools console, so you can try a selector on the live page before you put it in code. Use XPath when you need to select by text or move up the tree inside the query itself, and your library supports it: HtmlAgilityPack has XPath built in, and AngleSharp adds it through the AngleSharp.XPath package. onehtml has no XPath API, so in onehtml those cases are a selector followed by C#, as in the sample above.

Each row of this table returned the same elements on the sample page, the XPath through HtmlAgilityPack 1.13.1’s SelectNodes and the CSS through onehtml:

XPathCSS or C#Matches
//ul[@id='products']/li#products > li5
//li[contains(concat(' ', normalize-space(@class), ' '), ' sale ')]li.sale2
//a[starts-with(@href, 'https:')]a[href^='https:']1
//li[span[@class='badge']]li:has(> span.badge)2
//h3/following-sibling::p[1]h3 + p1
//a[contains(., 'manual')]QuerySelectorAll("a").Where(a => a.TextContent!.Contains("manual"))2

The class row is where CSS saves the most: .sale matches the whole word in a space-separated list, which XPath 1.0 needs the concat idiom to do safely. The HtmlAgilityPack migration guide translates more XPath expressions.

Which to use

  • You want modern selectors such as :has(), :is() or :not() with a list: onehtml or AngleSharp. Both matched Chrome on all 16 selectors above. onehtml publishes its Web Platform Tests results and every known failure.
  • You already use HtmlAgilityPack and your selectors are simple (tags, classes, ids, attributes, child and descendant combinators): Fizzler covers them. Add Fizzler 1.3.1 explicitly and check its LGPL license against your project.
  • You need .NET Framework: onehtml targets .NET 10 only, so use AngleSharp or HtmlAgilityPack. AngleSharp 1.8.6 ships .NET Framework 4.6.2 and 4.7.2 builds (NuGet).

Where next