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
SelectNodesequivalent 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
| HtmlAgilityPack | onehtml |
|---|---|
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.DocumentNode | doc.Root (the document node); doc.DocumentElement is <html> |
SelectNodes(xpath), null when nothing matches | QuerySelectorAll(css), an empty List<Node> when nothing matches |
SelectSingleNode(xpath), null when nothing matches | QuerySelector(css), the null node when nothing matches: check IsNull |
node.Descendants("a") | node.QuerySelectorAll("a") |
ChildNodes, ParentNode, NextSibling | ChildNodes, Parent or ParentElement, NextSibling or NextElementSibling |
node.Name | node.LocalName |
GetAttributeValue("href", ""), Attributes["href"]?.Value | GetAttribute("href") ?? "": null when absent |
HtmlEntity.DeEntitize(node.InnerText) | node.TextContent: already decoded |
InnerHtml, OuterHtml | doc.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:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Kitchen & home</title> </head> <body> <nav class=breadcrumbs><a href="/">Home</a> › <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 & 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
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())}");
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
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}");
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.productmatches the class token, which in XPath takes theconcat(' ', normalize-space(@class), ' ')idiom.contains(@class, 'product')would also match theproductswrapper. - Null checks move.
SelectNodesreturnsnullfor no match (docs), so HtmlAgilityPack code is full of??.QuerySelectorAllreturns an empty list.QuerySelectorreturns a null node rather thannull, becauseNodeis a struct: testIsNull. DeEntitizegoes away. The parser decodes character references, soTextContentgives “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:
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}");
Partner product: M-300 Next page: /kitchen/?page=2 Power: 2200 W, spec rows: 2 Toaster card: product sale table/tr: 2
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}");
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
| XPath | onehtml |
|---|---|
//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/tr | table > 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
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);
<h2><a href='https://partner.example/mixer'>Stand mixer & bowl</a></h2> <a href='https://partner.example/mixer'>Stand mixer & bowl</a>
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]);
<h2><a href="https://partner.example/mixer">Stand mixer & bowl</a></h2> <a href="https://partner.example/mixer">Stand mixer & bowl</a> <h2><a href='https://partner.example/mixer'>Stand mixer & 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.
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");
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:
1c1 < <!DOCTYPE html> --- > <!DOCTYPE html> 19c19 < <h2><a href='https://partner.example/mixer'>Stand mixer & bowl</a></h2> --- > <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer & bowl</a></h2> 22c22 < </div> --- > <p class="note">Prices include VAT.</p></div> 26c26 < <script>track("catalog");</script> --- >
19c19 < <h2><a href='https://partner.example/mixer'>Stand mixer & bowl</a></h2> --- > <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer & 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 unquotedclass=breadcrumbsand the›reference are unchanged in both files. - HtmlAgilityPack’s
Save(path)added a byte order mark. Line 1 differs only by the three bytesEF BB BFthat 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.
Applyapplies every edit or none. The result is re-parsed and compared with the expected tree before it is returned, and the originaldocstays valid. Editing explains what happens when a splice would change how the page parses.
Loading from the web
using HtmlAgilityPack; var web = new HtmlWeb(); HtmlDocument doc = web.Load("https://example.com/"); Console.WriteLine(doc.DocumentNode.SelectSingleNode("//title")!.InnerText);
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.
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}");
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
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}");
windows-1252: Café crème – €3.20In 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
- Getting started with onehtml: parse, query, edit, compute styles and check accessibility.
- Edit HTML without reformatting it and save and replay edits.
- Remove scripts and event handlers, a common HtmlAgilityPack job.
- HtmlAgilityPack vs AngleSharp, if you are still choosing.