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

GitHub 風味の Qiita マイページを作ってみます。
Let's 芝生駆動投稿！

# ことの始まり

GitHub のプロフィールページといえば [芝生](https://www.google.co.jp/search?q=github+contributions&es_sm=91&source=lnms&tbm=isch&sa=X&ved=0CAcQ_AUoAWoVChMI7fjMns2DyQIVh12mCh2lqgu7&biw=1182&bih=741&dpr=2) でおなじみですね。
青々としていく芝生をみていると、不思議と Contribution に対するモチベーションも上がるものです。

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

# 完成イメージ

結論、こんな感じになりました。
そう、ほぼほぼ GitHub です。記事の投稿で草が生えます。

<img width="300" alt="capture.png" src="https://qiita-image-store.s3.amazonaws.com/0/77513/018cc654-8df2-8d2c-d290-29ef04797b45.png">

fake_qiita : https://fakeqiita.herokuapp.com/
（Qiita アカウントをお持ちの方は是非お試しください！）

ソースコードは GitHub で公開しています。
https://github.com/mserizawa/fake_qiita

# 主に使うもの

* [Qiita API](http://qiita.com/api/v2/docs)
    * version 2
    * データソースとして
* [Phoenix](http://www.phoenixframework.org/)
    * version 1.0.3
    * WAF として
* [AngularJS](https://angularjs.org/)
    * version 1.4.5
    * DOM のレンダリングを簡略化するため
* [Bootstrap](http://getbootstrap.com/)
    * version 3.3.5
    * CSS フレームワークとして
* [Cal-heatmap](http://cal-heatmap.com/)
    * version 3.3.10
    * 芝生として

# Qiita API と必要なデータを理解する

Qiita には素晴らしい REST API が用意されています。
[仕様](http://qiita.com/api/v2/docs)を見ると、ユーザ認証〜各種 CRUD 操作まで一通りできるみたいです。
また、Read については認証不要で、サクッとレスポンスを確認できます。こういうの凄く大事ですよね。

ちなみに、今回は前提として「ログイン不要」で使えるアプリを目指そうと思います。芝生の確認くらい、ログイン無しでサクっと済ませたいからです。

GitHub 風味のマイページを作るにあたり、必要なデータは以下の通りです。

| GitHub でいう | 対応するリソース | 利用する API |
|:-----------|:------------|:------------|
| 左ペイン全般 | [ユーザ](http://qiita.com/api/v2/docs#ユーザ) | *下記 1. 参照* |
| Pupular repositories | [投稿](http://qiita.com/api/v2/docs#投稿) | *下記 2. 参照* |
| Contributions | [投稿](http://qiita.com/api/v2/docs#投稿) | */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 という名前で作ります。

```bash
$ mix phoenix.new fake_qiita
```

依存関係を追加します。

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

```ex: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 します。

```bash
$ mix deps.get
```

# Qiita アプリケーションのセッティングをする

「前提として認証不要」と書きましたが、内部的にはアプリケーションの登録と認証をしておきます。というのも、認証ナシだと 60reqs/hour という制限が設けられるためです（認証アリだと 1,000reqs/hour ）

Qiita アプリケーションの登録は「設定」画面から行います。
細かな手順は割愛しますが、今回はユーザにログインさせるわけではないので、アプリ名称やリダイレクト先 URL はそれほどこだわらなくても大丈夫です。
作成が完了すると ClientID と ClientSecret が発行されます。
これらを使ってアクセストークンも発行します。やり方は[コチラ](http://qiita.com/api/v2/docs#認証認可)をご確認ください。

これら 3つ のキーが揃ったら、OAuth2 に組み込みましょう。（OAuth2 の使い方は[コチラ](http://qiita.com/mserizawa/items/32c6ff17a7cdd51b1975)）
今回使うストラテジはこんな感じです。

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

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

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

```ex: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 の使い方は[コチラ](http://qiita.com/mserizawa/items/41feec92de6fccbba01f)）
ユーザ情報の取得自体は `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 で別途取得するようにしました。

```ex: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 も同じです）
ただ、今回は返却をパースする必要があります。パイプライン演算子で返却された要素１つ１つをパースするのですが、折角なのでこの処理は[並列に進めています](http://qiita.com/mserizawa/items/96f9d14e5ba24ccc8078)。

パース処理の実装は `parse_entry/1` で、まずは HTML のスクレイピングをします。
投稿データに記事への URL が含まれていますので、ここにリクエストして HTML を取得します。
スクレイピングといっても特段構える必要はなく、取得した HTML を [Floki](https://github.com/philss/floki) に渡して、タグ名とクラス名を指定してセレクタを発動させれば終了です。Floki 初めて使いましたが、簡単でいいですね。（ちょっとレスポンスのデータ構造がややこしいかも？）
ちなみに、当たり前ですが DOM 構成が変わったら詰みます。スクレイピングのコワイところですね...

ストック数が取れたら、後はアプリで必要なプロパティを抽出して返却します。

# 草を生やす

さて、描画に必要なデータは全て揃いました。念願の芝生を作りましょう。
Cal-heatmap というライブラリはかなりの優等生で、データを食わせるだけでいい感じに芝生を作ってくれます。
以下の形式のオブジェクトを受け付けます。

```js
{
    "timestamp": value,
    "timestamp2": value2,
    ...
}
```

timestamp は草を生やす日付の UnixTimestamp 文字列で、value は草の濃さを示す数値です。
今回は、timestamp -> 記事の投稿日時、value -> 記事のストック数 でやってみます。
Angular.js と [moment.js](http://momentjs.com/) を使うと、こんな感じでいけます。

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