C#でHTMLテーブルを安全にスクレイピングする方法|HtmlAgilityPackとXPathで可変行を堅牢に解析

画面スクレイピングで「行数が毎回変わるHTMLテーブル」から確実にデータを取りたい――この課題は多くのC#エンジニアが一度は通る道です。結論から言えば、正規表現ではなくHTML専用パーサー(HtmlAgilityPack等)を使うのが最短・最善です。本稿では、失敗しやすい落とし穴と、実務で通用する堅牢なコードまで、丸ごと解説します。

目次

C#でHTMLテーブルを読み取る方法

質問概要

  • 画面スクレイピングで取得した行数が可変のHTMLテーブルからデータを抽出したい。
  • 正規表現で処理すべきか、それとも別の方法があるか知りたい。

回答・解決策(結論)

手段可否・ポイント理由・注意点
HtmlAgilityPack などのHTMLパーサーを利用(推奨)◎ 安定・簡潔HTMLの入れ子構造や軽微なタグ欠損を自動で補正。 XPath / LINQで //table[@id='pcpHistoryTable']//tr → td をたどるだけで行・列取得可能。 最小コード例:
var rows = doc.DocumentNode.SelectNodes("//table[@id='pcpHistoryTable']//tbody//tr");
XmlDocument / XDocument / LINQ to XML△ 条件付きHTMLが完全にXML準拠であれば動く。 実HTMLは属性の未閉鎖や大文字小文字揺れがありエラーになりやすい。 例外が出やすく保守コスト高。
正規表現× 不適入れ子やフォーマット崩れに弱く、メンテナンス困難。 テーブルのネスト・セル内HTML・改行パターンで容易に破綻。

最小サンプル:HtmlAgilityPackでテーブルの全行・全セルを読む

まずは「とにかく動く」最小版です。HTML文字列(または取得したページのHTML)から、行数が可変のテーブルを読みます。


// NuGet: HtmlAgilityPack
// using HtmlAgilityPack;
using System;
using System.Linq;
using System.Collections.Generic;

public class Demo
{
    public static void Main()
    {
        var html = @"
        <table id='pcpHistoryTable'>
          <thead><tr><th>Name</th><th>Start</th><th>End</th></tr></thead>
          <tbody>
            <tr><td>John Doe</td><td>Feb 1, 2025</td><td>Current</td></tr>
            <tr><td>Jane Roe</td><td>Jan 10, 2024</td><td>Mar 8, 2024</td></tr>
          </tbody>
        </table>";

        var doc = new HtmlDocument();
        doc.OptionFixNestedTags = true;      // 軽微なタグ崩れに強くする
        doc.OptionReadEncoding = true;
        doc.LoadHtml(html);

        var rows = doc.DocumentNode.SelectNodes("//table[@id='pcpHistoryTable']//tbody//tr");
        if (rows == null || rows.Count == 0)
        {
            Console.WriteLine("no rows");
            return;
        }

        foreach (var row in rows)
        {
            var cells = row.SelectNodes("td")?
                           .Select(td => HtmlEntity.DeEntitize(td.InnerText).Trim())
                           .ToList() ?? new List<string>();

            Console.WriteLine(string.Join(" | ", cells));
        }
        // 出力例: John Doe | Feb 1, 2025 | Current
    }
}

URLから直接読み込む(実ページ)

ページURLがある場合は HtmlWeb を使えば同様のXPathで解析できます。認証・クッキー・リトライなどが必要な場合は HttpClient を併用してください。


// using HtmlAgilityPack;
// Install-Package HtmlAgilityPack
using System;
using HtmlAgilityPack;

public static class WebLoadSample
{
    public static HtmlDocument LoadByUrl(string url)
    {
        var web = new HtmlWeb
        {
            // ページ側の軽微なHTML欠損を補正
            AutoDetectEncoding = true,
            PreRequest = req =>
            {
                req.UserAgent = "Mozilla/5.0 (compatible; TableScraper/1.0)";
                return true;
            }
        };
        var doc = web.Load(url);
        doc.OptionFixNestedTags = true;
        return doc;
    }
}

実務で使う堅牢版:モデル化・日付変換・特殊値(Current)

テーブル行をC#オブジェクトに落とし込み、End 列の「Current」を null で表す設計にします。日付は英語表記(例:Feb 1, 2025)を想定し、CultureInfo と DateTime.ParseExact で厳密にパースします。


using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using HtmlAgilityPack;

public sealed class MembershipRow
{
    public string Name { get; init; } = "";
    public DateTime StartDate { get; init; }
    public DateTime? EndDate { get; init; } // Current: null
}

public static class TableParser
{
    private static readonly string[] KnownDateFormats =
    {
        "MMM d, yyyy", "MMM dd, yyyy", "MMMM d, yyyy", "MMMM dd, yyyy"
    };

    private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en-US");

    public static IReadOnlyList<MembershipRow> Parse(HtmlDocument doc)
    {
        var rows = doc.DocumentNode
                      .SelectNodes("//table[@id='pcpHistoryTable']//tbody//tr");

        if (rows == null) return Array.Empty<MembershipRow>();

        var list = new List<MembershipRow>();

        foreach (var row in rows)
        {
            var tds = row.SelectNodes("td");
            if (tds == null || tds.Count < 3) continue; // 想定外行はスキップ

            string nameRaw = Clean(tds[0].InnerText);
            string startRaw = Clean(tds[1].InnerText);
            string endRaw = Clean(tds[2].InnerText);

            DateTime start = ParseDate(startRaw);
            DateTime? end = ParseEnd(endRaw);

            list.Add(new MembershipRow
            {
                Name = nameRaw,
                StartDate = start,
                EndDate = end
            });
        }
        return list;
    }

    private static string Clean(string s)
        => HtmlEntity.DeEntitize(s).Replace(" ", " ").Trim();

    private static DateTime ParseDate(string s)
        => DateTime.ParseExact(s, KnownDateFormats, En, DateTimeStyles.None);

    private static DateTime? ParseEnd(string s)
    {
        if (string.Equals(s, "current", StringComparison.OrdinalIgnoreCase))
            return null; // 現在も有効
        return ParseDate(s);
    }
}

「終了日が未確定なら DateTime.MaxValue を入れておきたい」という運用もよくあります。分析・検索の都合に応じて、null と DateTime.MaxValue のどちらかをプロジェクトの共通ルールにしてください。

XPathの安定化テクニック(見出しが変わっても壊れにくく)

実サイトは小変更が頻発します。次のプラクティスでクエリの堅牢性を高め、スクレイパーの寿命を延ばします。

  • IDがあるなら最優先で使う: //table[@id='pcpHistoryTable']
  • tbodyが省略されるケース: ブラウザが暗黙に <tbody> を補う場合があるため、//table//tr と //table//tbody//tr の双方を試すフォールバック戦略が有効。
  • 見出しテキストに依存しない: //table[.//th[contains(normalize-space(.),'Start')]] のように「Start列が存在するテーブル」を相対的に特定。
  • 位置指定は最後の手段: //table[1]/tr[2]/td[3] のような位置固定は変更に弱い。

HtmlNode? table = doc.DocumentNode.SelectSingleNode(
    "//table[@id='pcpHistoryTable']"
) ?? doc.DocumentNode.SelectSingleNode(
    "//table[.//th[contains(normalize-space(.),'Start')]]"
);

var rows = table?.SelectNodes(".//tbody//tr") 
        ?? table?.SelectNodes(".//tr"); // tbody 省略対策

セル内HTML・空白・非表示要素への対処

セルにリンク・ボタン・改行・非表示要素が混ざると、InnerText だけでは余計な文字が紛れます。代表的な対策は以下です。

課題症状対策
&nbsp;(ノーブレークスペース)余計な空白、結合ミスHtmlEntity.DeEntitize で実体参照をデコード→ Replace("&nbsp;", " ")
非表示要素 display:none不要な文字が混入可視ノードのみ抽出(style判定 or 明示的に除外)
<br>や入れ子タグ想定外改行、タブ化改行をスペースに正規化、複数空白→単一空白

static string NormalizeWhitespace(string s)
{
    s = HtmlEntity.DeEntitize(s).Replace("&amp;nbsp;", " ");
    return System.Text.RegularExpressions.Regex.Replace(s, @"\s+", " ").Trim();
}

HttpClientでの取得・タイムアウト・リトライ

アクセス頻度の高い処理では HttpClient の使い回しとタイムアウト設定が重要です。429/503対策には指数バックオフのリトライを。


using System;
using System.Net.Http;
using System.Threading.Tasks;

public static class Http
{
    private static readonly HttpClient Client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(30)
    };

    public static async Task&lt;string&gt; GetStringAsync(string url)
    {
        var delay = 500;
        for (var attempt = 0; attempt &lt; 4; attempt++)
        {
            var resp = await Client.GetAsync(url);
            if ((int)resp.StatusCode == 429 || (int)resp.StatusCode == 503)
            {
                await Task.Delay(delay);
                delay *= 2;
                continue;
            }
            resp.EnsureSuccessStatusCode();
            return await resp.Content.ReadAsStringAsync();
        }
        throw new HttpRequestException("Too many retries");
    }
}

大規模ページや大量処理のパフォーマンス最適化

  • OptionFixNestedTags:崩れたHTML修復は便利だがコストも増える。必要時のみ有効に。
  • 狭い検索範囲: まずテーブルを SelectSingleNode で絞り、その配下のみで SelectNodes する。
  • 一括パース→即捨て: 行ループで必要な値だけ取り出し、不要なノード参照(特に親参照)を持ち回さない。
  • 型変換の事前計算: 日付フォーマット配列やカルチャは静的に保持(前掲コード参照)。
  • 並列化の慎重運用: 多数ページを Parallel.ForEach で回す際はサイト負荷とBAN対策を必ず考慮。

例外・欠損耐性(Nullチェックとフェイルソフト)

WebのHTMLは常に変わります。欠損・順序変更・空セルがあっても落ちないようにしましょう。


static (bool ok, MembershipRow? row, string? reason) TryParseRow(HtmlNode tr)
{
    var tds = tr.SelectNodes("td");
    if (tds == null || tds.Count &lt; 3) return (false, null, "columns &lt; 3");

    string name = NormalizeWhitespace(tds[0].InnerText);
    string startS = NormalizeWhitespace(tds[1].InnerText);
    string endS = NormalizeWhitespace(tds[2].InnerText);

    try
    {
        var start = DateTime.ParseExact(startS, KnownDateFormats, En, DateTimeStyles.None);
        var end = string.Equals(endS, "current", StringComparison.OrdinalIgnoreCase)
                  ? (DateTime?)null
                  : DateTime.ParseExact(endS, KnownDateFormats, En, DateTimeStyles.None);

        return (true, new MembershipRow { Name = name, StartDate = start, EndDate = end }, null);
    }
    catch (FormatException ex)
    {
        return (false, null, "date format error: " + ex.Message);
    }
}

集計側では 成功分だけ を使い、失敗分は理由付きでログに吐き出すと運用が楽になります。

テーブル見出し(ヘッダー)から列順を動的に特定

列順が変更されるケースに備え、<th> テキストからインデックスを求めておくと堅牢です。


static int IndexOfHeader(HtmlNode table, string headerText)
{
    var ths = table.SelectNodes(".//thead//th") ?? table.SelectNodes(".//tr[1]//th");
    if (ths == null) throw new InvalidOperationException("no header");

    for (int i = 0; i &lt; ths.Count; i++)
    {
        var text = NormalizeWhitespace(ths[i].InnerText);
        if (text.Contains(headerText, StringComparison.OrdinalIgnoreCase))
            return i;
    }
    throw new InvalidOperationException("header not found: " + headerText);
}

こうして動的に把握した列インデックスで、各行の td を取り出します。

CSV・JSONへエクスポート(後処理)

読み取った行をファイルに保存するシンプルな例です。追加ライブラリなしで実現できます。


using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

static void SaveAsCsv(IEnumerable&lt;MembershipRow&gt; rows, string path)
{
    var sb = new StringBuilder();
    sb.AppendLine("Name,StartDate,EndDate");
    foreach (var r in rows)
    {
        string end = r.EndDate.HasValue ? r.EndDate.Value.ToString("yyyy-MM-dd") : "";
        sb.AppendLine($"{Escape(r.Name)},{r.StartDate:yyyy-MM-dd},{end}");
    }
    File.WriteAllText(path, sb.ToString(), Encoding.UTF8);

    static string Escape(string s)
    {
        return s.Contains(',') ? $"\"{s.Replace("\"", "\"\"")}\"" : s;
    }
}

static void SaveAsJson(IEnumerable&lt;MembershipRow&gt; rows, string path)
{
    var opts = new JsonSerializerOptions
    {
        WriteIndented = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };
    File.WriteAllText(path, JsonSerializer.Serialize(rows, opts), Encoding.UTF8);
}

正規表現が失敗する理由(実例で理解)

「<td>(.*?)</td> で抜けばいいのでは?」という発想は、入れ子や改行、属性の揺れで簡単に崩れます。


&lt;td&gt;John &lt;span class="role"&gt;(Manager)&lt;/span&gt;&lt;/td&gt;

このようなセルで「John (Manager)」を期待すると、.*? は非貪欲でも予期せぬ位置で止まり、タグ内テキストの扱いも不安定になります。HTMLは木構造で解析するのが鉄則です。

XmlDocument / LINQ to XML が「たまに動いてしまう」罠

完璧に整ったXHTMLならXML系APIでも動きます。しかし、実サイトのHTMLは厳密なクローズ、属性引用符、エスケープが守られていないことが多く、突然の例外や取りこぼしを生みます。最初からHTMLパーサー一択にしておいた方が総コストは低くなります。

多言語・多フォーマットの日時対応

英語以外の表記(例:2025年2月1日)や、01/02/2025 のように国によって意味が揺れるフォーマットに備えて、サイトのロケール情報に合わせて CultureInfo と ParseExact を調整してください。複数候補を許容する場合は TryParseExact を使い、失敗時はログに落として後段で補正します。

ユニットテストで壊れにくいスクレイパーに

DOMが変わった瞬間に気付けるよう、最小HTMLのスナップショットを用意し、パース結果を検証します。


// xUnit例
using Xunit;
using HtmlAgilityPack;
using System.Linq;

public class ParserTests
{
    [Fact]
    public void Parse_Should_Read_All_Rows()
    {
        var html = @"
        &lt;table id='pcpHistoryTable'&gt;
          &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Name&lt;/th&gt;&lt;th&gt;Start&lt;/th&gt;&lt;th&gt;End&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;&lt;td&gt;Alice&lt;/td&gt;&lt;td&gt;Feb 1, 2025&lt;/td&gt;&lt;td&gt;Current&lt;/td&gt;&lt;/tr&gt;
            &lt;tr&gt;&lt;td&gt;Bob&lt;/td&gt;&lt;td&gt;Jan 10, 2024&lt;/td&gt;&lt;td&gt;Mar 8, 2024&lt;/td&gt;&lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;";

        var doc = new HtmlDocument();
        doc.LoadHtml(html);

        var rows = TableParser.Parse(doc);
        Assert.Equal(2, rows.Count);
        Assert.Equal("Alice", rows.First().Name);
        Assert.Null(rows.First().EndDate);
    }
}

スクレイピングの倫理・安定運用のミニチェックリスト

  • 同意や利用規約、アクセス頻度のポリシーを確認し、負荷をかけない。
  • ユーザーエージェント、リトライ、指数バックオフ、スロットリングを実装。
  • HTML変更検知(ヘッダー文言/IDの監視、テスト自動化)。
  • ログ粒度は「1行1失敗」まで落とすと調査が速い。
  • 障害時のフェイルソフト(部分成功で継続、後段で再処理)。

サンプル:実務寄りの完全例(取得→解析→永続化)

ページ読込、XPath安定化、オブジェクト化、CSV書き出しまで一連の流れをまとめます。


using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using HtmlAgilityPack;

public sealed class HistoryImporter
{
    private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en-US");
    private static readonly string[] Fmts = { "MMM d, yyyy", "MMM dd, yyyy", "MMMM d, yyyy", "MMMM dd, yyyy" };

    public async Task RunAsync(string url, string csvOut)
    {
        // 1) 取得
        var html = await Http.GetStringAsync(url);

        // 2) パース
        var doc = new HtmlDocument();
        doc.OptionFixNestedTags = true;
        doc.LoadHtml(html);

        var table = doc.DocumentNode.SelectSingleNode("//table[@id='pcpHistoryTable']")
                 ?? doc.DocumentNode.SelectSingleNode("//table[.//th[contains(.,'Start')]]");
        if (table == null) throw new InvalidOperationException("table not found");

        var rows = table.SelectNodes(".//tbody//tr") ?? table.SelectNodes(".//tr");
        var list = new List&lt;MembershipRow&gt;();
        var failures = new List&lt;(int idx, string reason)&gt;();

        int idx = 0;
        foreach (var tr in rows ?? Enumerable.Empty&lt;HtmlNode&gt;())
        {
            idx++;
            var tds = tr.SelectNodes("td");
            if (tds == null || tds.Count &lt; 3) { failures.Add((idx, "col &lt; 3")); continue; }

            string name = Normalize(tds[0].InnerText);
            string startS = Normalize(tds[1].InnerText);
            string endS = Normalize(tds[2].InnerText);

            try
            {
                var start = DateTime.ParseExact(startS, Fmts, En, DateTimeStyles.None);
                DateTime? end = endS.Equals("current", StringComparison.OrdinalIgnoreCase)
                    ? null
                    : DateTime.ParseExact(endS, Fmts, En, DateTimeStyles.None);

                list.Add(new MembershipRow { Name = name, StartDate = start, EndDate = end });
            }
            catch (Exception ex)
            {
                failures.Add((idx, ex.Message));
            }
        }

        // 3) CSV保存
        SaveCsv(list, csvOut);

        Console.WriteLine($"ok={list.Count}, ng={failures.Count}");
    }

    private static string Normalize(string s)
    {
        s = HtmlEntity.DeEntitize(s).Replace("&amp;nbsp;", " ");
        return System.Text.RegularExpressions.Regex.Replace(s, @"\s+", " ").Trim();
    }

    private static void SaveCsv(IReadOnlyList&lt;MembershipRow&gt; rows, string path)
    {
        var sb = new StringBuilder();
        sb.AppendLine("Name,StartDate,EndDate");
        foreach (var r in rows)
        {
            var end = r.EndDate?.ToString("yyyy-MM-dd") ?? "";
            sb.AppendLine($"{Csv(r.Name)},{r.StartDate:yyyy-MM-dd},{end}");
        }
        File.WriteAllText(path, sb.ToString(), Encoding.UTF8);

        static string Csv(string s) =&gt; s.Contains(',') ? $"\"{s.Replace("\"", "\"\"")}\"" : s;
    }
}

よくある質問(FAQ)

tbodyがない/theadがないページで壊れます XPathを //table//tr に広げるか、SelectNodes(".//tbody//tr") ?? SelectNodes(".//tr") のフォールバックを入れてください。 列の順番がときどき変わります ヘッダー文言から列インデックスを動的に求め、td の位置に直接依存しないロジックに。 日付が「1/2/2025」のように曖昧です サイトのロケールに合わせて ParseExact と CultureInfo を設定。複数候補を許容するなら TryParseExact で安全に。 セル内にHTMLが混ざり、余計な文字が取れてしまいます InnerText の正規化(実体参照のデコード、複数空白の統一、非表示要素の除外)を行うか、必要に応じて子ノードを精査して可視テキストのみを合成します。 正規表現ではダメですか? テーブルのネストや改行、タグ欠損で壊れやすく、保守コストが跳ね上がります。HTMLパーサーを使うのが王道です。

チェックリスト(この順で見直すと失敗しない)

  1. 対象テーブルのID/特徴は固定か?(固定ならIDでピンポイント取得)
  2. tbody/theadの有無に依存していないか?(フォールバック用意)
  3. ヘッダー駆動で列位置を決めているか?(順序変更耐性)
  4. 空白・改行・&nbsp; の正規化を入れたか?
  5. 日付のカルチャ/書式を明示したか?(ParseExact)
  6. Current 等の特殊値の運用ルール(null or MaxValue)を決めたか?
  7. 失敗行のログを残しているか?(後追い修正が速くなる)
  8. テストHTMLを作り、CIで回せるようにしたか?

まとめ

HTMLはXMLとは異なるため、正規表現や純粋なXMLリーダーではなくHTML専用パーサー(HtmlAgilityPack)が最も確実です。XPathと最小限のクリーニング処理さえ押さえれば、「行数が可変のテーブル」でも安定して抽出できます。さらに、ヘッダー駆動・日付の厳密パース・フォールバックXPath・例外耐性・テスト自動化を組み合わせることで、実運用に耐える強固なスクレイパーになります。

補足ポイント(再掲・要点だけ)

  1. 「Current」等の日付以外の値は EndDate = null(または DateTime.MaxValue)で表現。
  2. URLから直接読む場合は HtmlWeb または HttpClient+HtmlDocument.LoadHtml。
  3. データ後処理は DateTime.ParseExact を使ってロケールに合わせて厳密に。
  4. パフォーマンスは検索範囲の絞り込み、フォーマット配列の静的保持、行ごとの即時破棄で最適化。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次