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.
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:
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}");
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
titleattribute has the whole title. - Optional elements give the null node, not an exception. Check
IsNullbefore reading from anything that may be missing. - Say who you are. A
User-Agentwith 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:
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 }); }
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:
dotnet add package Microsoft.Playwright --version 1.64.1using 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)}"); }
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.txtfrom the site’s root. RFC 9309 defines itsUser-agent,AllowandDisallowrules, 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.