日程Fit|「いつ空いてますか?」の往復はもう不要。候補日を選んでURLを送るだけ|登録不要|今すぐ無料で使う →

ASP.NETで実現するWebスクレイピングと自動化|HttpClient・HtmlAgilityPack・Playwright・Selenium・Pollyまで完全ガイド

PHPのcURLのように外部APIを使わずに他サイトのHTMLを取得・解析したい――。本記事はASP.NET(Web Forms/MVC/ASP.NET Core)でのWebスクレイピングと自動化を、最小コードからスケール設計、運用の落とし穴まで一気通貫で整理します。法令・サイト規約順守を前提に、再現性の高い実装を示します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

ASP.NETでWebスクレイピングは可能か?結論と前提

結論から言うと可能です。HTTP取得は.NET標準のHttpClient、静的HTMLの解析はHtmlAgilityPackAngleSharp、JavaScriptでDOMが生成される動的ページはPlaywright for .NETSelenium 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 WebDriverJSで生成されるDOMはヘッドレスブラウザでレンダリング後に抽出。
大規模クロールWorker Service/キュー(Azure Queue、RabbitMQ 等)Web要求内で完結させず、非同期化&スケールアウト前提で設計。
設計の参考OSSScrapy、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ではIHttpClientFactoryHttpClientを管理し、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ではCookieContainerHttpClientHandler、動的サイトでは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クエリのpageoffsetを解析し、ページ上限を設ける。
  • 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ならHttpClientHtmlAgilityPackで十分、SPAならPlaywrightが必要です。以下のようにパイプライン化することで、構成の見通しが良くなります。

  1. フェッチ:URLキューから取り出し、レートリミット下で取得。
  2. パース:見出し、URL、日付、カテゴリを抽出。セレクタはフォールバック用意。
  3. 正規化:絶対URL化、日付のタイムゾーン統一、重複排除。
  4. 保存:Upsertで差分反映。履歴テーブルにハッシュも保管。
  5. 通知:新着が閾値を超えたらSlack/メールなどへ通知(内部実装)。

よくある失敗と回避策

失敗例原因回避策
Webリクエスト中にリクエストタイムアウトWeb要求内で長時間処理・同期ブロック非同期化+ジョブキュー。HTTPは即Acceptedを返し、バックグラウンドで処理。
DOM変更で抽出ゼロ脆いセレクタ、単一セレクタ依存フォールバックセレクタ、スナップショットテスト、データ属性優先。
429が頻発スロットリング不足、User-Agent不備RateLimiter+指数バックオフ。適切なUser-Agent表記。
重複データの氾濫Upsertなし、ハッシュ不備一意制約+ハッシュで冪等化。保存前に照合。

まとめ

ASP.NET/C#だけでWebスクレイピングは十分に実現可能です。静的ページはHttpClient+解析ライブラリ、動的ページはヘッドレスブラウザ。実運用では、レート制御・Polly・ジョブキュー・バックグラウンドワーカー・監視を組み合わせ、安全で壊れにくいパイプラインに仕上げることが成功の鍵です。小さく検証し、計測しながら段階的にスケールさせていきましょう。

この記事を書いた人

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

コメント

コメントする

目次