QiitaAPI
Elixir
Phoenix

Qiita API を使って GitHub 風味のマイページを作ってみる

More than 3 years have passed since last update.

GitHub 風味の Qiita マイページを作ってみます。

Let's 芝生駆動投稿!


ことの始まり

GitHub のプロフィールページといえば 芝生 でおなじみですね。

青々としていく芝生をみていると、不思議と Contribution に対するモチベーションも上がるものです。

「この芝生が Qiita にもあれば、もっともっと記事を投稿するに違いない」と漠然に思ったのがことの始まり。


完成イメージ

結論、こんな感じになりました。

そう、ほぼほぼ GitHub です。記事の投稿で草が生えます。

capture.png

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

依存関係を追加します。


mix.exs

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 用のサービスワーカを登録します。


fake_qiita.ex

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 の使い方はコチラ

今回使うストラテジはこんな感じです。


qiita.ex

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 も直接作ってしまいます。


ユーザ情報を問い合わせる

さて、ストラテジもできたところで、早速ユーザ情報を問い合わせてみましょう。


page_controller.ex

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 で別途取得するようにしました。


page_controller

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 の知識がまんべんなく使えて良かった