はじめに
先日SetupしたGROWIを使って支出管理簿をつけています。
我が家の支出はクレジットカード経由のものがほとんどで、かつメインで使っているダイナースクラブカードはクレジットカードの利用履歴をCSVファイルでダウンロードすることができます。
しかしながら、この記事を最初に書いた時点(2020年4月)では利用履歴から手動でGROWIの支出管理簿に転記している状況です。
そこで、CSVファイルに記載されているデータのうちの一部を抜き出して、GROWIの指定した記事に反映させることを最終的な目標としつつ、まずはGROWIの指定した記事をGROWIにログインすることなく編集するための方法を検討することにしました。
GROWIの中の記事を編集する方法の検討
GROWIは今どきのWikiなので、RESTっぽいWebAPIくらい普通にあるだろう常識的に考えて…
ということで、Google先生にお伺いを立ててみたり、GitHubでググってみると、以下の2つが記事の読み出し及び更新用に使えるようです(GROWIは他にもWebAPIが大量にあるようですので、いろいろな操作ができそうです)。
- /_api/pages.get: 記事の読み出し用のAPI
- /_api/pages.update: 記事の更新用のAPI。ただし、新規の記事の追加には使えないので、その場合には別のAPIを使用する。
他にもGROWIにはCROWI由来のWebAPIがありますが、その一覧及びコードについては以下のURLにあるようです(この記事を最初に書いた時点(2020年4月)の情報です)。
- 一覧: https://github.com/weseek/growi/blob/master/src/server/routes/index.js
- コード: https://github.com/weseek/growi/blob/master/src/server/routes/page.js
Scalaでのプログラムができるかどうかの検討
HTTPクライアント
支出管理簿はもともとMediaWikiで管理していたもので、MediaWikiのための表を作るためのツールをScalaで実装していました。
そのツールのコードを転用して、ダイナースクラブカードのWebサイトからダウンロードできるCSVファイルをGROWIの表っぽく整形して出力するツールをScalaで実装したので、この勢いで、GROWIとの読み書きもScalaで実装することにします。
Scalaで使えるHTTPクライアントとその実装例についてはこちらのサイトがサンプルコード込みで良くまとまっているので、それを参考にしつつ、最初はAkkaを試してみました。
しかし、Akkaはサーバ用に使うには良いものの、クライアント用に使うにはちょっと大掛かりすぎると感じたことと、クライアントの終了時に”Outgoing request stream error”というエラーが出るのがいまいち気持ち悪く感じました(※個人の感想です)。
そこで、クライアント用のコードがサクッと書けそうなscalaj-httpを使うことにしました。
JSONのライブラリ
GROWIとの間での記事の読み書きはJSONで行われますので、Scala用のJSONのライブラリを探す必要があります。
スポンサーリンク
最初はscala.util.parsing.jsonパッケージをしれっと使っていましたが、実はdeprecatedになっていることがわかったので、play-jsonライブラリを使用することにしました。play-jsonパッケージはPlay Frameworkの一部ですが、単独で使用できます。
play-jsonパッケージが提供しているJSONのオブジェクト操作用のクラスでは、バックスラッシュを演算子として使うことができますので、
というような感じでオブジェクト内の変数名(name)をキーにオブジェクト内を検索し、対応する値(value)を取得できます。
よって、XMLやJSONのパース処理でありがちな、あるオブジェクトの子要素を順番に確認して目的の変数名やオブジェクトを取り出す… というような処理の実装が不要になるので、JSONのオブジェクトをお手軽に扱うことができます。
作ってみた。
GROWIのAPI Tokenの取得
GROWIのWeb APIを使用するためには、以下の手順でAPI Tokenを取得する必要があります。
- GROWIにログインします。
- メニューバーのアカウント名が表示されている部分をクリックします。
- メニューが表示されますので、「ユーザ設定」(下図の赤矢印)をクリックします。
- 画面上部の「API設定」タブ(下図の赤矢印)をクリックします。
- 「API Token設定」画面の「API Tokenを更新」ボタン(下図の赤矢印)をクリックします。
- API Tokenが生成されます。末尾の”=”は割と大事です(後述)。
プログラムを書きます。
以下のようなプログラムを書いてみました。なお、前半部分はこちらのサイトのサンプルコードを大幅に参考にさせていただきつつ、不要な部分を省略しています。
package info.pandanote.scalajhttptest | |
// See https://pandanote.info/?p=6186 for details. | |
import java.net.{ URLEncoder, URLDecoder } | |
import scalaj.http._ | |
import play.api.libs.json._; | |
case class CustomResponse(statusCode: Int, status: String, body: String) | |
class ScalajHttpClientSample { | |
val readTimeoutMillis = 5000 | |
var connectTimeoutMillis = 5000 | |
def Get(url: String, headers: Map[String, String]): CustomResponse = httpRequest("GET", url, headers) | |
def Post(url: String, headers: Map[String, String], requestBody: String): CustomResponse = httpRequest("POST", url, headers, requestBody) | |
def Put(url: String, headers: Map[String, String], requestBody: String): CustomResponse = httpRequest("PUT", url, headers, requestBody) | |
def Delete(url: String, headers: Map[String, String]): CustomResponse = httpRequest("DELETE", url, headers) | |
def httpRequest(webMethod: String, url: String, headers: Map[String, String], requestBody: String = ""): CustomResponse = { | |
var request = Http(url) | |
.headers(headers) | |
.timeout(connTimeoutMs = connectTimeoutMillis, readTimeoutMs = readTimeoutMillis) | |
if (requestBody != "") { | |
request = request.postData(requestBody) | |
} | |
request = request.method(webMethod) | |
val response: HttpResponse[String] = request.execute() | |
return extractResponse(response) | |
} | |
def extractResponse(res: HttpResponse[String]): CustomResponse = { | |
val statusCode = res.code | |
val httpStatus = statusCode match { | |
case 200 => "OK" | |
case 201 => "CREATED" | |
case 204 => "NO_CONTENT" | |
case 401 => "UNAUTHORIZED" | |
case 400 => "BAD_REQUEST" | |
case 404 => "NOT_FOUND" | |
case 403 => "FORBIDDEN" | |
case 500 => "INTERNAL_SERVER_ERROR" | |
case _ => "OTHER_STATUS_CODE" | |
} | |
val body = res.body | |
return CustomResponse(statusCode, httpStatus, body) | |
} | |
} | |
object Main { | |
def siteurl = "https://wiki.example.org" // <-- Change here. | |
def main(args: Array[String]) = { | |
val client: ScalajHttpClientSample = new ScalajHttpClientSample | |
val token = "Token" // <-- Change here. | |
val r = client.Get(siteurl+"/_api/pages.get?access_token="+URLEncoder.encode(token,"UTF-8")+"&path=/"+URLEncoder.encode("テストページ","UTF-8"), Map.empty[String,String]) | |
val rr = r.body | |
val r3: JsValue = Json.parse(rr) | |
val page_id = r3 \ "page" \ "_id" | |
println(page_id) | |
val revision_id = r3 \ "page" \ "revision" \ "_id" | |
println(revision_id) | |
val body = r3 \ "page" \ "revision" \ "body" | |
println(body) | |
val param: Map[String,String] = Map("access_token" -> token, "body" -> (body.as[String]+"\n |
|
val message = Json.toJson(param) | |
val p = client.Post(siteurl+"/_api/pages.update",Map("Content-Type" -> "application/json"),Json.stringify(message)) | |
println(p) | |
println(message) | |
} | |
} |
プログラムの概要と注意が必要と思われる点は以下の通りです。
- 62行目で編集の対象となる記事を取得するためのAPIを実行し、63行目でその結果得られた記事(markdownフォーマット)を取り出しています。APIのレスポンスはURLエンコードされていることがあるようなので、そのような場合には63行目を以下のコードで置き換えてデコードします。
val rr = URLDecoder.decode(r.body,"UTF-8")
- 修正した記事をサーバにPOSTするためのAPIを実行する際には、修正の対象となる記事を特定するためにpage_id及びrevision_idを指定する必要があります。指定するpage_id及びrevision_idは前項のAPIの実行結果に含まれていますので、抽出しておきます(65行目及び67行目)。
- 修正した記事をサーバにPOSTするためのAPIを実行する際には、bodyはURLエンコード等は不要で、そのまま指定できます。また、上記のプログラム例では
のコマンドを追加していますが、 のコマンドでよく使用されているバックスラッシュはエスケープする必要があります(71行目)。
動作確認
プログラムが書き終わったら動作確認を行います。
以下の記事に前節のプログラムを用いて
プログラムを実行します。
プログラムの実行がエラーなく完了できたことを確認しつつ、上記の記事をリロードしてみます。
指数関数ですね。
まとめ
API Tokenの末尾の”=”は割と大事です。これをパラメータの指定のときに忘れてしまう(“access_token”に対応する値の”8Xw”の直後の部分です。なおAPI tokenはテスト用のものです。)と、サーバから”403 Forbidden”が返されてしまい(下図)、小一時間原因がわからず悩むことになります(経験者談)。
ここまでの作業でGROWIの記事の更新がプログラムからできるようになりましたので、CSVファイルでダウンロードが可能なクレジットカードの利用履歴を取り込むことができそうです。
この記事は以上です。