Edited at

HaskellからWordPressのREST APIを叩くメモ

この記事はメモ書きです。

どうも、自分のブログの脱WordPress化を画策しているmod_poppoです。

今日は、WordPressのREST APIを叩いてデータを取ってみたいと思います。APIのマニュアルはこの辺にあり、例えば

$ curl https://example.com/wp-json/wp/v2/posts | jq

とすると直近10件くらいの投稿が取得できます。


JSON

WordPressのREST APIのレスポンスはJSONで帰ってきます。これをいい感じにHaskellのデータ型にマップしたいです。

最初は自分でデータ型を定義して GenericFromJSON / ToJSON のインスタンスを定義しようとしたのですが、フィールド名のアレが面倒なのでやめました。代わりに extensible を使います。

extensible を使うと、投稿を表すデータ型が次のように定義できます。各フィールドにアクセスするには Lens と OverloadedLabels を使って post ^. #id という風にすれば良いようです。

type Post = Record '[ "date" >: LocalTime

, "date_gmt" >: LocalTime
, "guid" >: JSON.Object
, "id" >: Int
, "link" >: T.Text
, "modified" >: LocalTime
, "modified_gmt" >: LocalTime
, "slug" >: T.Text
, "status" >: T.Text
, "type" >: T.Text
, "title" >: JSON.Object
, "content" >: JSON.Object
, "author" >: Int
, "excerpt" >: JSON.Object
, "featured_media" >: Int
, "comment_status" >: T.Text -- one of "open", "closed"
, "ping_status" >: T.Text -- one of "open", "closed"
, "format" >: T.Text -- one of "standard", "aside", "chat", "gallery", "link", "image", "quote", "status", "video", "audio"
, "meta" >: JSON.Value
, "sticky" >: Bool
, "template" >: T.Text
, "categories" >: [Int]
, "tags" >: [Int]
]

ところで、TypeScriptでいうところのリテラル型のunion "open" | "closed" みたいなやつはHaskellでどう書いたらいいんでしょうか。もちろん自分でデータ型を定義すればいいんですけど、いちいち名前をつけるのが面倒です。わからん。


REST APIの定義

RESTのAPIを関数っぽい形で叩けると便利です。 GET /wp/v2/posts/123getPost 123 という具合です。

やり方は色々あると思いますが、ここでは Servant.Client を使ってみました。GET /wp/v2/postsGET /wp/v2/posts/:id の例は次のような感じになります:

type WordPressAPI = "wp" :> "v2" :> "posts" :> API.QueryParam "page" Int :> API.QueryParam "offset" Int :> API.Get '[JSON] [Post]

:<|> "wp" :> "v2" :> "posts" :> API.Capture "id" Int :> API.Get '[JSON] Post

wp_api :: Proxy WordPressAPI
wp_api = Proxy

getPosts :: Maybe Int -> Maybe Int -> ClientM [Post]
getPost :: Int -> ClientM Post
getPosts :<|> getPost = Client.client wp_api

エンドポイントが対応している QueryParam を全部書くと getPosts の引数が大量発生して大変そうです。 GetPostsParam みたいなデータ型にまとめられないんでしょうか。よくわかりません。

ちなみにクエリパラメーター等の仕様は

$ curl -X OPTIONS https://blog.miz-ar.info/wp-json/wp/v2/posts | jq

みたいな感じで取ってこれるようです。


コード全体

動くコードを載せておきます。依存するパッケージは base 以外だと


  • aeson

  • extensible

  • http-client

  • http-client-tls

  • lens

  • lens-aeson

  • servant

  • servant-client

  • text

  • time

あたりになります。

{-# LANGUAGE DataKinds #-}

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}

import Control.Lens
import Control.Monad
import qualified Data.Aeson as JSON
import Data.Aeson.Lens
import Data.Extensible
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Time (LocalTime, UTCTime)
import Network.HTTP.Client (newManager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Servant.API hiding (Post)
import qualified Servant.API as API
import Servant.Client as Client

type Post = Record '[ "date" >: LocalTime
, "date_gmt" >: LocalTime
, "guid" >: JSON.Object
, "id" >: Int
, "link" >: T.Text
, "modified" >: LocalTime
, "modified_gmt" >: LocalTime
, "slug" >: T.Text
, "status" >: T.Text
, "type" >: T.Text
, "title" >: JSON.Object
, "content" >: JSON.Object
, "author" >: Int
, "excerpt" >: JSON.Object
, "featured_media" >: Int
, "comment_status" >: T.Text -- one of "open", "closed"
, "ping_status" >: T.Text -- one of "open", "closed"
, "format" >: T.Text -- one of "standard", "aside", "chat", "gallery", "link", "image", "quote", "status", "video", "audio"
, "meta" >: JSON.Value
, "sticky" >: Bool
, "template" >: T.Text
, "categories" >: [Int]
, "tags" >: [Int]
]

type WordPressAPI = "wp" :> "v2" :> "posts" :> API.QueryParam "page" Int :> API.QueryParam "offset" Int :> API.Get '[JSON] [Post]
:<|> "wp" :> "v2" :> "posts" :> API.Capture "id" Int :> API.Get '[JSON] Post

wp_api :: Proxy WordPressAPI
wp_api = Proxy

getPosts :: Maybe Int -> Maybe Int -> ClientM [Post]
getPost :: Int -> ClientM Post
getPosts :<|> getPost = Client.client wp_api

main :: IO ()
main = do
manager <- newManager tlsManagerSettings
baseUrl <- Client.parseBaseUrl "https://example.com/wp-json/"
let clientEnv = Client.mkClientEnv manager baseUrl
result <- runClientM (getPosts Nothing Nothing) clientEnv
case result of
Left err -> print err
Right result -> forM_ result $ \post -> case post ^? #title . ix "rendered" . _String of
Just title -> T.putStrLn $ "[" <> T.pack (show (post ^. #date)) <> "] " <> title
Nothing -> T.putStrLn $ "[" <> T.pack (show (post ^. #date)) <> "]: title not available"

私のブログについて実行すると

[2019-10-12 19:14:03] Haskellのscan系関数を使いこなす

[2019-10-01 22:47:30] 技術書典7の振り返り
[2019-09-19 22:55:03] 技術書典7に、Haskellで競技プログラミングをやる本を出します
[2019-08-16 21:33:35] TeXにとってやばい入力ファイル名
[2019-07-15 18:50:05] AtCoderで青色になった
[2019-06-30 22:43:59] Haskellでの浮動小数点数の方向付き丸めを考える
[2019-05-27 21:17:37] HaskellでAtCoderに参戦して水色になった
[2019-04-24 20:57:59] 技術書典6の振り返り
[2019-04-11 20:39:13] 技術書典6に、ClutTeX(LaTeX文書処理自動化ツール)の本を出します
[2019-03-25 22:35:55] AtCoder Beginner Contest 122 の D の別解 (ABC122-D)

という風な結果が得られました。やったね。

デフォルトだと直近10件の記事ですが、 offset または page を変えるともっと古い記事も得られます。


その他

移行などの目的で自分の管理しているWordPressからデータを取ってくるには管理画面の「エクスポート」を使って WordPress eXtended Rss という形式のファイルを吐かせるという方法もあるようです。その場合はXMLのパースが必要になります。

追記:WXRをパースする話を書きました→ HaskellでWordPress eXtended RSS (WXR)ファイルをパースする