Kobo APIで続刊通知を作る:その1
無いようです? なら作りましょう
シリーズ物の作品の場合、最新刊が出たらプッシュで通知が欲しいわけですが、どうも楽天ブックスではそれがないようです。API もあることですし、作りましょう。
- PowerShellでやる
- v7系でやる
- クラスを使う
- 検索部分と通知部分は分離
という方針です。
今回は API コールできるところまでやっていきます。
途中経過は良いからコードを、というかたはこちら。でも API の ID は必要ですよ。
準備
API利用のIDを取る
楽天API の利用には ID が必要ですからまずは取得します。Rakuten Developpers のページから「アプリID発行」で OK。
ジャンル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 にします。また、型検査をきっちりやるためにもデータをクラス化しましょう。
class GenreInfo {
[string] $GenreId;
[string] $GenreName;
GenreInfo($h) {
$this.GenreId = $h.koboGenreId
$this.GenreName = $h.koboGenreName
}
}
Import-Clixml したデータは hashtable になるので、それをそのまま渡してインスタンスにします。呼び出す部分はこちら。とりあえずファイル名は決め打ちで。
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 小説・エッセイ
大丈夫のようですね。
最新刊を検索する
残念ながら最新刊だけを検索するということはできないようなのと、書籍タイトルもいろいろな形式があるので、
- 検索
- 除外しきれなかったものをさらに除外
- 発行日の最も新しいものを最新刊とみなす
ということでやります。
検索
書籍タイトルを先頭から一意になるように数文字、ジャンルを指定、必要に応じて絞り込みのためのキーワードを指定できるようにしておきましょう。
ジャンル名⇒ジャンルIDへ変換
ジャンル一覧はあるので、それを使ってジャンル名を ID に変換します。指定がない、指定されたものがない場合にはパラメータ指定を省略するので、APIへのパラメータとして変換結果が返ってくるようにします。
[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回ルールもあるので、スロットリング制御が必要です。
[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 で行きましょう。
[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 時間くらいで。
デバッグのためのメッセージを入れましょう(後で消す)。
[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