Recursive AI

Parse an HTML table in C#

All onehtml recipes

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.

Terminal
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:

C#
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>&nbsp;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; &nbsp; becomes U+00A0, which Trim removes
static string[] Cells(Node row, string selector) =>
    row.QuerySelectorAll(selector).Select(c => c.TextContent!.Trim()).ToArray();
Text
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, th or td elements above are closed, and the parser closes each where a browser would.
  • A tbody is always there. The parser inserts one, as browsers do, so table > tr matches nothing. Use table tr, or table > tbody > tr to skip a thead.
  • 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:

C#
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}");
Text
Plan | Includes
Basic | Email support
Team | ChatPhone
All rows, nested included: 4

Limits

  • colspan and rowspan aren’t expanded. A spanning cell is one td; read GetAttribute("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.