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:
<!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
dotnet new console -o Selectors cd Selectors dotnet add package RecursiveAI.OneHtml --version 1.0.0
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")));
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
QuerySelectorAllreturns every match in document order as aList<Node>.QuerySelectorreturns the first match, or the null node when nothing matches.Nodeis a struct, so testIsNullrather than comparing withnull.- Both work on any element, not only the document.
item.QuerySelector(".price")searches inside that one list item. Matchestests an element you already hold against a selector, andClosestwalks up to the nearest ancestor that matches. Together they replace most hand-written parent loops.- Form pseudo-classes such as
:checked,:requiredand:disabledare decided from the attributes, because no script runs.:hoverand:focusmatch only when you pass an interaction state. SelectorList.Parsecompiles 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.TryParsechecks 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).
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
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}"); }
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
Fizzlerexplicitly.Fizzler.Systems.HtmlAgilityPack1.2.1, the latest release, was published on 8 April 2020 (NuGet). It depends onFizzler1.2.0 or later, and without the third line above NuGet restores 1.2.0. In our run, 1.2.0 rejectedli:not(.sale)with “Unknown functional pseudo ‘not’”, andFizzler1.3.1 (NuGet, 10 July 2024) accepted it. - Licenses.
Fizzler1.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).
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")}");
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.
| Selector | Level | Chrome 149 | onehtml 1.0.0 | AngleSharp 1.8.6 | HtmlAgilityPack + Fizzler 1.3.1 |
|---|---|---|---|---|---|
#products > li | 2 | 5 | 5 | 5 | 5 |
li.sale a | 1 | 2 | 2 | 2 | 2 |
a[href^="https:"] | 3 | 1 | 1 | 1 | 1 |
li:not(.sale) | 3 | 3 | 3 | 3 | 3 |
li:nth-child(odd) | 3 | 3 | 3 | 3 | FormatException |
input:checked | 3 | 1 | 1 | 1 | FormatException |
button:disabled | 3 | 1 | 1 | 1 | FormatException |
li:not(.sale, .sold-out) | 4 | 2 | 2 | 2 | FormatException |
:is(h2, h3) + p | 4 | 2 | 2 | 2 | FormatException |
:where(h2, h3) | 4 | 2 | 2 | 2 | FormatException |
li:has(.badge) | 4 | 2 | 2 | 2 | FormatException |
li:has(> a[href^="https:"]) | 4 | 1 | 1 | 1 | FormatException |
a[href$=".pdf" i] | 4 | 2 | 2 | 2 | FormatException |
li:nth-child(2 of .sale) | 4 | 1 | 1 | 1 | FormatException |
input:required | 4 | 1 | 1 | 1 | FormatException |
input:placeholder-shown | 4 | 1 | 1 | 1 | FormatException |
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:
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")}");
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:
| XPath | CSS or C# | Matches |
|---|---|---|
//ul[@id='products']/li | #products > li | 5 |
//li[contains(concat(' ', normalize-space(@class), ' '), ' sale ')] | li.sale | 2 |
//a[starts-with(@href, 'https:')] | a[href^='https:'] | 1 |
//li[span[@class='badge']] | li:has(> span.badge) | 2 |
//h3/following-sibling::p[1] | h3 + p | 1 |
//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
Fizzler1.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
- Parse HTML in C#: strings, files, URLs, encodings and fragments.
- Web scraping in C#: the same selectors on pages fetched with
HttpClient. - HtmlAgilityPack vs AngleSharp: how the two compare beyond selectors.
- Getting started with onehtml.