Select the table’s rows with table tr, read the th cells as column names and the td cells as values. The program below turns a table into records and a CSV file.
dotnet new console -o HtmlTable && cd HtmlTable dotnet add package RecursiveAI.OneHtml --version 1.5.1
Put this in Program.cs and run dotnet run:
using RecursiveAI.OneHtml; Document doc = Document.Parse(""" <table id="rates"> <caption>Exchange rates</caption> <tr><th>Currency<th>Code<th>Rate <tr><td>Euro<td>EUR<td>0.92 <tr><td>Pound sterling<td>GBP<td>0.79 <tr><td>Japanese yen<td>JPY<td> 149.30 </table> """); Node table = doc.Root.QuerySelector("#rates"); // The parser adds the tbody a browser adds, so "table tr" is safer than "table > tr" List<Node> rows = table.QuerySelectorAll("tr"); string[] columns = Cells(rows[0], "th"); Console.WriteLine($"Columns: {string.Join(", ", columns)}"); var records = new List<Dictionary<string, string>>(); foreach (Node row in rows.Skip(1)) { string[] values = Cells(row, "td"); records.Add(columns.Zip(values).ToDictionary(p => p.First, p => p.Second)); } foreach (var r in records) { Console.WriteLine($"{r["Code"]}: {r["Rate"]} ({r["Currency"]})"); } // CSV: quote every field, double any quotes inside var csv = rows.Select(row => string.Join(',', Cells(row, "th, td") .Select(v => $"\"{v.Replace("\"", "\"\"")}\""))); File.WriteAllLines("rates.csv", csv); Console.Write(File.ReadAllText("rates.csv")); // The trimmed text of each cell; becomes U+00A0, which Trim removes static string[] Cells(Node row, string selector) => row.QuerySelectorAll(selector).Select(c => c.TextContent!.Trim()).ToArray();
Columns: Currency, Code, Rate EUR: 0.92 (Euro) GBP: 0.79 (Pound sterling) JPY: 149.30 (Japanese yen) "Currency","Code","Rate" "Euro","EUR","0.92" "Pound sterling","GBP","0.79" "Japanese yen","JPY","149.30"
- End tags can be missing. None of the
tr,thortdelements above are closed, and the parser closes each where a browser would. - A
tbodyis always there. The parser inserts one, as browsers do, sotable > trmatches nothing. Usetable tr, ortable > tbody > trto skip athead. - Values are text. Character references are decoded; parse numbers yourself with
CultureInfo.InvariantCulture.
To load the page from a file or URL first, see Parse HTML in C#.
Tables with a thead, and nested tables
When the header row is in a thead, read it from there and take the rows from tbody. The :scope child combinator keeps a nested table’s rows out:
using RecursiveAI.OneHtml; Document doc = Document.Parse(""" <table> <thead><tr><th>Plan</th><th>Includes</th></tr></thead> <tbody> <tr><td>Basic</td><td>Email support</td></tr> <tr><td>Team</td> <td><table><tr><td>Chat</td><td>Phone</td></tr></table></td></tr> </tbody> </table> """); Node table = doc.Root.QuerySelector("table"); var head = table.QuerySelectorAll(":scope > thead > tr > th") .Select(c => c.TextContent); Console.WriteLine(string.Join(" | ", head)); foreach (Node row in table.QuerySelectorAll(":scope > tbody > tr")) { var cells = row.QuerySelectorAll(":scope > td").Select(c => c.TextContent); Console.WriteLine(string.Join(" | ", cells)); } Console.WriteLine($"All rows, nested included: {table.QuerySelectorAll("tr").Count}");
Plan | Includes Basic | Email support Team | ChatPhone All rows, nested included: 4
Limits
colspanandrowspanaren’t expanded. A spanning cell is onetd; readGetAttribute("colspan")and repeat the value yourself if your data needs a full grid.- Only the markup. A table filled in by JavaScript is empty in the server’s HTML; see Web scraping in C# for rendering it first.
- Cell text is all of a cell’s text, including any nested table’s. Select narrower elements when cells hold more than a value.