Recursive AI

Query HTML with CSS selectors

All onehtml recipes

Find elements with QuerySelector, QuerySelectorAll, Matches and Closest using Selectors Level 4, including :has(), :is(), :not() with selector lists, and form-state pseudo-classes.

The query members are on Node, so you can query the whole document (doc.Root) or any element. They follow the DOM standard: QuerySelector returns the first match in document order, and QuerySelectorAll returns every match. When nothing matches, QuerySelector returns the null node. Check IsNull rather than comparing to null, because Node is a struct.

C#
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Selectors;
 
Document doc = Document.Parse(File.ReadAllBytes("events.html"));
Node root = doc.Root;
 
// First match, or the null node
Node current = root.QuerySelector("nav a[aria-current=page]");
Console.WriteLine($"Current page: {current.TextContent}");
 
Node none = root.QuerySelector("aside");
Console.WriteLine($"Has an aside: {!none.IsNull}");
 
// All matches, in document order; Selectors Level 4 is supported
foreach (Node card in root.QuerySelectorAll("article:has(img:not([alt]))"))
{
    Console.WriteLine($"Card without alt text: {card.GetAttribute("data-event-id")}");
}
 
foreach (Node link in root.QuerySelectorAll(":is(header, footer) a[href]"))
{
    Console.WriteLine($"{link.LocalName} -> {link.GetAttribute("href")}");
}
 
// Matches and Closest test an element you already have
Node details = root.QuerySelector("a[href$='/story-time/']");
Console.WriteLine($"Inside a card: {details.Matches(".card a")}, card: {details.Closest(".card").GetAttribute("data-event-id")}");
 
// Form state pseudo-classes are decided from attributes
Console.WriteLine($"Fields showing a placeholder: {root.QuerySelectorAll("input:placeholder-shown").Count}");
 
// Compile a selector once when you run it many times
SelectorList external = SelectorList.Parse("a[href^='http']:not([href*='harborstreet'])");
foreach (Node link in root.QuerySelectorAll(external))
{
    Console.WriteLine($"External: {link.GetAttribute("href")}");
}
 
// A bad selector throws SelectorSyntaxException
try
{
    root.QuerySelector("nav >");
}
catch (SelectorSyntaxException e)
{
    Console.WriteLine($"Rejected: {e.Message}");
}
Text
Current page: Events
Has an aside: False
Card without alt text: 114
a -> /
a -> /books/
a -> /events/
a -> /visit/
a -> mailto:[email protected]
Inside a card: True, card: 114
Fields showing a placeholder: 1
External: https://example.org/repair-cafe
Rejected: Not a valid selector list.

What the matcher does, and doesn’t, decide:

  • Type selectors and attribute names match HTML elements case-insensitively, and the HTML list of case-insensitive attribute values (type, lang and others) is honored. Add i for anything else, as in [href^='javascript:' i].
  • :checked, :disabled, :required, :placeholder-shown, :in-range and the other form pseudo-classes are decided from attributes. :valid and :invalid are decided where that is possible without script: required, email, url, pattern, min, max and step.
  • No script runs, so :defined matches every element that isn’t a custom element. :visited, :autofill, :popover-open and :modal never match.
  • :hover, :focus, :focus-visible, :active and :target match only when you give an interaction state in SelectorMatchOptions. Otherwise they match nothing. The cascade takes the same state through StyleContext (see Compute CSS styles).
  • Pseudo-elements parse but never match an element.
  • An unclosed [ or ( at the end of the input is closed for you, as CSS Syntax says, so a[href is valid. A selector that is really invalid throws SelectorSyntaxException.

SelectorList.Parse compiles a selector once. Matching against a compiled list allocates nothing, and QuerySelectorAll allocates only the result list.

There is no XPath API. The full rules, limits and the web-platform-tests results are in Selectors.

Sixteen selectors measured against AngleSharp, Fizzler and Chrome: CSS selectors in C#.