.NET 6とAngleSharpによるC#でのスクレイピング技法

C# Advent Calendar 2021の参加記事となっています。去年は2個エントリーしたあげく、1個すっぽかした(!)という有様だったので、今年は反省してちゃんと書きます。

スクレイピングに関しては10年前にC#でスクレイピング:HTMLパース(Linq to Html)のためのSGMLReader利用法という記事でSGMLReaderを使ったやり方を紹介していたのですが、10年前ですよ、10年前!さすがにもう古臭くて、現在ではもっとずっと効率的に簡単にできるようになってます。

今回メインで使うのはAngleSharpというライブラリです。AngleSharp自体は2015年ぐらいからもう既に定番ライブラリとして、日本でも紹介記事が幾つかあります。が、いまいち踏み込んで書かれているものがない気がするので、今回はもう少しがっつりと紹介していきたいと思っています。それと直近Visual StudioのWatchウィンドウの使い方を知らん、みたいな話を聞いたりしたので、デバッグ方法の手順みたいなものを厚めに紹介したいなあ、という気持ちがあります!

AngleSharpの良いところは、まずはHTMLをパースしてCSSセレクターで抽出できるところです。以前はLINQ(to DOM)があればCSSセレクターじゃなくてもいいっす、WhereとSelectManyとDescendantsでやってきますよ、とか言ってましたが、そんなにきちんと構造化されてるわけじゃないHTMLを相手にするのにあたっては、CSSセレクターのほうが100億倍楽!CSSセレクターの文法なんて大したことないので、普通に覚えて使えってやつですね。SQLと正規表現とCSSセレクターは三大言語関係なく覚えておく教養、と。

もう一つは、それ自体でネットワークリクエストが可能なこと。FormへのSubmitなどもサポートして、Cookieも保持し続けるとかが出来るので、ログインして会員ページを弄る、といったようなクローラーが簡単に書けるんですね。この辺非常に良く出来ていて、もう自前クローラーなんて投げ捨てるしかないです。また、JintというPure C#なJavaScriptインタプリタと統合したプラグインも用意されているので、JavaScriptがDOMをガリガリっと弄ってくる今風のサイトにも、すんなり対応できます。

AngleSharpの紹介記事では、よくHttpClientなどで別途HTMLを取ってきたから、それをAngleSharpのHtmlParserに読み込ませる、というやり方が書かれていることが多いのですが、取得も含めて全てAngleSharp上で行ったほうが基本的には良いでしょう。

ここまで来るとPure C#の軽量なヘッドレスブラウザとしても動作する、ということになるので、カジュアルなE2Eテストの実装基盤にもなり得ます。普通のユニットテストと並べて dotnet test だけでその辺もある程度まかなえたら、とても素敵なことですよね?がっつりとしたE2Eテストを書きたい場合はPlaywrightなどを使わなければ、ということになってしまいますが、まずは軽い感じから始めたい、という時にうってつけです。C#で書けるし。いいことです。

BrowingContextとQuerySelectorの基本

まずはシンプルなHTMLのダウンロードと解析を。基本は BrowsingContext を作って、それをひたすら操作していくことになります。

// この辺で色々設定する
var config = Configuration.Default
    .WithDefaultLoader(); // LoaderはデフォではいないのでOpenAsyncする場合につける

// Headless Browser的なものを作る
using var context = BrowsingContext.New(config);

// とりあえずこのサイトの、右のArchivesのリンクを全部取ってみる
var doc = await context.OpenAsync("https://neue.cc");

OpenAsyncで取得できた IDocument をよしなにCSSセレクターで解析していくわけですが、ここで絞り込みクエリー作成に使うのがVisual StudioのWatchウィンドウ。(Chromeのデベロッパーツールなどで機械的に取得したい要素のCSSセレクターを取得できたりしますが、手セレクターのほうがブレなくルールは作りやすいかな、と)。

デバッガーを起動して、とりあえずウォッチウィンドウを開いておもむろに、Nameのところでコードを書きます。

image

ウォッチウィンドウは見たい変数を並べておく、お気に入り的な機能、と思いきや本質的にはそうじゃなくて、式を自由に書いて、結果を保持する、ついでに式自体も保持できるという、実質REPLなのです。代入もラムダ式もLINQも自由に書けるし、入力補完も普通に出てくる。Immediate Windowよりも結果が遥かに見やすいので、Immediate Windowは正直不要です。

デバッガー上で動いているので実データを自由に扱えるというところがいいですね。というわけで、ToHtml()でHTMLを見て、QuerySelectorAllをゆっくり評価しながら書いていきましょう。まずはサイドバーにあるので .side_body を出してみると、あれ、二個あるの?と。

image

中開けてInnerHtml見ると、なるほどProfile部分とArchive部分、と。とりあえず後ろのほうで固定のはずなのでlast-childね、というところで一旦評価して大丈夫なのを確認した後に、あとはa、と。でここまでで期待通りの結果が取れていれば、コピペる。よし。

// 基本、QuerySelectorかQuerySelectorAllでDOMを絞り込む
var anchors = doc.QuerySelectorAll(".side_body:last-child a")
    .Cast<IHtmlAnchorElement>() // AngleSharp.Html.Dom
    .Select(x => x.Href)
    .ToArray();

単一の要素に絞り込んだ場合は、 IHtml*** にキャストしてあげると扱いやすくなります(attributeのhrefのtextを取得、みたいにしなくていい)。頻出パターンなので、QuerySelectorAll<T>でCastもセットになってすっきり。

doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a")

せっかくなので、年に何本記事を書いていたかの集計を出してみたいと思います!URLから正規表現で年と月を取り出すので、とりあえずここでもウォッチウィンドウです。

image

anchrosの[0]を確認して、これをデータソースとしてRegex.Matchを書いて、どのGroupに収まったのかを見ます。この程度だったら特にミスらないでしょー、と思いきや普通に割とミスったりするのが正規表現なので、こういうので確認しながらやっていけるのはいいですね。

後は普通の(?)LINQコード。グルーピングした後に、ひたすら全ページをOpenAsyncしていきます。記事の本数を数えるのはh1の数をチェックするだけなので、特に複雑なCSSセレクターは必要なし。本来はページングの考慮は必要ですが、一月単位だとページングが出てくるほどの記事量がないので、そこも考慮なしで。

var yearGrouped = anchors
    .Select(x =>
    {
        var match = Regex.Match(x, @"(\d+)/(\d+)");
        return new
        {
            Url = x,
            Year = int.Parse(match.Groups[1].Value),
            Month = int.Parse(match.Groups[2].Value)
        };
    })
    .GroupBy(x => x.Year);

foreach (var year in yearGrouped.OrderBy(x => x.Key))
{
    var postCount = 0;
    foreach (var month in year)
    {
        var html = await context.OpenAsync(month.Url);
        postCount += html.QuerySelectorAll("h1").Count(); // h1 == 記事ヘッダー
    }
    Console.WriteLine($"{year.Key}年記事更新数: {postCount}");
}

結果は

2009年記事更新数: 92
2010年記事更新数: 61
2011年記事更新数: 66
2012年記事更新数: 30
2013年記事更新数: 33
2014年記事更新数: 22
2015年記事更新数: 19
2016年記事更新数: 24
2017年記事更新数: 13
2018年記事更新数: 11
2019年記事更新数: 14
2020年記事更新数: 11
2021年記事更新数: 5

ということで右肩下がりでした、メデタシメデタシ。今年は特に書いてないなあ、せめて2ヶ月に1本は書きたいところ……。

なお、C#による自家製静的サイトジェネレーターに移行した話 で紹介しているのですが、このサイトは完全にGitHub上に.mdがフラットに並んで.mdが管理されているので、こういうの出すなら別にスクレイピングは不要です。

UserAgentを変更する

スクレイピングといったらログインしてごにょごにょする。というわけで、そうしたログイン処理をさくっとやってくれるのがAngleSharpの良いところです。ので紹介していきたいのですが、まずはやましいことをするので(?)、UserAgentを偽装しましょう。

AngleSharpが現在何を送っているのかを確認するために、とりあえずダミーのサーバーを立てます。その際には .NET 6 のASP .NET から搭載されたMinimal APIが非常に便利です!そしてそれをLINQPadで動かすと、テスト用サーバーを立てるのにめっちゃ便利です!やってみましょう。

image

たった三行でサーバーが立ちます。便利。

await context.OpenAsync("http://localhost:5000/headers");

でアクセスして、 AngleSharp/1.0.0.0 で送られていることが確認できました。

なお、LINQPadでASP.NETのライブラリを使うには、Referene ASP.NET Core assembliesのチェックを入れておく必要があります。

image

他、よく使うNuGetライブラリや名前空間なども設定したうえで、Set as default for new queriesしておくと非常に捗ります。

さて、で、このUser-Agentのカスタマイズの方法ですが、AngleSharpはServicesに機能が詰まっているようなDI、というかService Locatorパターンの設計になっているので、ロードされてるServicesを(Watch Windowで)一通り見ます。

image

型に限らず全Serviceを取得するメソッドが用意されていない場合でも、<object>で取ってやると全部出てくるような実装は割と多い(ほんと)ので、とりあえずやってみるのはオススメです。今回も無事それで取れました。

で、型名を眺めてそれっぽそうなのを見ると DefaultHttpRequester というのがかなりそれっぽく、その中身を見るとHeadersという輩がいるので、これを書き換えればいいんじゃないだろうかと当たりがつきます。

ここはやましい気持ちがあるので(?)Chromeに偽装しておきましょう。

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36";

再びOpenAsyncしてLINQPadの表示を見て、変更されてること確認できました。

image

ちなみに、DefaultじゃないHttpRequesterをConfigurationに登録しておく、ということも出来ますが、よほどカスタムでやりたいことがなければ、デフォルトのものをちょっと弄るぐらいの方向性でやっていったほうが楽です。

FormにSubmitする

クローラーと言ったらFormにSubmit、つまりログイン!そしてクッキーをいただく!認証!

さて、が、まぁ認証付きの何かを例にするのはアレなので、googleの検索フォームを例にさせていただきたいと思います。先にまずはコード全体像と結果を。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom; // 拡張メソッドとかで有効化されたりするのでusing大事
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies(); // login form的なものの場合これでクッキーを持ち歩く

using var context = BrowsingContext.New(config);

// お行儀悪いので(?)前述のこれやっておく
var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://google.com/");
var form = doc.Forms[0];
var result = await form.SubmitAsync(new { q = "AngleSharp" }); // name = valueは匿名型が使える

// とりあえず結果を表示しておく
var titles = result.QuerySelectorAll<IHtmlHeadingElement>("h3").Select(x => x.TextContent);
var i = 1;
foreach (var item in titles)
{
    Console.WriteLine($"{i++:00}: {item}");
}

image

WithDefaultLoader と、そして認証クッキー持ち歩きのために WithDefaultCookies をコンフィギュレーションに足しておくことが事前準備として必須です。User-Agentの書き換えはご自由に、ただやましいこと、ではなくてUA判定をもとにして処理する、みたいなサイトも少なからずあるので、余計ないこと考えなくて済む対策としてはUAをChromeに偽装しておくのはアリです。

FormへのSubmit自体は3行というか2行です。ページをOpenしてFormに対してSubmitするだけ。超簡単。 .FormsIHtmlElementFormsがすっと取れるので、あとは単純にSubmitするだけです。渡す値は { name = value }の匿名型で投げ込めばOK。

度々出てくるウォッチウィンドウの宣伝ですが、この何の値を投げればいいのか、を調べるのにHTMLとニラメッコではなく、ウォッチウィンドウで調査していきます。

image

まず("input")を拾うのですが、9個ある。多いね、で、まぁこれはほとんどtype = "hidden"なので無視して良い(AngleSharpがSubmitAsync時にちゃんと自動でつけて送信してくれる)。値を入れる必要があるのはhiddden以外のものなので、それをウォッチで普通にLINQで書けば、3件に絞れました。で、中身見ると必要っぽいのはqだけなので、 new { q = "hogemoge" } を投下、と。

認証が必要なサイトでは、これでBrowingContextに認証クッキーがセットされた状態になるので、以降のこのContextでのOpenや画像、動画リクエストは認証付きになります。

画像や動画を拾う

スクレイピングといったら画像集めマンです(?)。AngleSharpでのそうしたリソース取得のやり方には幾つかあるのですが、私が最も良いかな、と思っているのはIDocumentLoader経由でのフェッチです。

// BrowsingContextから引っ張る。Contextが認証クッキー取得済みなら認証が必要なものもダウンロードできる。
var loader = context.GetService<IDocumentLoader>();

// とりあえず適当にこのブログの画像を引っ張る
var response = await loader.FetchAsync(new DocumentRequest(new Url("https://user-images.githubusercontent.com/46207/142736833-55f36246-cb7f-4b62-addf-0e18b3fa6d07.png"))).Task;

using var ms = new MemoryStream();
await response.Content.CopyToAsync(ms);

var bytes = ms.ToArray(); // あとは適当にFile.WriteAllBytesでもなんでもどうぞ

内部用なので少し引数やAPIが冗長なところもありますが、それは後述しますが別になんとでもなるところなので、どちらかというと生のStreamが取れたりといった柔軟性のところがプラスだと思っています。普通にHttpClientで自前で取るのと比べると、認証周りやってくれた状態で始められるのが楽ですね。

並列ダウンロードもいけます、例えば、このブログの全画像を引っ張るコードを、↑に書いた全ページ取得コードを発展させてやってみましょう。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://neue.cc/");
var loader = context.GetService<IDocumentLoader>();

foreach (var arvhives in doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a"))
{
    var page = await context.OpenAsync(arvhives.Href);

    // content(ページ本体)下のimgを全部。
    // 今回はページ単位で5並列ダウンロードすることにする(粒度の考え方は色々ある)
    var imgs = page.QuerySelectorAll<IHtmlImageElement>("#content img");
    await Parallel.ForEachAsync(imgs, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (img, ct) =>
     {
         var url = new Url(img.Source);
         var response = await loader.FetchAsync(new DocumentRequest(url)).Task;

         // とりあえず雑にFile書き出し。
         Console.WriteLine($"Downloading {url.Path}");
         using (var fs = new FileStream(@$"C:\temp\neuecc\{url.Path.Replace('/', '_')}", FileMode.Create))
         {
             await response.Content.CopyToAsync(fs, ct);
         }
     });
}

.NET 6から Parallel.ForEachAsync が追加されたので、asyncコードを並列数(MaxDegreeOfParallelism)で制御した並列実行が容易に書けるようになりました。async/await以降、Parallel系の出番は圧倒的に減ったのは確かなのですが、Task.WhenAllだけだと並列に走りすぎてしまって逆に非効率となってしまって、そこを制御するコードを自前で用意する必要が出てきていたりと面倒なものも残っていました。それが、このParallel.ForEachAsyncで解消されたと思います。

Kurukuru Progress

数GBの動画をダウンロードする時などは、プログレスがないとちゃんと動いているのか確認できなくて不便です。しかし、ただ単にConsole.WriteLineするだけだとログが凄い勢いで流れていってしまって見辛くて困りものです。そこを解決するC#ライブラリがKurukuruで、見ればどんなものかすぐわかるので、まずは実行結果を見てもらいましょう(素の回線だと一瞬でダウンロード終わってしまったので回線の低速シミュレーションしてます)

guruguru

一行だけを随時書き換えていってくれるので、見た目も非常に分かりやすくて良い感じです。これはとても良い。Kurukuru、今すぐ使いましょう。ちなみに今回の記事で一番時間がかかったのは、Kurukuruの並列リクエスト対応だったりして(対応していなかったのでコード書いてPR上げて、今日リリースしてもらいましたできたてほやほやコード)。

AngleSharp側のコードですが、この例はFile Examples のMP4を並列で全部取るというものです。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
using Kurukuru;
using System.Text;

// Kurukuruを使う上で大事なおまじない
// え、デフォルトのEncodingがUTF8じゃないシェルがあるんです!?←Windows
Console.OutputEncoding = Encoding.UTF8;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://file-examples.com/index.php/sample-video-files/sample-mp4-files/");
var loader = context.GetService<IDocumentLoader>();

// ここから本体
var mp4s = doc.QuerySelectorAll<IHtmlAnchorElement>("a").Where(x => x.Href.EndsWith(".mp4"));
Console.WriteLine("Download sample-mp4-files");
await Parallel.ForEachAsync(mp4s, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (mp4, ct) =>
{
    var bin = await loader.FetchBytesAsync(mp4.Href);
    // あとはFile.WriteAllBytesするとか好きにして
});

ポイントは var bin = await loader.FetchBytesAsync(mp4.Href); で、これは拡張メソッドです。loaderにProgress付きでbyte[]返すメソッドを生やしたことで、随分シンプルに書けるようになりました。StreamのままFileStreamに書いたほうがメモリ節約的にはいいんですが、中途半端なところでコケたりした場合のケアが面倒くさいので、ガチガチなパフォーマンスが重視される場合ではないならbyte[]のまま受けちゃってもいいでしょう。1つ4GBの動画を5並列なんですが?という場合でも、たかがメモリ20GB程度なので普通にメモリ積んで処理すればいいっしょ。

FetchBytesAsyncの中身は以下のようなコードになります。

public static class DocumentLoaderExtensions
{
    public static async Task<byte[]> FetchBytesAsync(this IDocumentLoader loader, string address, CancellationToken cancellationToken = default)
    {
        var url = new AngleSharp.Url(address);
        var response = await loader.FetchAsync(new DocumentRequest(url)).Task;
        if (response.StatusCode != System.Net.HttpStatusCode.OK)
        {
            return Array.Empty<byte>(); // return empty instead of throws error(ここをどういう挙動させるかは好みで……。)
        }

        // Content-Lengthが取れない場合は死でいいということにする
        var contentLength = int.Parse(response.Headers["Content-Length"]);

        using var progress = new ProgressSpinner(url.Path.Split('/').Last(), contentLength);
        try
        {
            return await ReadAllDataAsync(response.Content, contentLength, progress, cancellationToken);
        }
        catch
        {
            progress.Cancel();
            throw;
        }
    }

    static async Task<byte[]> ReadAllDataAsync(Stream stream, int contentLength, IProgress<int> progress, CancellationToken cancellationToken)
    {
        var buffer = new byte[contentLength];
        var readBuffer = buffer.AsMemory();
        var len = 0;
        while ((len = await stream.ReadAsync(readBuffer, cancellationToken)) > 0)
        {
            progress.Report(len);
            readBuffer = readBuffer.Slice(len);
        }
        return buffer;
    }
}

public class ProgressSpinner : IProgress<int>, IDisposable
{
    readonly Spinner spinner;
    readonly string fileName;
    readonly int? totalBytes;
    int received = 0;

    public ProgressSpinner(string fileName, int? totalBytes)
    {
        this.totalBytes = totalBytes;
        this.fileName = fileName;
        this.spinner = new Spinner($"Downloading {fileName}");
        this.spinner.Start();
    }

    public void Report(int value)
    {
        received += value;
        if (totalBytes != null)
        {
            var percent = (received / (double)totalBytes) * 100;
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)} / {ToHumanReadableBytes(totalBytes.Value)} ( {Math.Floor(percent)}% )";
        }
        else
        {
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)}";
        }
    }

    public void Cancel()
    {
        spinner.Fail($"Canceled {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    public void Dispose()
    {
        spinner.Succeed($"Downloaded {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    static string ToHumanReadableBytes(int bytes)
    {
        var b = (double)bytes;
        if (b < 1024) return $"{b:0.00} B";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} KB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} MB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} GB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} TB";
        b /= 1024;

        return $"{0:0.00} PB";
    }
}

KurukuruのSpinnerを内包した IProgress<T> を作ってあげて、その中でよしなにやってあげるということにしました。まぁちょっと長いですが、一回用意すれば後はコピペするだけなので全然いいでしょう。みなさんもこのProgressSpinner、使ってやってください。

コマンド引数やロギング処理やオプション取得

クローラーとしてガッツシやりたいなら、モードの切り替えとかロギングとか入れたいです、というか入れます。そこで私が定形として使っているのはConsoleAppFrameworkZLogger。Cysharpの提供です。ワシが作った。それと今回のようなケースだとKokubanも便利なので入れます。やはりCysharpの提供です。

<ItemGroup>
    <PackageReference Include="AngleSharp" Version="1.0.0-alpha-844" />
    <PackageReference Include="Kurukuru" Version="1.4.0" />
    <PackageReference Include="ConsoleAppFramework" Version="3.3.2" />
    <PackageReference Include="ZLogger" Version="1.6.1" />
    <PackageReference Include="Kokuban" Version="0.2.0" />
</ItemGroup>

この場合Program.csは以下のような感じになります。割と短いですよ!

using ConsoleAppFramework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text;
using ZLogger;

Console.OutputEncoding = Encoding.UTF8;

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.AddZLoggerConsole();
        x.AddZLoggerFile($"logs/{args[0]}-{DateTime.Now.ToString("yyyMMddHHmmss")}.log");
    })
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<NanikaOptions>(hostContext.Configuration.GetSection("Nanika"));
    })
    .RunConsoleAppFrameworkAsync(args);

public class NanikaOptions
{
    public string UserId { get; set; } = default!;
    public string Password { get; set; } = default!;
    public string SaveDirectory { get; set; } = default!;
}

コンソールログだけだとウィンドウ閉じちゃったときにチッとかなったりするので(?)、ファイルログあると安心します。ZLoggerは秘伝のxmlコンフィグなどを用意する必要なく、これだけで有効化されるのが楽でいいところです。それでいてパフォーマンスも抜群に良いので。

ConsoleAppFrameworkはGenericHostと統合されているので、コンフィグの読み込みもOptionsで行います。appsettings.jsonを用意して

{
  "Nanika": {
    "UserId": "hugahuga",
    "Password": "takotako",
    "SaveDirectory": "C:\\temp\\dir",
  }
}

.csprojのほうに

<ItemGroup>
    <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
</ItemGroup>

と書いてあげれば、自動で読み込まれるようになるという仕様です。そして本体のコードは

public class NanikaDownloader : ConsoleAppBase
{
    readonly ILogger<NanikaDownloader> logger;
    readonly NanikaOptions options;

    // コンストラクタインジェクションでOptionsを受け取る
    public NanikaDownloader(ILogger<NanikaDownloader> logger, IOptions<NanikaOptions> options)
    {
        this.logger = logger;
        this.options = options.Value;
    }

    public async Task DownloadAre()
    {
        // Context.CancellationTokenを渡すのを忘れないように!(Ctrl+Cのキャンセル対応に必須)
        await loader.FecthAsyncBytes("...", Context.CancellationToken)
    }

    public async Task DownloadSore(int initialPage)
    {
        // Kokubanを使うとConsoleに出す文字列の色分けが簡単にできる!( `Chalk.Color +` だけで色が付く)
        logger.LogInformation(Chalk.Green + $"Download sore {initialPage} start");
    }
}

のように書きます。これの場合は、引数で NanikaDownloader.DownloadAre, NanikaDownloader.DownloadSore -initialPage * の実行切り替えができるようになるわけですね……!

また、文字色が一色だけだとコンソール上のログはかなり見づらいわけですが、Kokubanを使うことで色の出し分けが可能になります。これは、地味にめちゃくちゃ便利なのでおすすめ。別にバッチ系に限らず、コンソールログの色を調整するのってめっちゃ大事だと、最近実感しているところです。

ASP .NET Core(とかMagicOnionとか)で、ZLoggerでエラーを赤くしたい!とか、フレームワークが吐いてくる重要でない情報はグレーにして目立たなくしたい!とかの場合は、ZLoggerのPrefix/SuffixFormatterを使うのをオススメしてます(Kokubanのようにさっくり書けはしないのですが、まぁConfigurationのところで一回やるだけなので)

logging.AddZLoggerConsole(options =>
{
#if DEBUG
    // \u001b[31m => Red(ANSI Escape Code)
    // \u001b[0m => Reset
    // \u001b[38;5;***m => 256 Colors(08 is Gray)
    options.PrefixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error)
        {
            ZString.Utf8Format(writer, "\u001b[31m[{0}]", info.LogLevel);
        }
        else
        {
            if (!info.CategoryName.StartsWith("MyApp")) // your application namespace.
            {
                ZString.Utf8Format(writer, "\u001b[38;5;08m[{0}]", info.LogLevel);
            }
            else
            {
                ZString.Utf8Format(writer, "[{0}]", info.LogLevel);
            }
        }
    };
    options.SuffixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error || !info.CategoryName.StartsWith("MyApp"))
        {
            ZString.Utf8Format(writer, "\u001b[0m", "");
        }
    };
#endif

}, configureEnableAnsiEscapeCode: true); // configureEnableAnsiEscapeCode

こういうの、地味に開発効率に響くので超大事です。やっていきましょう。

まとめ

AngleSharpにかこつけてウォッチウィンドウをとにかく紹介したかったのです!ウォッチウィンドウ最強!値の変化があると赤くなってくれたりするのも便利ですね、使いこなしていきましょう。別にUnityとかでもクソ便利ですからね?

あ、で、AngleSharpはめっちゃいいと思います。他の言語のスクレピングライブラリ(Beautiful Soupとか)と比べても、全然張り合えるんじゃないかな。冒頭に書きましたがE2Eテストへの応用なども考えられるので、使いこなし覚えるのとてもいいんじゃないかと思います。ドキュメントが色々書いてあるようで実は別にほとんど大したこと書いてなくて役に立たないというのは若干問題アリなんですが、まぁ触って覚えるでもなんとかなるので、大丈夫大丈夫。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive