この記事はメモ書きです。
どうも、自分のブログの脱WordPress化を画策しているmod_poppoです。
今日は、WordPressのREST APIを叩いてデータを取ってみたいと思います。APIのマニュアルはこの辺にあり、例えば
$ curl https://example.com/wp-json/wp/v2/posts | jq
とすると直近10件くらいの投稿が取得できます。
JSON
WordPressのREST APIのレスポンスはJSONで帰ってきます。これをいい感じにHaskellのデータ型にマップしたいです。
最初は自分でデータ型を定義して Generic
で FromJSON
/ 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/123
が getPost 123
という具合です。
やり方は色々あると思いますが、ここでは Servant.Client
を使ってみました。GET /wp/v2/posts
と GET /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)ファイルをパースする