Recursive AI

Parse HTML in C#

Parse HTML in C# the way a browser does: load a string, file or URL, query it with CSS selectors, and handle broken markup and legacy encodings.

To parse HTML in C#, add the RecursiveAI.OneHtml package, pass a string or the page’s bytes to Document.Parse, and query the result with CSS selectors through QuerySelector and QuerySelectorAll. onehtml is an HTML parser for .NET that follows the WHATWG HTML standard, so broken markup turns into the same tree a browser builds, and when you give it bytes it picks the encoding the way a browser does. It is pure C# with no dependency outside .NET, licensed MIT OR Apache-2.0. This page covers strings, files, URLs, malformed markup and fragments, then compares it with HtmlAgilityPack and AngleSharp. Every sample was compiled and run against onehtml 1.0.0 on .NET 10.

The .NET base class library has no HTML parser of its own. System.Xml needs well-formed XML, which most real HTML isn’t, so a parser library is the practical route.

Install

TerminalNuGet
dotnet new console -o HtmlParsing
cd HtmlParsing
dotnet add package RecursiveAI.OneHtml --version 1.0.0

onehtml targets .NET 10 (net10.0), works with NativeAOT and trimming, and never reads files or the network itself: you hand it a string or bytes.

Parse an HTML string in C#

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
string html = """
    <ul id="products">
      <li class="product" data-sku="K-100"><a href="/p/kettle">Kettle</a> <span class="price">$39.00</span>
      <li class="product sale" data-sku="T-200"><a href="/p/toaster">Toaster</a> <span class="price">$24.50</span>
      <li class="product" data-sku="M-300"><a href="/p/mixer">Stand mixer &amp; bowl</a> <span class="price">$189.00</span>
    </ul>
    """;
 
Document doc = Document.Parse(html);
 
foreach (Node item in doc.Root.QuerySelectorAll("li.product"))
{
    Node link = item.QuerySelector("a");
    string price = item.QuerySelector(".price").TextContent!;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {link.TextContent,-20} {price,8}  {link.GetAttribute("href")}");
}
 
Node sale = doc.Root.QuerySelector("li.sale a");
Console.WriteLine($"On sale: {sale.TextContent}");
 
Node missing = doc.Root.QuerySelector("li.discontinued");
Console.WriteLine($"Discontinued found: {!missing.IsNull}");
OutputConsole
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  /p/mixer
On sale: Toaster
Discontinued found: False
  • Malformed markup doesn’t throw. The li elements above have no end tags, and the parser closes each one where the standard says to, as a browser would. Only the safety limits throw, for example HtmlLimitExceededException for elements nested more than 4,096 deep.
  • A Document is an immutable snapshot that any number of threads can read at once. Node is a small struct that points into it.
  • QuerySelector returns the first match in document order, and QuerySelectorAll returns every match as a List<Node>. They take Selectors Level 4, including :has(), :is(), :where() and :not().
  • When nothing matches, QuerySelector returns the null node. Check IsNull before reading from it: calling GetAttribute on the null node throws.
  • Character references are already decoded, so &amp; reads as &. GetAttribute returns null when the attribute is absent and "" when it is present but empty.

Load an HTML document from a file

Read the file as bytes and pass the bytes. The parser then finds the encoding the way a browser does: a byte order mark first, then the Content-Type you pass in ParseOptions, then a <meta charset> near the top of the file. This sample writes a small windows-1252 page, the kind older sites and CMS exports still produce, then loads it both ways:

Program.csonehtml 1.0.0 · .NET 10
using System.Text;
using RecursiveAI.OneHtml;
 
// Make a legacy page: saved as windows-1252 and declared with a <meta> tag
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
File.WriteAllBytes("menu.html", Encoding.GetEncoding(1252).GetBytes(
    "<!DOCTYPE html><meta charset=\"windows-1252\"><title>Menu</title><h1>Café crème – €3.20</h1>"));
 
// Pass the bytes: the parser picks the encoding the way a browser does
byte[] bytes = File.ReadAllBytes("menu.html");
Document doc = Document.Parse(bytes);
Console.WriteLine($"{doc.Source.Encoding.Name} ({doc.Source.Confidence}), {doc.Mode}");
Console.WriteLine(doc.Root.QuerySelector("h1").TextContent);
 
// Reading the file as a string first decodes it as UTF-8 before the parser sees the <meta>
Document wrong = Document.Parse(File.ReadAllText("menu.html"));
Console.WriteLine(wrong.Root.QuerySelector("h1").TextContent);
OutputConsole
windows-1252 (Certain), NoQuirks
Café crème – €3.20
Caf� cr�me � �3.20

From bytes, the parser reads the <meta> tag and decodes the text correctly. File.ReadAllText decodes the file as UTF-8 before the parser ever sees that tag, so every non-ASCII character is lost. Encoding.RegisterProvider is there only to write the sample file: onehtml decodes the WHATWG encodings itself. When a page declares nothing at all, the parser falls back to windows-1252, as the standard recommends. If you know your files are UTF-8, set ParseOptions.DefaultEncoding (see detect the encoding).

Document.Parse takes a string, bytes (ReadOnlyMemory<byte>, so a byte[] works) or a SourceText, not a Stream. To parse a stream, copy it into a MemoryStream first, as the parse HTML recipe shows.

Parse HTML from a URL

onehtml doesn’t download anything. Fetch the page with HttpClient, then pass the bytes and the response’s Content-Type header, so a charset in the header wins over the page’s <meta>, as it does in a browser:

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
using var http = new HttpClient();
using HttpResponseMessage response = await http.GetAsync("https://example.com/");
response.EnsureSuccessStatusCode();
byte[] body = await response.Content.ReadAsByteArrayAsync();
 
// The charset in the Content-Type header takes priority over a <meta> tag, as in a browser
var options = new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() };
Document doc = Document.Parse(body, options);
 
Console.WriteLine($"Content-Type: {options.ContentType}, decoded as {doc.Source.Encoding.Name}");
Console.WriteLine($"Title: {doc.Root.QuerySelector("title").TextContent}");
Console.WriteLine($"Language: {doc.DocumentElement.GetAttribute("lang")}");
 
// QuerySelector returns a null node when nothing matches: check IsNull before using it
Node link = doc.Root.QuerySelector("a[href]");
Console.WriteLine(link.IsNull ? "No links on this page" : link.GetAttribute("href"));
OutputConsole
Content-Type: text/html; charset=utf-8, decoded as UTF-8
Title: Example Domain
Language: en
No links on this page

Scripts are kept as inert text and never run. If the content you need is built by JavaScript, parse the DOM a real browser produced instead, for example the string from Playwright’s Page.ContentAsync(), which the Playwright accessibility testing article uses.

Malformed HTML: the tree a browser builds

Real pages leave out end tags, misnest formatting and put text in the wrong part of a table. The HTML standard defines exactly what tree each of these becomes, and onehtml implements that algorithm, including the tokenizer, every insertion mode, the adoption agency algorithm and foster parenting:

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
string[] inputs =
{
    "<p>One<p>Two<div>Three</div>",
    "<b>bold <i>both</b> italic</i>",
    "<table><tr><td>1</td></tr>Total<tr><td>2</td></tr></table>",
    "<ul><li>One<li>Two</ul><p>a < b & c",
};
 
foreach (string html in inputs)
{
    Document doc = Document.Parse(html);
    Console.WriteLine(doc.SerializeChildren(doc.Root.QuerySelector("body")));
}
OutputConsole
<p>One</p><p>Two</p><div>Three</div>
<b>bold <i>both</i></b><i> italic</i>
Total<table><tbody><tr><td>1</td></tr><tr><td>2</td></tr></tbody></table>
<ul><li>One</li><li>Two</li></ul><p>a &lt; b &amp; c</p>
  • An open p is closed by the next p or div.
  • Misnested <b><i>…</b></i> becomes two i elements, so “italic” stays italic.
  • Text inside a table but outside a cell is moved in front of the table, and rows get the tbody a browser adds. A selector written against the source, such as table > tr, matches nothing; use table > tbody > tr or table tr.
  • A stray < or & is text. SerializeChildren writes it back escaped.

The parser is tested against the html5lib tokenizer and encoding tests and the Web Platform Tests for tree construction, with every deliberate exception listed with its reason. On 18,330 crawled pages and stylesheets, parsing and writing back with no edits gives byte-identical output (conformance table). To record the parse errors a page has, set ParseOptions.RecordErrors.

Parse an HTML fragment

A fragment such as a table row or a list item means something different depending on where it goes. Document.ParseFragment parses it as the inner HTML of a context element, as element.innerHTML does in a browser:

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
// Parse markup as the inner HTML of a <tbody>, the way element.innerHTML does
var tbody = new FragmentContext(ElementNamespace.Html, "tbody");
Document rows = Document.ParseFragment("<tr><td>Kettle<td>$39.00<tr><td>Toaster<td>$24.50", tbody);
 
Console.WriteLine($"Rows: {rows.Root.QuerySelectorAll("tr").Count}");
Console.WriteLine(rows.SerializeChildren(rows.Root));
 
// The same markup inside a <div>: table parts outside a table are dropped
Document loose = Document.ParseFragment("<tr><td>Kettle<td>$39.00", new FragmentContext(ElementNamespace.Html, "div"));
Console.WriteLine(loose.SerializeChildren(loose.Root));
OutputConsole
Rows: 2
<tr><td>Kettle</td><td>$39.00</td></tr><tr><td>Toaster</td><td>$24.50</td></tr>
Kettle$39.00

FragmentContext.FromElement takes the context from an element of a parsed page. The HTML fragments recipe has more.

Choosing a .NET HTML parser

The other two common choices are HtmlAgilityPack and AngleSharp. Here is how the three compare on what each publisher states, for the versions on NuGet on 26 October 2026. The builds row lists the lib folders of each published package.

onehtml 1.0.0HtmlAgilityPack 1.13.1AngleSharp 1.8.6
LicenseMIT OR Apache-2.0 (NuGet)MIT (NuGet)MIT (NuGet)
Builds in the package.NET 10.NET Framework 3.5, 4.0 and 4.5, .NET 8, .NET Standard 2.0, UAP 10.0, portable profiles.NET 10 and 8, .NET Standard 2.0, .NET Framework 4.6.2 and 4.7.2
Parsing, as described by the publisher“a WHATWG-conformant parser”“very tolerant with ‘real world’ malformed HTML”“construct a DOM based on the official W3C specification”
QueriesCSS Selectors Level 4; no XPathXPath; CSS through the third-party Fizzler package (HAP site)CSS selectors; XPath through AngleSharp.XPath
Loading from the webNo; you pass the bytesHtmlWeb (docs)BrowsingContext with a loader (README)

The trees differ too. On the misnested and table inputs above, HtmlAgilityPack 1.13.1 keeps “italic” outside any i element and leaves “Total” inside the table, while AngleSharp 1.8.6 and onehtml build the browser’s tree. The HtmlAgilityPack vs AngleSharp comparison runs those inputs through all three and covers the rest of the differences.

On speed, the project’s published benchmark parses a 100-page sample of crawled pages from bytes in 125.0 ms per sweep with onehtml, 341.7 ms with AngleSharp 1.8.6 and 292.8 ms with HtmlAgilityPack 1.13.1. It ran on an AMD Ryzen 9 7950X3D with .NET 10, using BenchmarkDotNet in process.

What onehtml doesn’t do

  • No XPath. Queries are CSS selectors plus ordinary C#: LINQ over QuerySelectorAll, and DOM navigation such as Closest, ParentElement and NextElementSibling. The HtmlAgilityPack migration guide translates common XPath expressions.
  • No fetching and no script. You supply bytes, and scripts stay as text.
  • No layout or painting, so no screenshots and no HTML to PDF.
  • .NET 10 only. There is no .NET Framework or .NET Standard build.

Where next