📚

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

2024/06/13に公開

無いようです? なら作りましょう

シリーズ物の作品の場合、最新刊が出たらプッシュで通知が欲しいわけですが、どうも楽天ブックスではそれがないようです。API もあることですし、作りましょう。

  • PowerShellでやる
  • v7系でやる
  • クラスを使う
  • 検索部分と通知部分は分離

という方針です。

今回は API コールできるところまでやっていきます。

途中経過は良いからコードを、というかたはこちら。でも API の ID は必要ですよ。
https://github.com/npwshy/Send-NewBookNotice

準備

API利用のIDを取る

楽天API の利用には ID が必要ですからまずは取得します。Rakuten Developpers のページから「アプリID発行」で OK。

https://webservice.rakuten.co.jp/

ジャンルIDを取得

API 利用のテストも兼ねてジャンル ID を取得しておきます。<ID> 部分は取得したIDを入れます。

$res = Invoke-WebRequest 'https://app.rakuten.co.jp/services/api/Kobo/GenreSearch/20131010?applicationId=<ID>&koboGenreId=101'
$json = $res.Content |ConvertFrom-Json -AsHashtable
$json.children.child |Export-Clixml genre.xml

これでジャンル一覧がファイル genre.xml に書き出されました。

テスト

このデータを使う際には Import-Clixml で。

$genre = Import-Clixml genre.xml
$genre[0]

Name                           Value
----                           -----
koboGenreName                  小説・エッセイ
genreLevel                     2
koboGenreId                    101901

できてますね。

ジャンルデータをロードするコード

先ほどのデータファイルをロードする部分を作っていきます。再利用する(かもしれない)のでファイルは .psm1 にします。また、型検査をきっちりやるためにもデータをクラス化しましょう。

KoboAPI.psm1
class GenreInfo {
    [string] $GenreId;
    [string] $GenreName;

    GenreInfo($h) {
        $this.GenreId = $h.koboGenreId
        $this.GenreName = $h.koboGenreName
    }
}

Import-Clixml したデータは hashtable になるので、それをそのまま渡してインスタンスにします。呼び出す部分はこちら。とりあえずファイル名は決め打ちで。

KoboAPI.psm1
class KoboAPI {
    [GenreInfo[]] $GenreInfo;

    LoadGenre() {
        $fp = "genre.xml"
        $this.GenreInfo = Import-Clixml $fp |ForEach-Object { [GenreInfo]::New($_) }
    }
}

テスト

早速テストしましょう。

using module .\KoboAPI.psm1
$api = [KoboAPI]::New()
$api.LoadGenre()
$api.GenreInfo[0]

GenreId GenreName
------- ---------
101901  小説・エッセイ

大丈夫のようですね。

最新刊を検索する

残念ながら最新刊だけを検索するということはできないようなのと、書籍タイトルもいろいろな形式があるので、

  1. 検索
  2. 除外しきれなかったものをさらに除外
  3. 発行日の最も新しいものを最新刊とみなす

ということでやります。

検索

書籍タイトルを先頭から一意になるように数文字、ジャンルを指定、必要に応じて絞り込みのためのキーワードを指定できるようにしておきましょう。

ジャンル名⇒ジャンルIDへ変換

ジャンル一覧はあるので、それを使ってジャンル名を ID に変換します。指定がない、指定されたものがない場合にはパラメータ指定を省略するので、APIへのパラメータとして変換結果が返ってくるようにします。

KoboAPI.psm1
    [string] GetGenreIdParam([string]$genre) {
        if (-not $genre) {
            return ""
        } elseif ($genre -match '^\d') {
            return  "koboGenreId=$genre"
        } else {
            $g = $this.GenreInfo |Where-Object { $_.GenreName -match $genre } |Select-Object -First 1
            return $g ? "koboGenreId=$($g.GenreId)" : ""
        }
    }

テスト

入力と出力を区別するために入力部分のまえに > をつけています。

>using module .\KoboAPI.psm1
>$api = [KoboAPI]::New()
>$api.loadGenre()
>$api.GetGenreIdParam("小説")
koboGenreId=101901
>$api.GetGenreIdParam("コミック")
koboGenreId=101904
>$api.GetGenreIdParam("")

>$api.GetGenreIdParam("ジャンル")

OKのようですね。

APIアクセス:スロットリング

API コールは1秒1回ルールもあるので、スロットリング制御が必要です。

KoboAPI.psm1
    [DateTime] $LastAPICall = 0;
    [int] $APICallInternval = 1000; # milliseconds


    Throttling() {
        $waitms = $this.APICallInternval - (([DateTime]::Now - $this.LastAPICall).TotalMilliseconds)
        if ($waitms -gt 0) {
            write-host "sleeping $waitms [ms]"
            Start-Sleep -Milliseconds $waitms
        }
    }

API をコールしたらその時刻を覚えておき、次のコールが適切な間隔(1秒)になるように時間待ちをします。Start-Sleep したかどうかわかりにくいのでデバッグ用にメッセージを出します(後で消す)。

テスト

> using module .\KoboAPI.psm1
> $api = [KoboAPI]::New()
> $api.Throttling()
> $api.Throttling()
> $api.LastAPICall=[datetime]::now; $api.Throttling()
sleeping 998.7414 [ms]

LastAPICall の値は初期値 0 のまま更新されませんのでそのままでは待ち処理をしません。LastAPICall を設定した直後に呼び出すとほぼ1秒待ちます。OKですね。

結果キャッシュ

API コールの結果はそんなに頻繁に変わらないはずですし、もろもろのテストで何度も API コールをするのも気が引けます。ということで API コールの結果をファイルにキャッシュします。本来ならこのキャッシュ処理部分はさらに別クラスに実装するのですが、本題からどんどん離れてしまうので割り切って API クラスの中に入れてしまいます。

まずはキャッシュするファイル名の決定。同じURLアクセスが同じファイル名になることと、異なるURLは異なるファイル名になること、という必要性からハッシュ関数で。キャッシュファイルを入れておくディレクトリも別にしておきます。名前? とりあえず決め打ちで .\cache で行きましょう。

KoboAPI.psm1
    [string] $CacheDir = ".\cache"
    $HashFunc = (New-Object System.Security.Cryptography.SHA256CryptoServiceProvider)

    [string] GetCacheFilename($url) {
        $u = [URI]$url
        $hashcode = ($this.HashFunc.ComputeHash([Text.Encoding]::UTF8.GetBytes($url)) |ForEach-Object { $_.ToString("x2") }) -join('')
        return Join-Path $this.CacheDir ($u.host + "_" + $hashcode)
    }

テスト

> $api.GetCacheFilename("https://zen.dev/1")
.\cache\zen.dev_bcb15eaf2728998b2d0bd0b36f6dcb3c4ef7f5726199bc711ea9547653809e9c
> $api.GetCacheFilename("https://zen.dev/2")
.\cache\zen.dev_9275f8801e5aca9b94cec745bb1e9b0693ed5fceaee19e4486d576baeecd438e

大丈夫ですね。

APIアクセス

実際に API をコールする部分を作ります。いつまでもキャッシュファイルを参照するのもダメですから消費期限を設定します。とりあえず 3 時間くらいで。

デバッグのためのメッセージを入れましょう(後で消す)。

KoboAPI.psm1
    [DateTime] $Expire = [DateTime]::Now.AddHours(-3)

    [hashtable] APIGet($url) {
        $fp = $this.GetCacheFilename($url)
        if (Test-Path $fp) {
            if ((Get-Item $fp).LastWriteTime -gt $this.Expire) {
                #--- キャッシュファイルがあり、新しい
                write-host "キャッシュ利用: $fp"
                return (Get-Content $fp) |ConvertFrom-Json -AsHashtable
            }        
        }  
               
        #--- キャッシュがない、あるいは古い
        $this.Throttling()
        $res = Invoke-WebRequest $url -Method Get
        $this.LastAPICall = [DateTime]::Now
            
        if ($res.StatusCode -eq 200) {
            $res.Content |Out-File $fp -Encoding utf8
            return $res.Content |ConvertFrom-Json -AsHashtable
        }

        return $null
    }

テスト

せっかくですので、ジャンルデータを取ってきましょう。

> using module .\KoboAPI.psm1
> $api = [KoboAPI]::New()
> $api.APIGet("https://app.rakuten.co.jp/services/api/Kobo/GenreSearch/20131010?applicationId=<ID>&koboGenreId=101")

Name                           Value
----                           -----
children                       {System.Management.Automation.OrderedHashtable, System.Management.Automation.OrderedHas…
current                        {[genreLevel, 1], [koboGenreId, 101], [koboGenreName, 電子書籍]}
parents                        {}

> dir .\cache\ |ft name

Name
----
app.rakuten.co.jp_1de1b971cedf690ec50976c34bd70bd5d317764964066499c3bfffa32562c737

OKですね。

その2に続きます。

Discussion