.NETでWebページのPDFを作成する Playwright編

こんにちは。

テックリードのTerukiです。

以前.NETでWebページのPDFを作成するための記事を書いたのですが、その記事ではSeleniumを使っていました。

https://qiita.com/omt_teruki/items/48c486c521bfa969776a

最近になって謎のタイムアウトエラーなどが発生するようになって困ったのでPlaywrightに乗り換えてみようと思います。

# Playwright

必要あるか？という気はしますが一応紹介。

https://playwright.dev/

Microsoftが作ったブラウザオートメーションツールです。
基本はE2Eテストなどで使われると思いますが今回はPDF生成です。

Seleniumよりも新しいのでアーキテクチャ的な部分もモダンな感じなんだろうなと勝手に思っています。

Microsoft製なのもあって.NETの公式サポートも十分にあります。非常にありがたいです。

# 問題点

Lambdaはサーバレスで料金が安くスケーラビリティもあるのが強力なメリットですが、Lambdaはその仕組み上処理を終えるとLambdaで動いていたプロセスやスレッドがすべて一時停止してしまいます。

普通のアプリなら特に問題は起きないですが、Chromiumのようなマルチプロセスアーキテクチャのアプリだと問題が発生します。

そのため、起動時に `--single-process` というオプションをつけて実行させる必要があるのですが、これを使うと今度はPlaywrightがエラーを吐きます:sob:

ブラウザが閉じられました的なエラーが出るのでシングルプロセスだと上手く検知出来ないのだろうなと思っています。

Oh my teethではPDF生成処理の堅牢性が以前よりも重要になってきたので、コストメリットを諦めてECS Fargateで実行するようにしてみます。

# 実装

難しい点はやっぱりDockerfileなどのインフラ関連かなと思います。

いろいろ試行錯誤しましたが動かすことが出来たDockerfileがこちらです。

```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080

ENV PLAYWRIGHT_BROWSERS_PATH=/app/ms-playwright

# PowerShellのパッケージをインストールするのに必要
RUN apt-get update && apt-get install -y libgssapi-krb5-2

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

COPY ["PDFGenerator/PDFGenerator.csproj", "PDFGenerator/"]
RUN dotnet restore "PDFGenerator/PDFGenerator.csproj"

COPY . .
WORKDIR "/src/PDFGenerator"
RUN dotnet build "PDFGenerator.csproj" -c Release -o /app/build

FROM build AS publish

RUN dotnet publish "PDFGenerator.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish ./

# PowerShellパッケージをコピー wgetなどで取ってくるようにしても良い
COPY PDFGenerator/powershell_7.4.10-1.deb_amd64.deb /app/powershell_7.4.10-1.deb_amd64.deb

# PowerShellをインストールしてChromiumとそれに必要なライブラリをインストール
RUN dpkg -i powershell_7.4.10-1.deb_amd64.deb && pwsh /app/playwright.ps1 install chromium --with-deps --only-shell

ENTRYPOINT ["dotnet", "PDFGenerator.dll"]
```

PDFGeneratorというプロジェクトがある体になっていますが使えるところを適宜コピペしてもらえると良いかなと。

mcr.microsoft.com/dotnet/sdkには最初からPowerShellが入っているのでbuildステージでplaywright installしようとしたのですが、実際に動かすとChromiumがクラッシュしたので一番最後にインストールするようにしています。

後は適当にASP.NET Coreのプロジェクトを作ってコントローラでPlaywrightを呼び出します。

`Microsoft.Playwright` NuGetパッケージを入れておきます。

コントローラで直接Playwrightを叩くのもアレなのでこんなサービスを作ってSingletonでDIに登録しました。

```cs
using Microsoft.Playwright;

public class PlaywrightService {

    private static IPlaywright Playwright = null!;
    private static IBrowser Browser = null!;

    private async ValueTask InitializeAsync() {
        if (Playwright is not null) {
            return;
        }

        Playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false);
        Browser = await Playwright.Chromium.LaunchAsync(new() {
            Headless = true,
        }).ConfigureAwait(false);
    }

    private async Task<byte[]> GetPdfAsync(IPage page) {

        // Webフォントの読み込みを待つ
        await page.WaitForFunctionAsync("document.fonts.ready").ConfigureAwait(false);

        // SSRの読み込みを待つ
        await page.WaitForFunctionAsync("document.readyState === 'complete'").ConfigureAwait(false);

        // SPAの読み込みを待つ 仕方ない
        await page.WaitForTimeoutAsync(2000).ConfigureAwait(false);

        var cdp = await page.Context.NewCDPSessionAsync(page).ConfigureAwait(false);
        var result = await cdp.SendAsync("Page.printToPDF", []).ConfigureAwait(false)!;
        var pdf = result.Value.GetProperty("data").GetString()!;

        return Convert.FromBase64String(pdf);
    }

    /// <summary>
    /// 指定したURLのサイトをPDF化する
    /// </summary>
    /// <param name="url">PDF化したいページのURL</param>
    /// <returns>PDFのバイナリ</returns>
    public async Task<byte[]> GetPdfFromUrlAsync(string url) {

        await InitializeAsync().ConfigureAwait(false);

        var page = await Browser.NewPageAsync().ConfigureAwait(false);

        try {
            await page.GotoAsync(url).ConfigureAwait(false);

            return await GetPdfAsync(page).ConfigureAwait(false);
        } finally {
            await page.CloseAsync().ConfigureAwait(false);
        }
    }

    /// <summary>
    /// 指定したHTMLのページをPDF化する
    /// </summary>
    /// <param name="html">HTML</param>
    /// <returns>PDFのバイナリ</returns>
    public async Task<byte[]> GetPdfFromHtmlAsync(string html) {

        await InitializeAsync().ConfigureAwait(false);

        var page = await Browser.NewPageAsync().ConfigureAwait(false);
        try {
            await page.SetContentAsync(html).ConfigureAwait(false);

            return await GetPdfAsync(page).ConfigureAwait(false);
        } finally {
            await page.CloseAsync().ConfigureAwait(false);
        }
    }
}
```

URLを指定するタイプとHTMLを直接指定するやつがあります。

このコードは汎用的にいろいろなページを読めるようにしているのでWait処理が悲しいことになっていますが、可能であれば特定のDOMが存在しているかどうかなどを見たほうが良いと思います。

---

Seleniumも触った後なので余計感じますがPlaywrightは各種メソッドが非常に使いやすく設計されているなと思いました。

今後は他の用途でも使っていきたいなと思っています。

# Oh my teethについて
[Oh my teeth](https://www.oh-my-teeth.com/?tags=qiita)では未来の歯科体験を創るために日々活動しています。

Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。

一緒に未来の歯科体験を創りませんか？興味がある方は是非こちらを確認してください。

https://oh.careers/

カジュアル面談も可能なので気軽に応募してみてください！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/0138b492-e02b-852e-61fa-fc065b53d285.png" width="450" />