Recursive AI

Web scraping in C#

All onehtml recipes

Download the page with HttpClient, parse the bytes with onehtml, and pull out the data with CSS selectors. The program below scrapes the first page of books.toscrape.com, a sandbox built for scraping practice.

Terminal
dotnet new console -o BookScraper && cd BookScraper
dotnet add package RecursiveAI.OneHtml --version 1.5.1

Put this in Program.cs and run dotnet run:

C#
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 does
byte[] body = await response.Content.ReadAsByteArrayAsync();
string? type = response.Content.Headers.ContentType?.ToString();
var options = new ParseOptions { ContentType = type };
Document doc = Document.Parse(body, options);
 
List<Node> books = doc.Root.QuerySelectorAll("article.product_pod");
Console.WriteLine($"{books.Count} books on {pageUrl}");
 
foreach (Node book in books.Take(4))
{
    Node link = book.QuerySelector("h3 > a");
    string title = link.GetAttribute("title")!;   // the link text is cut short
    string price = book.QuerySelector(".price_color").TextContent!;
    string stock = book.QuerySelector(".availability").TextContent!.Trim();
    var url = new Uri(pageUrl, link.GetAttribute("href"));   // a relative link
    Console.WriteLine($"{price,7}  {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(.star-rating.Five)").Count;
Console.WriteLine($"Five-star books on this page: {fiveStars}");
Text
20 books on https://books.toscrape.com/
 £51.77  In stock  A Light in the Attic
         https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
 £53.74  In stock  Tipping the Velvet
         https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
 £50.10  In stock  Soumission
         https://books.toscrape.com/catalogue/soumission_998/index.html
 £47.82  In stock  Sharp Objects
         https://books.toscrape.com/catalogue/sharp-objects_997/index.html
Five-star books on this page: 4
  • Find the selectors in DevTools. Inspect the element in your browser and write the selector there first. onehtml builds the same tree the browser does, so the selector you tested selects the same elements here.
  • Pass bytes, not a string. With the bytes and the Content-Type, the parser chooses the encoding as a browser does. This site declares UTF-8 only in a <meta> tag, which is why the “£” comes out right.
  • Read attributes when the text is cut. The link text here is truncated (“A Light in the …”); the title attribute has the whole title.
  • Optional elements give the null node, not an exception. Check IsNull before reading from anything that may be missing.
  • Say who you are. A User-Agent with a contact URL lets a site owner reach you instead of blocking you.

Follow pagination

Follow the site’s own “next” link until there isn’t one, wait between requests, and cap the page count so a mistake can’t become an endless crawl. This writes a CSV:

C#
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");
        string price = book.QuerySelector(".price_color").TextContent!.TrimStart('£');
        decimal gbp = decimal.Parse(price, CultureInfo.InvariantCulture);
        string title = link.GetAttribute("title")!.Replace("\"", "\"\"");
        var url = new Uri(next, link.GetAttribute("href"));
        rows.Add($"\"{title}\",{gbp.ToString(CultureInfo.InvariantCulture)},{url}");
    }
 
    Console.WriteLine($"Page {pages}: {next.Segments[^1]}, {rows.Count - 1} books");
    Node nextLink = doc.Root.QuerySelector("li.next > a");
    next = nextLink.IsNull ? null : new Uri(next, nextLink.GetAttribute("href"));
 
    await Task.Delay(TimeSpan.FromSeconds(1));   // one request a second
}
 
File.WriteAllLines("mystery.csv", rows);
Console.WriteLine($"Wrote {rows.Count - 1} books to mystery.csv");
Console.WriteLine(rows[1]);
 
async Task<Document> FetchAsync(Uri url)
{
    using HttpResponseMessage response = await http.GetAsync(url);
    response.EnsureSuccessStatusCode();
    byte[] body = await response.Content.ReadAsByteArrayAsync();
    string? type = response.Content.Headers.ContentType?.ToString();
    return Document.Parse(body, new ParseOptions { ContentType = type });
}
Text
Page 1: index.html, 20 books
Page 2: page-2.html, 32 books
Wrote 32 books to mystery.csv
"Sharp Objects",47.82,https://books.toscrape.com/catalogue/sharp-objects_997/index.html

Page 2’s address is relative (page-2.html), so resolve the next link against the page it came from. Parse numbers with CultureInfo.InvariantCulture, or a machine set to a decimal-comma locale reads “47.82” wrong. Reuse one HttpClient for the whole run. If a site answers 429 or 503, slow down and honor Retry-After.

Pages that JavaScript builds

When the data you see in the browser isn’t in the HTML the server sends, a script built it, and you need a browser to run that script. Let Playwright render the page, then parse the finished DOM:

Terminal
dotnet add package Microsoft.Playwright --version 1.64.1
C#
using Microsoft.Playwright;
using RecursiveAI.OneHtml;
 
const string url = "https://quotes.toscrape.com/js/";
 
// The HTML the server sends: a script builds the quotes later, in the browser
using var http = new HttpClient();
Document raw = Document.Parse(await http.GetByteArrayAsync(url));
int inHtml = raw.Root.QuerySelectorAll(".quote").Count;
Console.WriteLine($"Quotes in the server's HTML: {inHtml}");
 
// Downloads Chromium the first time; does nothing once it is there
Microsoft.Playwright.Program.Main(["install", "chromium"]);
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!;
    var tags = quote.QuerySelectorAll(".tag").Select(t => t.TextContent);
    Console.WriteLine($"{author}: {string.Join(", ", tags)}");
}
Text
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

The first run also prints Chromium’s download progress before this output. Check the browser’s Network tab for a JSON API first: pages that render on the client often fetch their data from one, and calling it with HttpClient is faster than running a browser.

Before you scrape a site that isn’t yours

  • robots.txt. Fetch /robots.txt from the site’s root. RFC 9309 defines its User-agent, Allow and Disallow rules, and says they “are not a form of access authorization”: they are requests that well-behaved crawlers honor.
  • Terms of use and APIs. An official API is usually more stable than HTML that changes with each redesign. Whether scraping a site is allowed depends on its terms, the data and where you are; this isn’t legal advice.

Limits

  • onehtml doesn’t fetch or run scripts. HTTP stays in your code, where you control headers, timeouts, proxies and retries; JavaScript needs a browser, as above.
  • No XPath. Selectors plus LINQ cover the same ground; see Use CSS selectors in C#.
  • .NET 10 only.

For the text of a page, see Extract text from HTML; for its links and images, Extract links and images. How onehtml compares with HtmlAgilityPack and AngleSharp: HtmlAgilityPack vs AngleSharp.