Recursive AI

Web scraping in C#

Web scraping in C# with HttpClient and CSS selectors: fetch a page, extract data, follow pagination politely, and handle pages that JavaScript builds.

Web scraping in C# takes two parts: HttpClient to download the page, and an HTML parser to turn it into a tree you can query with CSS selectors. .NET ships the first and not the second. This tutorial uses onehtml as the parser: it parses pages the way a browser does, detects the character encoding from the bytes and the Content-Type header, and supports Selectors Level 4, so :has() and :not() do filtering that would otherwise take a loop. It covers one page, then pagination with polite request pacing, then pages that build their content with JavaScript. The samples scrape books.toscrape.com and quotes.toscrape.com, two sandboxes built for scraping practice, and were run on .NET 10 with onehtml 1.0.0 and Microsoft.Playwright 1.64.1.

Set up the project

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

onehtml targets .NET 10 and has no dependencies outside .NET. It never makes network requests itself, so all HTTP stays in your HttpClient code, where you control headers, timeouts, proxies and retries.

Scrape a page

Open the page in your browser’s DevTools first and find the elements that hold the data. On books.toscrape.com each book is an article.product_pod, with the title in the title attribute of the link inside its h3, and the price in .price_color.

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
 
using var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.ParseAdd("BookPriceBot/1.0 (+https://example.com/bot)");
 
var pageUrl = new Uri("https://books.toscrape.com/");
using HttpResponseMessage response = await http.GetAsync(pageUrl);
response.EnsureSuccessStatusCode();
 
// Pass the bytes and the Content-Type: the parser picks the encoding as a browser would
byte[] body = await response.Content.ReadAsByteArrayAsync();
Document doc = Document.Parse(body, new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() });
 
List<Node> books = doc.Root.QuerySelectorAll("article.product_pod");
Console.WriteLine($"{books.Count} books on {pageUrl}");
 
foreach (Node book in books.Take(5))
{
    Node link = book.QuerySelector("h3 > a");
    string title = link.GetAttribute("title")!;                          // the link text is truncated, the title isn't
    string price = book.QuerySelector(".price_color").TextContent!;
    string rating = book.QuerySelector("p.star-rating").GetAttribute("class")!.Split(' ')[1];
    string stock = book.QuerySelector(".availability").TextContent!.Trim();
    var url = new Uri(pageUrl, link.GetAttribute("href"));                // resolve the relative link
    Console.WriteLine($"{price,7}  {rating,-5}  {stock}  {title}");
    Console.WriteLine($"         {url}");
}
 
// :has() filters on what an element contains, so no loop is needed
int fiveStars = doc.Root.QuerySelectorAll("article.product_pod:has(p.star-rating.Five)").Count;
Console.WriteLine($"Five-star books on this page: {fiveStars}");
OutputConsole
20 books on https://books.toscrape.com/
 £51.77  Three  In stock  A Light in the Attic
         https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
 £53.74  One    In stock  Tipping the Velvet
         https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
 £50.10  One    In stock  Soumission
         https://books.toscrape.com/catalogue/soumission_998/index.html
 £47.82  Four   In stock  Sharp Objects
         https://books.toscrape.com/catalogue/sharp-objects_997/index.html
 £54.23  Five   In stock  Sapiens: A Brief History of Humankind
         https://books.toscrape.com/catalogue/sapiens-a-brief-history-of-humankind_996/index.html
Five-star books on this page: 4
  • Pass bytes, not a string. ReadAsByteArrayAsync plus the Content-Type header lets the parser choose the encoding as a browser does: a byte order mark, then the header’s charset, then a <meta> declaration near the top of the page. This site sends text/html with no charset and declares UTF-8 in a <meta http-equiv> tag, which is how the “£” comes out right. The encoding recipe shows the rules.
  • Read attributes when the text is cut. The link text on this site is truncated (“A Light in the …”), and the title attribute holds the full title.
  • Resolve links against the page URL. new Uri(pageUrl, href) applies the same rules a browser does to relative links. If the page has a <base href>, resolve against that instead.
  • Selectors return a null node, not an exception, when nothing matches. Check IsNull on anything optional before you read it, because reading attributes from the null node throws.
  • Identify your scraper. A User-Agent with a contact URL lets a site owner reach you instead of blocking you.

The parser follows the WHATWG HTML standard, so a page with missing end tags or misnested elements becomes the same tree the browser showed you in DevTools, and the selectors you tested there select the same elements. The CSS selectors article covers the selector syntax in more depth.

Follow pagination

Most listings span several pages. Follow the site’s own “next” link until there isn’t one, wait between requests, and cap the number of pages so a mistake can’t turn into an endless crawl:

Program.csonehtml 1.0.0 · .NET 10
using System.Globalization;
using RecursiveAI.OneHtml;
 
using var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.ParseAdd("BookPriceBot/1.0 (+https://example.com/bot)");
 
var rows = new List<string> { "title,price_gbp,url" };
Uri? next = new Uri("https://books.toscrape.com/catalogue/category/books/mystery_3/index.html");
int pages = 0;
 
while (next is not null && pages < 10)
{
    Document doc = await FetchAsync(next);
    pages++;
 
    foreach (Node book in doc.Root.QuerySelectorAll("article.product_pod"))
    {
        Node link = book.QuerySelector("h3 > a");
        decimal price = decimal.Parse(book.QuerySelector(".price_color").TextContent!.TrimStart('£'), CultureInfo.InvariantCulture);
        var url = new Uri(next, link.GetAttribute("href"));
        rows.Add($"\"{link.GetAttribute("title")!.Replace("\"", "\"\"")}\",{price.ToString(CultureInfo.InvariantCulture)},{url}");
    }
 
    // Follow the "next" link until there isn't one
    Node nextLink = doc.Root.QuerySelector("li.next > a");
    Console.WriteLine($"Page {pages}: {next.Segments[^1]}, {rows.Count - 1} books so far");
    next = nextLink.IsNull ? null : new Uri(next, nextLink.GetAttribute("href"));
 
    await Task.Delay(TimeSpan.FromSeconds(1));   // one request a second keeps the load on the site low
}
 
File.WriteAllLines("mystery.csv", rows);
Console.WriteLine($"Wrote {rows.Count - 1} books to mystery.csv");
 
async Task<Document> FetchAsync(Uri url)
{
    using HttpResponseMessage response = await http.GetAsync(url);
    response.EnsureSuccessStatusCode();
    byte[] body = await response.Content.ReadAsByteArrayAsync();
    return Document.Parse(body, new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() });
}
OutputConsole
Page 1: index.html, 20 books so far
Page 2: page-2.html, 32 books so far
Wrote 32 books to mystery.csv
mystery.csvCSV
title,price_gbp,url
"Sharp Objects",47.82,https://books.toscrape.com/catalogue/sharp-objects_997/index.html
"In a Dark, Dark Wood",19.63,https://books.toscrape.com/catalogue/in-a-dark-dark-wood_963/index.html
"The Past Never Ends",56.50,https://books.toscrape.com/catalogue/the-past-never-ends_942/index.html
  • Resolve the next link against the current page. Page 2’s address is relative (page-2.html), and new Uri(next, href) resolves it against the page it came from.
  • Parse numbers with CultureInfo.InvariantCulture, or a machine set to a locale that uses a decimal comma will read “47.82” wrong.
  • Reuse one HttpClient for the whole run rather than creating one per request.
  • Keep the delay. One request a second is slow for a program and fast for a person, and it keeps you from loading the server. If a site answers with HTTP 429 or 503, slow down further, and honor a Retry-After header if there is one.

robots.txt and terms of use

Before you scrape a site that isn’t yours, read two things:

  • robots.txt. Fetch /robots.txt from the site’s root. The format is standardized in RFC 9309: groups of User-agent lines followed by Allow and Disallow path rules. The RFC states that these rules “are not a form of access authorization”: they are requests that well-behaved crawlers honor. When robots.txt returns a status in the 400–499 range, as both sandboxes here do, the RFC says crawlers “MAY access any resources on the server”.
  • The site’s terms of use, and any API it offers. An official API is usually more stable than HTML that changes with each redesign.

Whether scraping a particular site is allowed depends on the site’s terms, the data and where you are. This article isn’t legal advice.

Scrape pages that JavaScript builds

onehtml doesn’t run scripts. When the data you see in the browser isn’t in the HTML the server sends, a script built it after the page loaded, and you need a browser to run that script. Playwright for .NET drives Chromium. Let it render the page, then pass the finished DOM from ContentAsync to the parser:

TerminalNuGet
dotnet add package Microsoft.Playwright --version 1.64.1
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
Program.csonehtml 1.0.0 · Playwright 1.64.1
using Microsoft.Playwright;
using RecursiveAI.OneHtml;
 
const string url = "https://quotes.toscrape.com/js/";
 
// The HTML the server sends has no quotes in it: a script builds them in the browser
using var http = new HttpClient();
Document raw = Document.Parse(await http.GetByteArrayAsync(url));
Console.WriteLine($"Quotes in the server's HTML: {raw.Root.QuerySelectorAll(".quote").Count}");
 
// Let a real browser run the script, then parse the DOM it built
using IPlaywright playwright = await Playwright.CreateAsync();
await using IBrowser browser = await playwright.Chromium.LaunchAsync();
IPage page = await browser.NewPageAsync();
await page.GotoAsync(url);
await page.WaitForSelectorAsync(".quote");
Document rendered = Document.Parse(await page.ContentAsync());
 
List<Node> quotes = rendered.Root.QuerySelectorAll(".quote");
Console.WriteLine($"Quotes in the rendered DOM:  {quotes.Count}");
foreach (Node quote in quotes.Take(3))
{
    string author = quote.QuerySelector(".author").TextContent!;
    string tags = string.Join(", ", quote.QuerySelectorAll(".tag").Select(t => t.TextContent));
    Console.WriteLine($"{author}: {tags}");
}
OutputConsole
Quotes in the server's HTML: 0
Quotes in the rendered DOM:  10
Albert Einstein: change, deep-thoughts, thinking, world
J.K. Rowling: abilities, choices
Albert Einstein: inspirational, life, live, miracle, miracles

WaitForSelectorAsync waits until the script has added the first quote, so the DOM is complete before you read it. Check for a JSON API first, though. Pages that render on the client often fetch their data from one, and the browser’s Network tab shows it. Calling that API with HttpClient is faster than running a browser. On this sandbox the data is inline in a <script> element instead, as a JavaScript array.

Choosing a parser for scraping

HtmlAgilityPack and AngleSharp are the other common .NET choices. Here is how they differ on the points that matter for scraping, as each publisher states them, for the versions on NuGet in October 2026:

onehtml 1.0.0HtmlAgilityPack 1.13.1AngleSharp 1.8.6
QueriesCSS Selectors Level 4; no XPathXPath; CSS through the third-party Fizzler package (HAP site)CSS selectors; XPath through AngleSharp.XPath
Downloads pages itselfNo; you pass the bytesHtmlWeb (docs)BrowsingContext with a loader (README)
Builds in the package.NET 10.NET Framework 3.5 to 4.5, .NET 8, .NET Standard 2.0 and others (NuGet).NET 10 and 8, .NET Standard 2.0, .NET Framework 4.6.2 and 4.7.2 (NuGet)
LicenseMIT OR Apache-2.0MITMIT

On parsing 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, on an AMD Ryzen 9 7950X3D with .NET 10. For a polite scraper the network sets the pace, so parse speed matters most when you reprocess pages you’ve already saved. The HtmlAgilityPack vs AngleSharp comparison covers the rest.

Where next