📚

Kobo APIで続刊通知を作る:その3

2024/06/16に公開

これまでとここでの話

これまで;

  • Kobo APIで最新刊を検索できるようにした
  • リストにある通知対象を検索し、未通知の最新があれば通知するようにした

今回:

  • 通知部分を作る

通知をどうする

いろいろな方法が、たとえば LINE とか Teams とか Slack とかメールとか SNS とかあるわけですが、今回は一番簡単なメールにします。メールが一番簡単なのは Outlook クライアントに全部細かいところを任せられるから。どの方法でも基本的にはユーザ認証が必要で、その認証も2要素だったりアプリの登録やらトークン取得やら、手間がかかります(それはそれで楽しいのですが)。なので今回はメールです。

メールも認証必要? はい、ですがその部分は Outlook に丸投げです。ですので、Windows おまけの Outlook ではなく、PowerShell で細かく操作できる Microsoft 365 Apps の Outlook を使います。

いちおう基礎クラスと実装クラスに分けておく

とはいえ、いろいろな通知ロジックを使えるように、基礎的なクラスとメール通知を実装するサブクラスにしておきましょう。

NotifyBase (基礎クラス) --> NotifyMail (実装クラス)

という関係です。NotifyMail に変わるサブクラスを作って他の通知方法を簡単に実装できる、ということにしておきます。

NotifyBase.psm1
class NotifyParams {
    $NewBookList;
}

まずは、各種パラメータをクラス化しておきます。これも実装クラスに合わせて継承したものを作ります。

NotifyMail.psm1
class NotifyMailParams : NotifyParams {
    [string]$To;
    [string]$Cc;
    [string]$Subject;
}

こんな感じで。

基礎クラスはメッセージの用意だけ

通知すべき一覧から案内文を作る部分も通知の方法によって変わるといえば変わるのですが、大きく変わるならサブクラス側で実装しなおせばいいので、基礎クラス側に文面作成コードを入れます。

NotifyBase.psm1
class NotifyBase {
    [NotifyParams] $Params;

    NotifyBase($p) { $this.Params = $p }

    [string[]] GetHTMLMessage() {
        $msg = @()
        $msg += ,'<html>'
        $msg += ,'<body>'
        $msg += $this.GetTextMessage() |ForEach-Object { "$_<br/>" }
        $msg += ,'</body>'
        $msg += ,'</html>'
        return $msg
    }

    [string[]] GetTextMessage() {
        $msg = @()
        $msg += ,""
        $msg += ,"楽天Koboに続刊が出ました"
        $msg += ,""

        $this.Params.NewBookList |ForEach-Object {
            $msg += ,"タイトル: <a href=""$($_.itemUrl)"">$($_.title)</a>"
            $msg += ,"発売日: $($_.salesDate)"
            $msg += ,""
        }

        return $msg
    }
}

はい、簡単ですね。

サブクラス側

どんどん行きます。

メール通知を行うサブクラス側では、メール出すだけ。これは Outlook を操作するクラスを使っています。

NotifyMail.psm1
class NotifyMail : NotifyBase {
    NotifyMail($p) : base($p) {}

    Send() {
        $msg = $this.GetHTMLMessage() -join("`n")
        $mailer = [OutlookApp]::New()
        $mailer.SendMail($this.Params.To, $this.Params.Cc, $this.Params.Subject,  $msg)
        $mailer.Quit()
    }
}

Outlook

Outlook(繰り返しますが Windows についてくる版ではないもの)はオブジェクトモデルのインタフェースが用意されているので楽ちんです。

検索すると Send-MailMessage Cmdlet でやる例がわんさか出てきますが、Send-MailMessage はすでに廃止されている(depreciated)上、今どき認証不要でつながる SMTP サーバもそうないと思われます。つまりそのままの例では動かないことがほとんど。-Credential でパスワードを渡すこともできなくはないですが、定期実行したい今回の例には向いていません。

ということで Outlook です。

メールの送信に最低限必要なのは SendMail() メソッドだけです。これもパラメータを設定して Send() するだけの簡単仕様。

若干ややこしいのは周辺です。COM オブジェクトを完全にメモリ解放するのは厄介という話がありますが、定期実行する都度 PowerShell ごと起動、終了するので問題はないハズ。

Outlook は基本的にはインストール、設定されているだけで常時起動していないケースも想定しています(自分がそうだから)。この場合、Outlookは自動起動するのでよいのですが、送信終了後にOutlookを終了するとまだ送信が終わっていないこともあります。なので、送信を待ちたい。その部分が若干面倒で、今回は送信済みフォルダに送信したメールが入ってくるかどうかで判断しています(メソッド WaitSendingItem)。遅い時だと10秒くらいかかるので…。他の部分が超簡単なのである種税金みたいなものとあきらめています。

Outlook を常時起動しているという環境なら送信後に Outlook を終了させる必要もない(というかしてはいけない)のでこの部分も要らなくなるのですがその部分は実装していません。

OutlookApp.psm1
class OutlookApp {
    $App;
    $OutlookIsRunning;
    $LastSendItem = $null;

    OutlookApp() {
        $this.OutlookIsRunning = Get-Process -Name OUTLOOK -ErrorAction ignore
        $this.App = New-Object -ComObject Outlook.Application
    }

    SendMail([string]$to, [string]$cc, [string]$subject, [string]$msg) {
        $item = $this.App.CreateItem(0) # 0 = MailItem
        $item.To = $to
        if ($cc) {
            $item.Cc = $cc
        }
        $item.Subject = $subject
        $item.BodyFormat = 1 # 1 = HTML
        $item.HTMLBody = $msg
        $this.LastSendItem = @{To=$to; Subject=$subject; SentOn=[DateTime]::Now}
        $item.Send()
    }

    Quit() {
        $this.WaitSendingItem()
        if (-not $this.OutlookIsRunning) {
            $this.App.Quit()
            Get-Process -Name OUTLOOK -ErrorAction ignore |Stop-Process
        }
    }

    WaitSendingItem() {
        if (-not $this.LastSendItem) { return }

        $sentItemsFolder = $this.App.GetNamespace("MAPI").GetDefaultFolder(5)  # Sent Items

        $cond = "[SentOn] >= '$($this.LastSendItem.SentOn.ToString('yyyy\/M\/d HH:mm'))' And [Subject] = '$($this.LastSendItem.Subject)' And [To] = '$($this.LastSendItem.To)'"
        write-host "cond=$cond"
        while (1) {
            $items = $sentItemsFolder.Items.Restrict($cond)
            write-host "item count=$($items.count)"
            if ($items.Count -gt 0) {
                break
            }
            start-sleep -s 2
        }
    }
}

完成まであと一歩

はいこれで(ほぼ)全部が完成です。実行するには、ファイルのあるフォルダで以下を実行するだけです。

pwsh .\Send-NewBookNotice.ps1 <リストのパス> <Rakuten API ID> <メールアドレス>

されにはこれを BAT ファイルにしましょう。

Run.bat
cd <フォルダ>
pwsh .\Send-NewBookNotice.ps1 <リストのパス> <Rakuten API ID> <メールアドレス>

cd は無くても問題ないですが、ミス防止の保険ですね。で、これをスケジュールタスクとして Windows に登録し、一日一回くらい起動すれば完成です。

おつかれさまでした。

最終版

コードはこちらに。ご笑納ください。

https://github.com/npwshy/Send-NewBookNotice

若干の変更点

GitHub にある版は実は若干変わっています。

たとえばここ。

Send-NewBookNotice.ps1
    #
    # CSV ファイルを更新
    #
    # まずはバックアップ
    $ext = Split-Path -Extension $BookList
    $basename = $BookList -replace "$ext$",''
    3 .. 0 |ForEach-Object {
        $n = $_;
        $n1 = $_ + 1;
        Move-Item -Path "$basename-$n$ext" -Destination "$basename-$n1$ext" -Force -ErrorAction SilentlyContinue
    }
    Move-Item -Path "$BookList" -Destination "$basename-0$ext" -Force -ErrorAction SilentlyContinue

元の記事のものは無造作に Move-Item していたので、拡張子が変わり古いファイルを開きたいときに問題がありました。これは拡張子を維持するように手を加えてあります。

Discussion