PHPのcURLのように外部APIを使わずに他サイトのHTMLを取得・解析したい――。本記事はASP.NET(Web Forms/MVC/ASP.NET Core)でのWebスクレイピングと自動化を、最小コードからスケール設計、運用の落とし穴まで一気通貫で整理します。法令・サイト規約順守を前提に、再現性の高い実装を示します。
ASP.NETでWebスクレイピングは可能か?結論と前提
結論から言うと可能です。HTTP取得は.NET標準のHttpClient、静的HTMLの解析はHtmlAgilityPackやAngleSharp、JavaScriptでDOMが生成される動的ページはPlaywright for .NETやSelenium WebDriverで対応できます。大量データや定期実行は、Webアプリ本体の同期処理に閉じず、バックグラウンドワーカー+キューへ分離するのが実運用の定石です。
ただし、スクレイピングは技術的に可能でも、対象サイトの利用規約とrobots.txt(クロール可否やアクセス頻度など)に従う必要があります。個人情報や著作物の取り扱いにも十分な配慮を行ってください。
全体像:小さく始めて安全にスケールするアーキテクチャ
- 取得層:
HttpClientまたはヘッドレスブラウザ(Playwright/Selenium)。Pollyでタイムアウト・リトライ・サーキットブレーカー。 - 解析層:HtmlAgilityPack(XPath中心)/AngleSharp(CSSセレクタ中心)。抽出結果はドメインモデルへマッピング。
- 制御層:レートリミット、スロットリング、ジョブキュー。バックグラウンドワーカーが非同期に処理。
- 保存層:EF Core等でDBへ永続化。冪等性(重複排除)とスキーマ進化を考慮。
- 運用層:ログ、メトリクス、アラート、ヘルスチェック。障害時の再実行と可観測性を確保。
目的別の実装手段(早見表)
| 目的 | 主な手段 | 補足ポイント |
|---|---|---|
| HTMLを取得 | HttpClientでGET/POST | .NET標準のみで完結。タイムアウト・リトライはPolly併用が安全。 |
| HTMLを解析 | HtmlAgilityPack(XPath)/AngleSharp(CSSセレクタ) | 正規表現だけでのパースは保守性が低く非推奨。 |
| 動的ページ対応 | Playwright for .NET/Selenium WebDriver | JSで生成されるDOMはヘッドレスブラウザでレンダリング後に抽出。 |
| 大規模クロール | Worker Service/キュー(Azure Queue、RabbitMQ 等) | Web要求内で完結させず、非同期化&スケールアウト前提で設計。 |
| 設計の参考OSS | Scrapy、PySpider、Crawlee、Apify SDK、Apache Nutch 等 | キュー・リトライ・ポリシー管理の考え方をC#へ移植。 |
最小サンプル:静的HTMLの取得と解析
using var client = new HttpClient();
var html = await client.GetStringAsync("https://example.com");
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
// 例:h2 要素を列挙
var nodes = doc.DocumentNode.SelectNodes("//h2");
foreach (var n in nodes ?? Enumerable.Empty())
{
Console.WriteLine(n.InnerText.Trim());
}
より堅牢に:HttpClientFactoryとPollyでの耐障害設計
ASP.NET CoreではIHttpClientFactoryでHttpClientを管理し、Pollyのポリシーでネットワークの瞬断や429/5xxに耐える構成が推奨です。
依存性注入の設定例(Program.cs)
using Polly;
using Polly.Extensions.Http;
using System.Net;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
static IAsyncPolicy GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError() // 5xx,408
.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
static IAsyncPolicy GetTimeoutPolicy()
{
return Policy.TimeoutAsync(10); // 秒
}
builder.Services.AddHttpClient("scraper", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyScraperBot/1.0 (+info)");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetTimeoutPolicy());
var app = builder.Build();
app.MapGet("/", () => "OK");
app.Run();
呼び出し側
public class FetchService
{
private readonly IHttpClientFactory _factory;
public FetchService(IHttpClientFactory factory) => _factory = factory;
public async Task<string> GetHtmlAsync(string url, CancellationToken ct)
{
var client = _factory.CreateClient("scraper");
using var res = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
res.EnsureSuccessStatusCode();
return await res.Content.ReadAsStringAsync(ct);
}
}
robots.txtとレートリミット:最初に組み込む安全装置
最低限の礼儀として、User-Agentを明示し、robots.txtを確認、リクエスト間隔を空けましょう。以下は簡易なrobots.txtチェック例です(精密な対応には専用パーサを推奨)。
public static async Task<bool> IsAllowedByRobotsAsync(Uri target, IHttpClientFactory factory)
{
var robots = new Uri($"{target.Scheme}://{target.Host}/robots.txt");
var client = factory.CreateClient("scraper");
try
{
var text = await client.GetStringAsync(robots);
var lines = text.Split('\n');
var userAgentBlock = false;
var disallows = new List<string>();
foreach (var raw in lines)
{
var line = raw.Trim();
if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) continue;
if (line.StartsWith("User-agent:", StringComparison.OrdinalIgnoreCase))
{
var ua = line.Split(':', 2)[1].Trim();
userAgentBlock = ua == "*" || ua.Contains("MyScraperBot", StringComparison.OrdinalIgnoreCase);
}
else if (userAgentBlock && line.StartsWith("Disallow:", StringComparison.OrdinalIgnoreCase))
{
disallows.Add(line.Split(':', 2)[1].Trim());
}
}
var path = target.AbsolutePath;
return !disallows.Any(d => path.StartsWith(d, StringComparison.OrdinalIgnoreCase));
}
catch
{
// robots.txtが無い場合はサイト方針を尊重し、明示の許可が無い限り慎重に扱う
return false;
}
}
レートリミッタでアクセス頻度を制御(.NET)
using System.Threading.RateLimiting;
var limiter = TokenBucketRateLimiter.Create(new TokenBucketRateLimiterOptions
{
TokenLimit = 10, // バースト上限
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
TokensPerPeriod = 1,
AutoReplenishment = true
});
async Task WithLimitAsync(Func> action)
{
using var lease = await limiter.AcquireAsync(1);
if (!lease.IsAcquired) throw new Exception("Rate limit exceeded");
return await action();
}
HTML解析:HtmlAgilityPackとAngleSharpの使い分け
HtmlAgilityPack(XPath)
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
// 例:記事カードからタイトルとURLを抽出
var cards = doc.DocumentNode.SelectNodes("//article//a[@class='card']");
var items = new List<(string Title, string Url)>();
foreach (var a in cards ?? Enumerable.Empty())
{
var title = a.InnerText.Trim();
var href = a.GetAttributeValue("href", string.Empty);
items.Add((title, href));
}
AngleSharp(CSSセレクタ)
using AngleSharp;
using AngleSharp.Dom;
var context = BrowsingContext.New(Configuration.Default);
using var doc2 = await context.OpenAsync(req => req.Content(html));
// 例:.entry > h2 > a を抽出
var links = doc2.QuerySelectorAll(".entry h2 a")
.Select(a => new { Title = a.Text().Trim(), Url = a.GetAttribute("href") })
.ToList();
動的ページ対応:Playwright for .NET
SPAや無限スクロールなど、サーバー配信HTMLに必要情報が含まれない場合は、ヘッドレスブラウザでレンダリング後にDOMから抽出します。Playwrightは自動インストールと安定性が魅力です。
using Microsoft.Playwright;
public async Task> CaptureHeadingsAsync(string url)
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
var page = await browser.NewPageAsync(new BrowserNewPageOptions
{
UserAgent = "MyScraperBot/1.0"
});
await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 30000 });
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
// DOMからテキストを抽出
var headings = await page.EvaluateAsync<string[]>(@"
Array.from(document.querySelectorAll('h2')).map(e => e.textContent.trim())
");
return headings;
}
Selenium WebDriverの例(Chrome Headless)
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--disable-gpu");
options.AddArgument("--no-sandbox");
using var driver = new ChromeDriver(options);
driver.Navigate().GoToUrl("[https://example.com](https://example.com)");
var headings = driver.FindElements(By.CssSelector("h2")).Select(e => e.Text).ToList();
抽出結果を型で扱う:ドメインモデルとマッピング
public record Article(string Title, Uri Url, DateTimeOffset? PublishedAt);
public static Article? ToArticle(HtmlAgilityPack.HtmlNode card)
{
var a = card.SelectSingleNode(".//a");
if (a == null) return null;
var title = a.InnerText.Trim();
var href = a.GetAttributeValue("href", null);
if (string.IsNullOrWhiteSpace(href)) return null;
DateTimeOffset? published = null;
var timeNode = card.SelectSingleNode(".//time[@datetime]");
if (timeNode != null && DateTimeOffset.TryParse(timeNode.GetAttributeValue("datetime", ""), out var dt))
published = dt;
return new Article(title, new Uri(href, UriKind.RelativeOrAbsolute), published);
}
大量クロールの設計:バックグラウンド化とキューイング
ASP.NET MVC/Minimal API のリクエスト中にスクレイピングを完了させる設計はタイムアウトやスケールの壁に直面します。ジョブ投入API+バックグラウンドワーカーへ分離し、再実行と並列制御を行いましょう。
Channelを使った簡易キュー+BackgroundService
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
public record CrawlJob(Uri Url);
public class CrawlQueue
{
private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(1000)
{
SingleReader = false, SingleWriter = false, FullMode = BoundedChannelFullMode.Wait
});
public ValueTask WriteAsync(CrawlJob job, CancellationToken ct) => _channel.Writer.WriteAsync(job, ct);
public IAsyncEnumerable<CrawlJob> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);
}
public class CrawlWorker : BackgroundService
{
private readonly CrawlQueue _queue;
private readonly FetchService _fetch;
public CrawlWorker(CrawlQueue queue, FetchService fetch)
{
_queue = queue; _fetch = fetch;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _queue.ReadAllAsync(stoppingToken))
{
try
{
var html = await _fetch.GetHtmlAsync(job.Url.ToString(), stoppingToken);
// TODO: 解析・保存
}
catch (Exception ex)
{
// TODO: ログ記録と再実行ポリシー
}
}
}
}
メッセージキューを使う(Azure Queue/RabbitMQ等)
Web/APIはジョブをキューへ投入し、Workerが並列に処理します。キューは再試行や遅延配信、可視性タイムアウトなどの機能で失敗に強いフローを提供します。
エラーハンドリングと再試行設計の実践ポイント
- 分類:ネットワーク系(タイムアウト、DNS)、HTTP系(4xx/5xx)、DOM系(セレクタ不一致)、アプリ系(シリアライズ失敗)。
- 対策:一時的エラーは指数バックオフ、恒久的エラーは即失敗+検知、未知のDOM変更はフィーチャーフラグで抽出器を切替。
- 観測:成功/失敗率、平均レイテンシ、HTTPステータス分布、429発生数、抽出ゼロ件の割合をダッシュボード化。
認証・セッション・クッキーの扱い
ログインが必要な場合、相手の許容範囲と規約遵守を前提に、フォームログインやトークンを扱います。HttpClientではCookieContainer+HttpClientHandler、動的サイトではPlaywrightのStorageStateでセッション保存が有効です。
// Cookie対応のHttpClient
var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() };
var client = new HttpClient(handler);
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyScraperBot/1.0");
パフォーマンス:並列度・キャッシュ・限界の見極め
- 並列度:CPUではなく帯域幅と相手サイトの許容量で決める。レートリミッタと
SemaphoreSlimで行儀よく。 - キャッシュ:ETag/If-Modified-Sinceを尊重。静的ページは結果を数分~数時間キャッシュ。
- 短絡評価:正規表現は最後の手段。まずは堅牢なセレクタ戦略(ID・データ属性)を検討。
データ保存:EF Coreでの冪等なUpsert
public class ArticleEntity
{
public int Id { get; set; }
public string Url { get; set; } = default!;
public string Title { get; set; } = default!;
public DateTimeOffset? PublishedAt { get; set; }
public DateTimeOffset CrawledAt { get; set; }
}
public async Task UpsertAsync(AppDbContext db, Article a, CancellationToken ct)
{
var url = a.Url.IsAbsoluteUri ? a.Url.ToString() : new Uri(new Uri("[https://example.com](https://example.com)"), a.Url).ToString();
var entity = await db.Articles.FirstOrDefaultAsync(x => x.Url == url, ct);
if (entity == null)
{
entity = new ArticleEntity
{
Url = url,
Title = a.Title,
PublishedAt = a.PublishedAt,
CrawledAt = DateTimeOffset.UtcNow
};
db.Articles.Add(entity);
}
else
{
entity.Title = a.Title;
entity.PublishedAt = a.PublishedAt;
entity.CrawledAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
}
監視・運用:ログ、メトリクス、ヘルスチェック
- 構造化ログ:URL、処理時間、HTTPコード、抽出件数、失敗理由を必ずログ。
- ヘルスチェック:アプリの
/healthで依存先(DB・キュー)を検査。 - アラート:連続失敗、429急増、抽出ゼロが閾値を超えたら通知。
// Program.cs の一部
builder.Services.AddHealthChecks();
// ...
app.MapHealthChecks("/health");
ASP.NETの各フレームワーク別Tips
Web Forms
- 非同期API呼び出しを
async/awaitに統一。UIスレッドブロックを避ける。 - 長時間処理はページライフサイクル外へ。バックグラウンドワーカーを別プロセスで。
MVC(.NET Framework/.NET Core)
- ユーザー操作でスクレイピングを起動する場合は即時レスポンス+非同期キュー投入。
- 結果表示はDBから読み出してページング。コントローラで外部サイトへ直アクセスしない。
ASP.NET Core(Minimal API含む)
IHttpClientFactoryとPolly、RateLimiterで堅牢化。BackgroundServiceを複数立て、役割(取得・解析・保存)を分離。
ジョブスケジューリングの選択肢
- サーバーレス:定期実行やスケールに強い(例:タイマー起動)。
- オンプレ/VM:Windows タスクスケジューラ+Worker Service。
- アプリ内:Quartz.NETやHangfireでAP内ジョブ。ただしWebプロセス再起動に要注意。
無限スクロールやページネーションへの対処
- URLクエリの
pageやoffsetを解析し、ページ上限を設ける。 - Playwrightで「スクロールして一定件数が増えたら停止」など停止条件を明確に。
- ページごとに重複検出(URLハッシュ、コンテンツハッシュ)で同一データの再収集を回避。
コンテンツの正規化と重複排除
- テキストの正規化(空白・改行・全角半角)を共通関数化。
- リンクは絶対URL化し、トラッキングクエリを除去。
- 同一タイトル+同一URLは一意制約で防止。
ユニットテストと再現性の確保
- HTMLスナップショットをテスト資産として保存し、DOM変更の検出を自動化。
- 抽出ロジックは副作用の無い純関数に寄せ、I/Oはモック可能に。
// 例:抽出器の単体テスト(xUnit)
[Fact]
public void Extract_Title_From_Snapshot()
{
var html = File.ReadAllText("Snapshots/article_20240101.html");
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
var node = doc.DocumentNode.SelectSingleNode("//h1");
Assert.Equal("期待するタイトル", node.InnerText.Trim());
}
セレクタ戦略:壊れにくい抽出を設計する
- IDやdata属性があれば最優先。クラス名は変更されやすい。
- 親子関係は浅く、
ancestor descendantより近接セレクタで。 - フォールバックセレクタを用意し、第一候補が失敗したら代替を試す。
画像・PDF・ファイル取得時の注意
- 大きなバイナリは
HttpCompletionOption.ResponseHeadersReadでストリーミング。 - コンテンツタイプと拡張子の整合性を検証。
- 保存先にハッシュ名を用い、改ざん検出に備える。
using var res = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
await using var fs = File.Create(path);
await res.Content.CopyToAsync(fs, ct);
セキュリティと法令順守
- 規約:対象サイトの利用規約、robots.txtを確認。禁止されている領域はアクセスしない。
- 個人情報:GDPRや各国法制、国内法に配慮。不要な個人データは収集しない、保存しない。
- 説明責任:問い合わせ窓口、User-Agent表記、アクセス意図を明確に。
実用テンプレート:URLを受け取り記事タイトルを一覧化するMinimal API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("scraper").SetHandlerLifetime(TimeSpan.FromMinutes(5));
builder.Services.AddSingleton<CrawlQueue>();
builder.Services.AddHostedService<CrawlWorker>();
builder.Services.AddScoped<FetchService>();
var app = builder.Build();
app.MapPost("/crawl", async (CrawlQueue queue, string url) =>
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return Results.BadRequest("invalid url");
await queue.WriteAsync(new CrawlJob(u), CancellationToken.None);
return Results.Accepted($"/status/{u.Host}");
});
app.Run();
Playwrightでの無限スクロール処理の雛形
public async Task<IReadOnlyList<string>> InfiniteScrollAsync(string url, int targetCount)
{
using var pw = await Playwright.CreateAsync();
await using var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
var page = await browser.NewPageAsync();
await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
int lastCount = 0;
for (int i = 0; i < 50; i++)
{
await page.EvaluateAsync("window.scrollTo(0, document.body.scrollHeight)");
await page.WaitForTimeoutAsync(1000);
var count = await page.EvaluateAsync<int>("document.querySelectorAll('.item').length");
if (count >= targetCount) break;
if (count == lastCount) break; // これ以上増えない
lastCount = count;
}
var titles = await page.EvaluateAsync<string[]>(@"
Array.from(document.querySelectorAll('.item .title')).map(e => e.textContent.trim())
");
return titles;
}
ログとアラートの実装例(Serilog想定)
Log.Information("Fetch started {Url}", url);
try
{
var html = await _fetch.GetHtmlAsync(url, ct);
Log.Information("Fetch ok {Url} {Length}", url, html.Length);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
Log.Warning(ex, "429 Too Many Requests {Url}", url);
// バックオフ
}
catch (Exception ex)
{
Log.Error(ex, "Fetch failed {Url}", url);
}
チェックリスト:運用前に必ず確認
- 対象サイトの規約・robots.txt・法令に適合しているか。
- User-Agent、レートリミット、バックオフ、リトライが実装されているか。
- 障害時の再実行と重複排除ができるか。
- ダッシュボードで失敗率・遅延・429を監視できるか。
- データの出自と更新日時を保存しているか。
ベストプラクティスまとめ
- コア技術:
HttpClientで取得、HtmlAgilityPack/AngleSharpで解析、動的ページはPlaywright/Selenium。 - 堅牢化:Pollyでリトライ・タイムアウト、RateLimiterで頻度制御。
- スケール:バックグラウンドワーカー+メッセージキューで非同期化。
- 法令・規約:robots.txtと利用規約を尊重。個人情報は扱わない方針を原則に。
- 運用:ログ、メトリクス、ヘルスチェック、アラートで可観測性を担保。
付録:NuGetキーワード(検索用)
HtmlAgilityPack / AngleSharp / Microsoft.Playwright / Selenium.WebDriver / Polly / Polly.Extensions.Http / System.Threading.RateLimiting / Serilog / Quartz / Hangfire / Azure.Storage.Queues / RabbitMQ.Client
ケーススタディ:API非公開のニュースサイトを要約収集
例えば更新の早いニュースサイトから見出しと要約を収集したいケースを考えます。静的HTMLならHttpClient+HtmlAgilityPackで十分、SPAならPlaywrightが必要です。以下のようにパイプライン化することで、構成の見通しが良くなります。
- フェッチ:URLキューから取り出し、レートリミット下で取得。
- パース:見出し、URL、日付、カテゴリを抽出。セレクタはフォールバック用意。
- 正規化:絶対URL化、日付のタイムゾーン統一、重複排除。
- 保存:Upsertで差分反映。履歴テーブルにハッシュも保管。
- 通知:新着が閾値を超えたらSlack/メールなどへ通知(内部実装)。
よくある失敗と回避策
| 失敗例 | 原因 | 回避策 |
|---|---|---|
| Webリクエスト中にリクエストタイムアウト | Web要求内で長時間処理・同期ブロック | 非同期化+ジョブキュー。HTTPは即Acceptedを返し、バックグラウンドで処理。 |
| DOM変更で抽出ゼロ | 脆いセレクタ、単一セレクタ依存 | フォールバックセレクタ、スナップショットテスト、データ属性優先。 |
| 429が頻発 | スロットリング不足、User-Agent不備 | RateLimiter+指数バックオフ。適切なUser-Agent表記。 |
| 重複データの氾濫 | Upsertなし、ハッシュ不備 | 一意制約+ハッシュで冪等化。保存前に照合。 |
まとめ
ASP.NET/C#だけでWebスクレイピングは十分に実現可能です。静的ページはHttpClient+解析ライブラリ、動的ページはヘッドレスブラウザ。実運用では、レート制御・Polly・ジョブキュー・バックグラウンドワーカー・監視を組み合わせ、安全で壊れにくいパイプラインに仕上げることが成功の鍵です。小さく検証し、計測しながら段階的にスケールさせていきましょう。

コメント