GitHub 風味の Qiita マイページを作ってみます。
Let's 芝生駆動投稿!
ことの始まり
GitHub のプロフィールページといえば 芝生 でおなじみですね。
青々としていく芝生をみていると、不思議と Contribution に対するモチベーションも上がるものです。
「この芝生が Qiita にもあれば、もっともっと記事を投稿するに違いない」と漠然に思ったのがことの始まり。
完成イメージ
結論、こんな感じになりました。
そう、ほぼほぼ GitHub です。記事の投稿で草が生えます。
fake_qiita : https://fakeqiita.herokuapp.com/
(Qiita アカウントをお持ちの方は是非お試しください!)
ソースコードは GitHub で公開しています。
https://github.com/mserizawa/fake_qiita
主に使うもの
-
Qiita API
- version 2
- データソースとして
-
Phoenix
- version 1.0.3
- WAF として
-
AngularJS
- version 1.4.5
- DOM のレンダリングを簡略化するため
-
Bootstrap
- version 3.3.5
- CSS フレームワークとして
-
Cal-heatmap
- version 3.3.10
- 芝生として
Qiita API と必要なデータを理解する
Qiita には素晴らしい REST API が用意されています。
仕様を見ると、ユーザ認証〜各種 CRUD 操作まで一通りできるみたいです。
また、Read については認証不要で、サクッとレスポンスを確認できます。こういうの凄く大事ですよね。
ちなみに、今回は前提として「ログイン不要」で使えるアプリを目指そうと思います。芝生の確認くらい、ログイン無しでサクっと済ませたいからです。
GitHub 風味のマイページを作るにあたり、必要なデータは以下の通りです。
GitHub でいう | 対応するリソース | 利用する API |
---|---|---|
左ペイン全般 | ユーザ | 下記 1. 参照 |
Pupular repositories | 投稿 | 下記 2. 参照 |
Contributions | 投稿 | /api/v2/items |
1. ユーザ情報について
http://somewhere/{{user_id}}
のような URI を想定しているのですが、Qiita API にはユーザ ID を指定してユーザ情報を取得する API が用意されていませんでした。
ログインユーザの情報は取れるのですが、それは前提に反するので NG。
仕方ないので、「/api/v2/items に一度問い合わせて、そこに付随されているユーザ情報を使う」という手段を取ります。(つまり、未投稿のユーザについては参照不可...無念)
2. 人気の投稿について
Qiita API が返却する投稿データにはストック数が含まれていませんでした。
/api/v2/items/:item_id/stockers を使っても良いのですが、そうすると記事単位で API を叩くことになり、リクエスト制限に引っかかりそうなので NG。
仕方ないので、「投稿記事の HTML をスクレイピングしてストック数を抽出する」という手段を取ります。
Phoenix アプリケーションをセットアップする
それではアプリケーションを作っていきましょう。
Fake Qiita という名前で作ります。
$ mix phoenix.new fake_qiita
依存関係を追加します。
defmodule FakeQiita.Mixfile do
use Mix.Project
...
def application do
[mod: {FakeQiita, []},
applications: [:phoenix, :phoenix_html, :cowboy, :logger,
:phoenix_ecto, :postgrex,
# oauth2 と con_cache を登録
:oauth2, :con_cache]]
end
...
defp deps do
[{:phoenix, "~> 1.0.3"},
{:phoenix_ecto, "~> 1.1"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.1"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:cowboy, "~> 1.0"},
{:oauth2, "~> 0.3"}, # Qiita API の認証用
{:con_cache, "~> 0.9.0"}, # API 返却値の一時的なキャッシュ用
{:floki, "~> 0.6.1"}] # HTML のパース用
end
...
end
con_cache 用のサービスワーカを登録します。
defmodule FakeQiita do
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
...
worker(ConCache, [
[
ttl_check: :timer.minutes(1),
# キャッシュの寿命は 30分 にしておきます
ttl: :timer.minutes(30)
],
[
name: :qiita_cache
]
])
]
...
end
...
end
最後に依存関係を DL します。
$ mix deps.get
Qiita アプリケーションのセッティングをする
「前提として認証不要」と書きましたが、内部的にはアプリケーションの登録と認証をしておきます。というのも、認証ナシだと 60reqs/hour という制限が設けられるためです(認証アリだと 1,000reqs/hour )
Qiita アプリケーションの登録は「設定」画面から行います。
細かな手順は割愛しますが、今回はユーザにログインさせるわけではないので、アプリ名称やリダイレクト先 URL はそれほどこだわらなくても大丈夫です。
作成が完了すると ClientID と ClientSecret が発行されます。
これらを使ってアクセストークンも発行します。やり方はコチラをご確認ください。
これら 3つ のキーが揃ったら、OAuth2 に組み込みましょう。(OAuth2 の使い方はコチラ)
今回使うストラテジはこんな感じです。
defmodule FakeQiita.Qiita do
use OAuth2.Strategy
def client do
OAuth2.Client.new([
strategy: __MODULE__,
client_id: System.get_env("CLIENT_ID"),
client_secret: System.get_env("CLIENT_SECRET"),
site: "http://qiita.com/api/v2"
])
end
def access_token do
OAuth2.AccessToken.new(System.get_env("ACCESS_TOKEN"), client())
end
end
各種キーは環境変数から取るようにしています。
ユーザにログインさせないため、Client は最低限の情報のみで OK です。AccessToken も直接作ってしまいます。
ユーザ情報を問い合わせる
さて、ストラテジもできたところで、早速ユーザ情報を問い合わせてみましょう。
defmodule FakeQiita.PageController do
use FakeQiita.Web, :controller
...
def select_user(conn, %{"user_id" => user_id}) do
user = ConCache.get_or_store(:qiita_cache, "#{user_id}_user", fn() ->
request_user(user_id)
end)
case user do
{:ok, value} ->
render conn, "user.html", user: value
{:not_found, []} ->
render conn, "404.html"
{:server_error, []} ->
render conn, "500.html"
end
end
...
defp request_user(user_id) do
token = FakeQiita.Qiita.access_token()
result = OAuth2.AccessToken.get!(token, "/items?per_page=1&query=user:#{user_id}")
case result do
%{status_code: 200, body: [item | _]} ->
{:ok, item["user"]}
%{status_code: 200, body: []} ->
{:not_found, []}
_ ->
{:server_error, []}
end
end
...
end
select_user/2
が入り口で、/{{user_id}}
でここにルーティングされるように設定してあります。
まずは ConCache の get_or_store/3
で、キャッシュの取得及び作成をしています。(ConCache の使い方はコチラ)
ユーザ情報の取得自体は request_user/1
です。この中でやっていることは単純で、OAuth2 を使って /api/v2/items
に GET リクエストをし、その返却に応じたレスポンスの生成をします。
{status_code: 200, body: [item | _]}
は「status_code が 200 で、かつ body のリストが1つ以上の要素を持つ」場合に引っかかるガードです。パターンマッチングと束縛を使った、いかにも Elixir っぽい書き方ですね。
item には投稿データが入ってくるので、そこにある user 要素のみを返却します。
%{status_code: 200, body: []}
は存在しないユーザ ID、もしくは未投稿のユーザ ID が指定された場合に引っかかります。これらは 404 扱いにしてしまいます。
この2つのガードに引っかからないものは全て 500 扱いにします。多分リクエスト制限にひっかかったらここに来ると思います。
ユーザの投稿一覧を問い合わせる
次にユーザの投稿一覧を問い合わせます。
これはユーザ情報の取得と同時にやってもよかったのですが、スクレイピングの都合上意外と時間がかかるので、Ajax で別途取得するようにしました。
defmodule FakeQiita.PageController do
use FakeQiita.Web, :controller
...
def select_entries(conn, %{"user_id" => user_id}) do
entries = ConCache.get_or_store(:qiita_cache, "#{user_id}_entries", fn() ->
request_entries([], user_id, 1)
end)
json conn, entries
end
...
defp parse_entry(entry) do
url = entry["url"]
%{body: html} = HTTPoison.get!(url)
results = Floki.find(html, "span.js-stocksCount")
{_, _, [stocks_string]} = List.first(results)
{stocks, _} = Integer.parse(stocks_string)
%{
"created_at" => entry["created_at"],
"url" => url,
"tags" => entry["tags"],
"title" => entry["title"],
"stock_count" => stocks
}
end
defp request_entries(entries, _user_id, page) when length(entries) < (page - 1) * 100 do
entries
end
defp request_entries(entries, user_id, page) do
token = FakeQiita.Qiita.access_token()
result = OAuth2.AccessToken.get!(token, "/items?page=#{page}&per_page=100&query=user:#{user_id}")
case result do
%{status_code: 200, body: body} ->
parsed = body
|> Enum.map(&Task.async(fn -> parse_entry(&1) end))
|> Enum.map(&Task.await(&1, 5_000))
request_entries(entries ++ parsed, user_id, page + 1)
_ ->
nil
end
end
end
入り口は select_entries/2
で /{{user_id}}/entries.json
でルーティングされるように設定してあります。
こちらもユーザ情報と同様にまずはキャッシュの有無の確認で、投稿一覧自体は request_entries/3
で行います。
Qiita API で一度に取得できる要素数は 100 なので、投稿を全て取得するためにはページングの処理が必要になり得ます。また、ユーザがいくつの投稿を持つかもわからないので、「返却された要素数がページ数 x 100 を下回るまでページングする」という手段を取ります。
こういうときに役に立つのが「再帰」で、上記の離脱タイミングを制御するガードは length(entries) < (page - 1) * 100
と表現できます。
リクエスト方法自体はユーザ情報と変わりません。(API も同じです)
ただ、今回は返却をパースする必要があります。パイプライン演算子で返却された要素1つ1つをパースするのですが、折角なのでこの処理は並列に進めています。
パース処理の実装は parse_entry/1
で、まずは HTML のスクレイピングをします。
投稿データに記事への URL が含まれていますので、ここにリクエストして HTML を取得します。
スクレイピングといっても特段構える必要はなく、取得した HTML を Floki に渡して、タグ名とクラス名を指定してセレクタを発動させれば終了です。Floki 初めて使いましたが、簡単でいいですね。(ちょっとレスポンスのデータ構造がややこしいかも?)
ちなみに、当たり前ですが DOM 構成が変わったら詰みます。スクレイピングのコワイところですね...
ストック数が取れたら、後はアプリで必要なプロパティを抽出して返却します。
草を生やす
さて、描画に必要なデータは全て揃いました。念願の芝生を作りましょう。
Cal-heatmap というライブラリはかなりの優等生で、データを食わせるだけでいい感じに芝生を作ってくれます。
以下の形式のオブジェクトを受け付けます。
{
"timestamp": value,
"timestamp2": value2,
...
}
timestamp は草を生やす日付の UnixTimestamp 文字列で、value は草の濃さを示す数値です。
今回は、timestamp -> 記事の投稿日時、value -> 記事のストック数 でやってみます。
Angular.js と moment.js を使うと、こんな感じでいけます。
$http.get("/" + userId + "/entries.json").success(function(dt) {
var calData = {};
dt.forEach(function(entry) {
var seconds = String(Math.floor(moment(entry.created_at, "YYYY-MM-DD'T'HH:mm:ssZ").unix()));
calData[seconds] = entry.stock_count;
});
var cal = new CalHeatMap();
cal.init({
start: moment().add(-1, "year").add(1, "month").toDate(),
data: calData,
domain: "month",
cellSize: 9,
legendHorizontalPosition: "right"
});
});
それから
後は用意したデータを表示していくのみです。
モクモクと HTML タグを打って、CSS を書いて、Angular.js でバインディングさせていけば、あっという間に GitHub ライクな画面が出来上がると思います(雑)
GitHub が Bootstrap を使っているかどうは知りませんが、デザイン・コンポートネントがかなり似ていますので、デフォルトままでも結構それっぽくなりました。
ちなみに、GitHub には Longest streak と Current streak という、それぞれ「最長継続日数」と「現在の継続日数」を表す項目があって、これも忠実に再現してみたのですが、思いの外計算が大変でした。
ロジックを組み上げるチカラは普段から養わないとダメですね...
あと、期間を指定して活動の一覧を出す機能もあったのですが、これは Angular.js のおかげでサクッと作れました。双方向データバインディング最高ですね。
感想
- これでモチベーション高く Qiita に記事を投稿していけそう
- 今まで培ってきた Elixir の知識がまんべんなく使えて良かった