Recursive AI

Migrate from HtmlAgilityPack to onehtml

Move C# code from HtmlAgilityPack to onehtml: LoadHtml, SelectNodes, InnerText, OuterHtml and Save mapped, with both sides run. No XPath; .NET 10 only.

Migrating from HtmlAgilityPack to onehtml replaces HtmlDocument.LoadHtml with Document.Parse, XPath queries through SelectNodes with CSS selectors through QuerySelectorAll, InnerText plus HtmlEntity.DeEntitize with TextContent, and in-place changes followed by Save with a list of edits applied in one step. Two things don’t carry over. onehtml has no XPath, so every XPath expression becomes a CSS selector, LINQ or DOM navigation. onehtml runs on .NET 10 only, with no .NET Framework or .NET Standard build. If either is a blocker, stay on HtmlAgilityPack. Every pair below was run on both sides, HtmlAgilityPack 1.13.1 and onehtml 1.0.0 on .NET 10, against the same input.

Before you migrate

  • No XPath. There is no SelectNodes equivalent that takes XPath, and no XSLT. The XPath section shows how common expressions translate.
  • .NET 10 only. The package targets net10.0. HtmlAgilityPack 1.13.1 ships builds for .NET Framework 3.5 and later (NuGet).
  • No HtmlWeb. onehtml never touches the network or the file system. You read the bytes and pass them in.
  • Documents are immutable. You don’t change nodes in place. You collect edits and apply them, which returns a new document.
  • The tree is the browser’s. Rows get a tbody, and misnested tags are repaired as the HTML standard says, so some queries written against HtmlAgilityPack’s tree need adjusting (see HtmlAgilityPack vs AngleSharp for the measured differences).

HtmlAgilityPack to onehtml: the API map

HtmlAgilityPackonehtml
var doc = new HtmlDocument(); doc.LoadHtml(html);Document doc = Document.Parse(html);
doc.Load(path), DetectEncodingAndLoad(path)Document.Parse(File.ReadAllBytes(path)): the encoding is detected from the bytes
new HtmlWeb().Load(url)HttpClient, then Document.Parse(bytes, new ParseOptions { ContentType = … })
doc.DocumentNodedoc.Root (the document node); doc.DocumentElement is <html>
SelectNodes(xpath), null when nothing matchesQuerySelectorAll(css), an empty List<Node> when nothing matches
SelectSingleNode(xpath), null when nothing matchesQuerySelector(css), the null node when nothing matches: check IsNull
node.Descendants("a")node.QuerySelectorAll("a")
ChildNodes, ParentNode, NextSiblingChildNodes, Parent or ParentElement, NextSibling or NextElementSibling
node.Namenode.LocalName
GetAttributeValue("href", ""), Attributes["href"]?.ValueGetAttribute("href") ?? "": null when absent
HtmlEntity.DeEntitize(node.InnerText)node.TextContent: already decoded
InnerHtml, OuterHtmldoc.SerializeChildren(node), doc.SerializeNode(node)
SetAttributeValue(name, value)Edit.SetAttribute(node, name, value)
node.Remove()Edit.Remove(node)
AppendChild(HtmlNode.CreateNode(html))Edit.Append(node, html)
doc.Save(path)File.WriteAllBytes(path, result.Document!.ToLosslessBytes())

Install onehtml with dotnet add package RecursiveAI.OneHtml --version 1.0.0. The samples read this page, catalog.html:

catalog.htmlHTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Kitchen &amp; home</title>
</head>
<body>
  <nav class=breadcrumbs><a href="/">Home</a> &rsaquo; <a href="/kitchen/">Kitchen</a></nav>
  <div class="products">
    <div class="product" data-sku="K-100">
      <h2><a href="/p/kettle">Kettle</a></h2>
      <span class="price">$39.00</span>
    </div>
    <div class="product sale" data-sku="T-200">
      <h2><a href="/p/toaster">Toaster</a></h2>
      <span class="price">$24.50</span>
    </div>
    <div class="product" data-sku="M-300">
      <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
      <span class="price">$189.00</span>
    </div>
  </div>
  <h3>Specs</h3>
  <table class="specs"><tr><th>Power</th><td>2200 W</td></tr><tr><th>Capacity</th><td>1.7 l</td></tr></table>
  <a href="/kitchen/?page=2" class="pager">Next page</a>
  <script>track("catalog");</script>
</body>
</html>

Load a document and query it

BeforeHtmlAgilityPack 1.13.1
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
// Match the class token "product", not "products"
IEnumerable<HtmlNode> products = doc.DocumentNode.SelectNodes(
    "//div[contains(concat(' ', normalize-space(@class), ' '), ' product ')]") ?? Enumerable.Empty<HtmlNode>();
 
foreach (HtmlNode product in products)
{
    HtmlNode link = product.SelectSingleNode(".//h2/a")!;
    string name = HtmlEntity.DeEntitize(link.InnerText);
    string price = product.SelectSingleNode(".//span[@class='price']")!.InnerText;
    Console.WriteLine($"{product.GetAttributeValue("data-sku", "")}  {name,-20} {price,8}  {link.GetAttributeValue("href", "")}");
}
 
// SelectNodes returns null, not an empty collection, when nothing matches
HtmlNodeCollection? none = doc.DocumentNode.SelectNodes("//div[@class='discontinued']");
Console.WriteLine($"No match: {(none is null ? "null" : none.Count.ToString())}");
OutputHtmlAgilityPack
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  https://partner.example/mixer
No match: null
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
// A class selector matches the class token "product", not "products"
foreach (Node product in doc.Root.QuerySelectorAll("div.product"))
{
    Node link = product.QuerySelector("h2 > a");
    string name = link.TextContent!;  // character references are already decoded
    string price = product.QuerySelector("span.price").TextContent!;
    Console.WriteLine($"{product.GetAttribute("data-sku") ?? ""}  {name,-20} {price,8}  {link.GetAttribute("href") ?? ""}");
}
 
// QuerySelectorAll returns an empty list when nothing matches
List<Node> none = doc.Root.QuerySelectorAll("div.discontinued");
Console.WriteLine($"No match: {none.Count}");
Outputonehtml
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  https://partner.example/mixer
No match: 0
  • Class tests get shorter. div.product matches the class token, which in XPath takes the concat(' ', normalize-space(@class), ' ') idiom. contains(@class, 'product') would also match the products wrapper.
  • Null checks move. SelectNodes returns null for no match (docs), so HtmlAgilityPack code is full of ??. QuerySelectorAll returns an empty list. QuerySelector returns a null node rather than null, because Node is a struct: test IsNull.
  • DeEntitize goes away. The parser decodes character references, so TextContent gives “Stand mixer & bowl”.

XPath without a CSS equivalent

Most XPath maps to a selector. Text tests and sibling axes don’t, and they become a line of LINQ or a DOM property:

BeforeHtmlAgilityPack 1.13.1
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
HtmlNode root = doc.DocumentNode;
 
// An element that contains a matching descendant
HtmlNode partner = root.SelectSingleNode(
    "//div[contains(concat(' ', normalize-space(@class), ' '), ' product ')][.//a[starts-with(@href, 'https://')]]")!;
Console.WriteLine($"Partner product: {partner.GetAttributeValue("data-sku", "")}");
 
// Match on text
HtmlNode next = root.SelectSingleNode("//a[contains(text(), 'Next')]")!;
Console.WriteLine($"Next page: {next.GetAttributeValue("href", "")}");
 
// Axes: the cell after a header cell, and the table after a heading
HtmlNode power = root.SelectSingleNode("//th[text()='Power']/following-sibling::td[1]")!;
HtmlNode specs = root.SelectSingleNode("//h3[text()='Specs']/following-sibling::table[1]")!;
Console.WriteLine($"Power: {power.InnerText}, spec rows: {specs.SelectNodes(".//tr")!.Count}");
 
// Up the tree
HtmlNode card = root.SelectSingleNode("//a[@href='/p/toaster']/ancestor::div[1]")!;
Console.WriteLine($"Toaster card: {card.GetAttributeValue("class", "")}");
 
// Rows written directly inside <table>
Console.WriteLine($"table/tr: {root.SelectNodes("//table[@class='specs']/tr")?.Count ?? 0}");
OutputHtmlAgilityPack
Partner product: M-300
Next page: /kitchen/?page=2
Power: 2200 W, spec rows: 2
Toaster card: product sale
table/tr: 2
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
Node root = doc.Root;
 
// An element that contains a matching descendant: :has()
Node partner = root.QuerySelector("div.product:has(a[href^='https://'])");
Console.WriteLine($"Partner product: {partner.GetAttribute("data-sku")}");
 
// Match on text: CSS has no text selector, so filter with LINQ
Node next = root.QuerySelectorAll("a").FirstOrDefault(a => a.TextContent!.Contains("Next"));
Console.WriteLine($"Next page: {next.GetAttribute("href")}");
 
// Axes become DOM navigation
Node power = root.QuerySelectorAll("th").First(th => th.TextContent == "Power").NextElementSibling;
Node specs = root.QuerySelectorAll("h3").First(h => h.TextContent == "Specs").NextElementSibling;
Console.WriteLine($"Power: {power.TextContent}, spec rows: {specs.QuerySelectorAll("tr").Count}");
 
// Up the tree: Closest
Node card = root.QuerySelector("a[href='/p/toaster']").Closest("div");
Console.WriteLine($"Toaster card: {card.GetAttribute("class")}");
 
// Rows written directly inside <table>: the parser adds the <tbody> a browser adds
Console.WriteLine($"table > tr: {root.QuerySelectorAll("table.specs > tr").Count}, table > tbody > tr: {root.QuerySelectorAll("table.specs > tbody > tr").Count}");
Outputonehtml
Partner product: M-300
Next page: /kitchen/?page=2
Power: 2200 W, spec rows: 2
Toaster card: product sale
table > tr: 0, table > tbody > tr: 2
XPathonehtml
//div[.//a[starts-with(@href, 'https://')]]QuerySelector("div:has(a[href^='https://'])")
//a[contains(text(), 'Next')]QuerySelectorAll("a").FirstOrDefault(a => a.TextContent!.Contains("Next"))
following-sibling::td[1]NextElementSibling, or the selector th + td
ancestor::div[1]Closest("div")
..ParentElement
//li[2]li:nth-of-type(2)
//table/trtable > tbody > tr, or table tr

FirstOrDefault on a list of nodes returns the null node when nothing matches, so the IsNull check works there too. The last line of each output is the one to watch. HtmlAgilityPack keeps rows directly under table, while onehtml adds the tbody a browser adds, so table > tr matches nothing. The CSS selectors recipe covers :has(), :is() and :not().

InnerText becomes TextContent

TextContent is decoded, but it follows the DOM standard, so it includes the text of script and style elements, which HtmlAgilityPack’s InnerText leaves out of the body. If you used InnerText to get readable text, use the text walk in extract visible text from HTML in C#. That article runs both libraries on the same page and can also drop text hidden by CSS.

InnerHtml and OuterHtml

BeforeHtmlAgilityPack 1.13.1
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
HtmlNode heading = doc.DocumentNode.SelectSingleNode("//a[starts-with(@href, 'https://')]/..")!;
Console.WriteLine(heading.OuterHtml);
Console.WriteLine(heading.InnerHtml);
OutputHtmlAgilityPack
<h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
<a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a>
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
Node heading = doc.Root.QuerySelector("a[href^='https://']").ParentElement;
Console.WriteLine(doc.SerializeNode(heading));      // outerHTML, as a browser serializes it
Console.WriteLine(doc.SerializeChildren(heading));  // innerHTML
 
// The original markup, character for character, from the source map
Console.WriteLine(doc.Source.Text[heading.Source.Range.Start..heading.Source.EndTag.End]);
Outputonehtml
<h2><a href="https://partner.example/mixer">Stand mixer &amp; bowl</a></h2>
<a href="https://partner.example/mixer">Stand mixer &amp; bowl</a>
<h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>

HtmlAgilityPack returns the markup as it was written, single quotes included. SerializeNode and SerializeChildren write the tree the way a browser serializes outerHTML and innerHTML. When you need the original text, slice it from the source with the node’s source range, as the last line does.

Edit and save

The same three changes on both sides: add rel="nofollow" to external links, remove scripts, and append a note to the product list.

BeforeHtmlAgilityPack 1.13.1
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
foreach (HtmlNode link in doc.DocumentNode.SelectNodes("//a[starts-with(@href, 'https://')]") ?? Enumerable.Empty<HtmlNode>())
{
    link.SetAttributeValue("rel", "nofollow");
}
foreach (HtmlNode script in doc.DocumentNode.SelectNodes("//script") ?? Enumerable.Empty<HtmlNode>())
{
    script.Remove();
}
HtmlNode list = doc.DocumentNode.SelectSingleNode("//div[@class='products']")!;
list.AppendChild(HtmlNode.CreateNode("<p class=\"note\">Prices include VAT.</p>"));
 
doc.Save("catalog.out.html");
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Editing;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
var edits = new List<Edit>();
foreach (Node link in doc.Root.QuerySelectorAll("a[href^='https://']"))
{
    edits.Add(Edit.SetAttribute(link, "rel", "nofollow"));
}
foreach (Node script in doc.Root.QuerySelectorAll("script"))
{
    edits.Add(Edit.Remove(script));
}
edits.Add(Edit.Append(doc.Root.QuerySelector("div.products"), "<p class=\"note\">Prices include VAT.</p>"));
 
// All edits apply or none do; the result is re-parsed and verified, and doc is unchanged
EditResult result = doc.Apply(edits);
if (!result.Succeeded)
{
    throw new InvalidOperationException(result.Failure);
}
File.WriteAllBytes("catalog.out.html", result.Document!.ToLosslessBytes());

Here is what each program changed, from diff catalog.html catalog.out.html:

diffHtmlAgilityPack 1.13.1
1c1
< <!DOCTYPE html>
---
> <!DOCTYPE html>
19c19
<       <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
---
>       <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer &amp; bowl</a></h2>
22c22
<   </div>
---
>   <p class="note">Prices include VAT.</p></div>
26c26
<   <script>track("catalog");</script>
---
>   
diffonehtml 1.0.0
19c19
<       <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
---
>       <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer &amp; bowl</a></h2>
21a22
>     <p class="note">Prices include VAT.</p>
26c27
<   <script>track("catalog");</script>
---
>   
  • Both leave untouched markup alone. The single-quoted href, the unquoted class=breadcrumbs and the &rsaquo; reference are unchanged in both files.
  • HtmlAgilityPack’s Save(path) added a byte order mark. Line 1 differs only by the three bytes EF BB BF that it wrote at the start of the file. onehtml writes back exactly the bytes it read, apart from the edits.
  • The appended element goes on its own line, indented like its siblings, in onehtml. HtmlAgilityPack puts it straight before </div>.
  • Edits are atomic and checked. Apply applies every edit or none. The result is re-parsed and compared with the expected tree before it is returned, and the original doc stays valid. Editing explains what happens when a splice would change how the page parses.

Loading from the web

BeforeHtmlAgilityPack 1.13.1
using HtmlAgilityPack;
 
var web = new HtmlWeb();
HtmlDocument doc = web.Load("https://example.com/");
 
Console.WriteLine(doc.DocumentNode.SelectSingleNode("//title")!.InnerText);
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
 
// onehtml never touches the network: fetch the bytes yourself and pass the Content-Type
using var http = new HttpClient();
using HttpResponseMessage response = await http.GetAsync("https://example.com/");
response.EnsureSuccessStatusCode();
 
Document doc = Document.Parse(await response.Content.ReadAsByteArrayAsync(),
    new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() });
 
Console.WriteLine(doc.Root.QuerySelector("title").TextContent);

Both print “Example Domain”. Fetching yourself lets you set timeouts, headers, retries and caching on your own HttpClient, and passing ContentType lets a charset in the response header take priority, as in a browser.

Legacy encodings

menu.html is a windows-1252 page that declares its encoding with <meta charset="windows-1252">. The parse HTML in C# article has the lines that create it.

BeforeHtmlAgilityPack 1.13.1
using System.Text;
using HtmlAgilityPack;
 
// .NET only ships UTF-8, UTF-16 and a few others; register the legacy code pages first
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
 
var doc = new HtmlDocument();
doc.Load("menu.html");
Console.WriteLine($"Load:                  {doc.Encoding?.WebName}: {doc.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
 
var detected = new HtmlDocument();
detected.DetectEncodingAndLoad("menu.html");
Console.WriteLine($"DetectEncodingAndLoad: {detected.Encoding?.WebName}: {detected.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
 
var given = new HtmlDocument();
given.Load("menu.html", Encoding.GetEncoding(1252));
Console.WriteLine($"Load with encoding:    {given.Encoding?.WebName}: {given.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
OutputHtmlAgilityPack
Load:                  windows-1252: Caf� cr�me � �3.20
DetectEncodingAndLoad: windows-1252: Caf� cr�me � �3.20
Load with encoding:    windows-1252: Café crème – €3.20
Afteronehtml 1.0.0
using RecursiveAI.OneHtml;
 
// The WHATWG encodings are built in: no provider to register
Document doc = Document.Parse(File.ReadAllBytes("menu.html"));
Console.WriteLine($"{doc.Source.Encoding.Name}: {doc.Root.QuerySelector("h1").TextContent}");
Outputonehtml
windows-1252: Café crème – €3.20

In this run, HtmlAgilityPack’s Load and DetectEncodingAndLoad reported windows-1252 but returned garbled text, and only passing the encoding to Load decoded the page correctly. onehtml found the encoding from the bytes, without a code-page provider. If you pass explicit encodings to HtmlAgilityPack today for pages that declare their encoding, you can drop them when you move.

Where next