画面スクレイピングで「行数が毎回変わる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("&nbsp;", " ").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 だけでは余計な文字が紛れます。代表的な対策は以下です。
| 課題 | 症状 | 対策 |
|---|---|---|
| (ノーブレークスペース) | 余計な空白、結合ミス | HtmlEntity.DeEntitize で実体参照をデコード→ Replace(" ", " ") |
非表示要素 display:none | 不要な文字が混入 | 可視ノードのみ抽出(style判定 or 明示的に除外) |
| <br>や入れ子タグ | 想定外改行、タブ化 | 改行をスペースに正規化、複数空白→単一空白 |
static string NormalizeWhitespace(string s)
{
s = HtmlEntity.DeEntitize(s).Replace("&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<string> GetStringAsync(string url)
{
var delay = 500;
for (var attempt = 0; attempt < 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 < 3) return (false, null, "columns < 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 < 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<MembershipRow> 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<MembershipRow> rows, string path)
{
var opts = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
File.WriteAllText(path, JsonSerializer.Serialize(rows, opts), Encoding.UTF8);
}
正規表現が失敗する理由(実例で理解)
「<td>(.*?)</td> で抜けばいいのでは?」という発想は、入れ子や改行、属性の揺れで簡単に崩れます。
<td>John <span class="role">(Manager)</span></td>
このようなセルで「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 = @"
<table id='pcpHistoryTable'>
<thead><tr><th>Name</th><th>Start</th><th>End</th></tr></thead>
<tbody>
<tr><td>Alice</td><td>Feb 1, 2025</td><td>Current</td></tr>
<tr><td>Bob</td><td>Jan 10, 2024</td><td>Mar 8, 2024</td></tr>
</tbody>
</table>";
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<MembershipRow>();
var failures = new List<(int idx, string reason)>();
int idx = 0;
foreach (var tr in rows ?? Enumerable.Empty<HtmlNode>())
{
idx++;
var tds = tr.SelectNodes("td");
if (tds == null || tds.Count < 3) { failures.Add((idx, "col < 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("&nbsp;", " ");
return System.Text.RegularExpressions.Regex.Replace(s, @"\s+", " ").Trim();
}
private static void SaveCsv(IReadOnlyList<MembershipRow> 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) => 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パーサーを使うのが王道です。
チェックリスト(この順で見直すと失敗しない)
- 対象テーブルのID/特徴は固定か?(固定ならIDでピンポイント取得)
- tbody/theadの有無に依存していないか?(フォールバック用意)
- ヘッダー駆動で列位置を決めているか?(順序変更耐性)
- 空白・改行・ の正規化を入れたか?
- 日付のカルチャ/書式を明示したか?(ParseExact)
- Current 等の特殊値の運用ルール(null or MaxValue)を決めたか?
- 失敗行のログを残しているか?(後追い修正が速くなる)
- テストHTMLを作り、CIで回せるようにしたか?
まとめ
HTMLはXMLとは異なるため、正規表現や純粋なXMLリーダーではなくHTML専用パーサー(HtmlAgilityPack)が最も確実です。XPathと最小限のクリーニング処理さえ押さえれば、「行数が可変のテーブル」でも安定して抽出できます。さらに、ヘッダー駆動・日付の厳密パース・フォールバックXPath・例外耐性・テスト自動化を組み合わせることで、実運用に耐える強固なスクレイパーになります。
補足ポイント(再掲・要点だけ)
- 「Current」等の日付以外の値は
EndDate = null(またはDateTime.MaxValue)で表現。 - URLから直接読む場合は
HtmlWebまたはHttpClient+HtmlDocument.LoadHtml。 - データ後処理は
DateTime.ParseExactを使ってロケールに合わせて厳密に。 - パフォーマンスは検索範囲の絞り込み、フォーマット配列の静的保持、行ごとの即時破棄で最適化。

コメント