Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
87
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

Organization

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

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 の知識がまんべんなく使えて良かった
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
87
Help us understand the problem. What are the problem?