Livebook と Exploler で Qiita の記事データを解析する

## はじめに

:::note info
2022/12/12 更新

最新版のモジュールを使うように更新しました
:::

どれくらい Qiita の記事がアクセスされたのか、どういう記事の評価が高いのか知りたい！

今回は Elixir Livebook と Explorer 、そして Qiita API を利用して Qiita の投稿記事を分析します

https://livebook.dev/

https://github.com/elixir-nx/explorer

https://qiita.com/api/v2/docs

ちなみに、 Livebook は以下のリンクから簡単に macOS 、 Windows にインストールすることができます

https://livebook.dev/#install

Livebook は Jupyter と同じように可視化、コードの共有ができ、
Explorer は pandas と同じようにデータフレームを操作できます

是非、これを機に Livebook でデータ分析をしてみてください

実装したノートブックの全文はこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/qiita/my_articles.livemd

## 実行環境

以下のリポジトリーの Docker コンテナ上で実行しています

Docker さえあれば簡単に実行できるので是非やってみましょう

https://github.com/RyoWakabayashi/elixir-learning

## アクセストークンの取得

データ分析を実行する前に、 Qiita API で認証するためのアクセストークンを準備する必要があります

Qiita にサインインし、以下の操作を行なってください

- Qiita の右上、ログインユーザーのアイコンをクリック
- 「設定」をクリック

![スクリーンショット 2022-11-22 23.47.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f5138970-e475-c453-f47a-21cfcecf1111.png)

左メニューの「アプリケーション」をクリック

![スクリーンショット 2022-11-22 23.48.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c019421f-14c5-8899-8a82-7c45ae91a1e5.png)

個人用アクセストークンの右「新しくトークンを発行する」をクリック

![スクリーンショット 2022-11-22 23.51.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bd593c9c-94cd-0e21-fb01-37d554cb6228.png)

- 「アクセストークンの説明」に適当な値を入力
- スコープの「read_qiita」にチェック
- 「発行する」をクリック

![スクリーンショット 2022-11-22 23.52.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c50546c8-2b21-97ef-59bd-90a5feffd832.png)

アクセストークンが表示されるので、このページを表示したままにしておくか、どこか安全な場所にメモしてください

![スクリーンショット 2022-11-22 23.56.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/985425f3-d27f-7f66-ccd8-44432c0963ce.png)

## セットアップ

Livebook から新規ノートブックを開きます

![スクリーンショット 2022-11-23 23.48.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f4ba2a5e-8ad0-78f8-6b20-4a8de1eedb7a.png)

以下のコードをセルに入力し、依存パッケージのインストールを実行します

```elixir
Mix.install([
  {:httpoison, "~> 1.8"},
  {:json, "~> 1.4"},
  {:explorer, "~> 0.4"},
  {:kino, "~> 0.8"},
  {:kino_vega_lite, "~> 0.1"}
])
```

トークンの入力エリアを表示します

```elixir
# Qiita のアクセストークンを入力する
token_input = Kino.Input.password("TOKEN")
```

表示された入力エリアに先ほど準備した Qiita のアクセストークンを入力します

![スクリーンショット 2022-11-22 23.58.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f8fc5da9-b14a-58e7-0be3-1fdff17e3c00.png)

Qiita API のベース URL を設定します

```elixir
base_url = "https://qiita.com/api/v2"
```

この後使うモジュールにエイリアスを設定しておきます

```elixir
alias Explorer.DataFrame
alias Explorer.Series
alias VegaLite, as: Vl
require Explorer.DataFrame
```

認証ヘッダーを設定します

```elixir
auth_header = {"Authorization", "Bearer #{Kino.Input.read(token_input)}"}
"dummy"
```

## 記事一覧を取得する

Qiita の記事一覧は以下のようにして取得します

```elixir
articles =
  "#{base_url}/authenticated_user/items"
  |> HTTPoison.get!([auth_header])
  |> then(&JSON.decode!(&1.body))
```

件数を確認してみましょう

```elixir
Enum.count(articles)
```

![スクリーンショット 2022-11-23 22.34.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/96fa77d0-0326-9867-69e3-b59601d732ce.png)

私の記事は20件を超えているはずですが、、、

QIita API のドキュメントを確認すると、どうやらページングしないといけないようです

https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems

> - page
>   ページ番号 (1から100まで)
>   Example: 1
>   Type: string
>   Pattern: /^[0-9]+$/
>
> - per_page
>   1ページあたりに含まれる要素数 (1から100まで)
>   Example: 20
>   Type: string
>   Pattern: /^[0-9]+$/

私は 100 記事未満なので `per_page` に 100 を指定すれば全件取得できますが、
それでは今後行き詰まるので、全ページ取得できるようにします

データが存在しないページを指定したとき空配列 `[]` が返ってくるため、
`[]` が返るまでページ番号を 1 ずつ増やしながら API を呼び出すことにしましょう

Elixir には所謂 while のような構文が存在しないため、再帰呼び出しで実装しました

もっとスマートな解決方法がある場合は是非教えてください

```elixir
defmodule Qiita do
  @moduledoc """
  Qiita API を呼び出す
  """

  @base_url "https://qiita.com/api/v2"

  @doc """
  1ページ分の記事一覧を取得する

  ## パラメータ

    - page: ページ番号
    - auth_header: 認証ヘッダー

  """
  @spec get_articles(integer, tuple) :: list
  def get_articles(page, auth_header) do
    "#{@base_url}/authenticated_user/items?page=#{page}"
    |> HTTPoison.get!([auth_header])
    |> then(&JSON.decode!(&1.body))
  end

  @doc """
  再帰的に記事一覧を取得する

  ## パラメータ

    - page: ページ番号
    - auth_header: 認証ヘッダー

  """
  @spec get_articles_cyclic(integer, tuple) :: list
  def get_articles_cyclic(page, auth_header) do
    IO.inspect("get page #{page}")
    articles = get_articles(page, auth_header)

    case articles do
      # 空であれば次ページを取得しない
      [] ->
        IO.inspect("stop")
        articles

      # 空以外の場合は次ページを取得する
      _ ->
        articles ++ get_articles_cyclic(page + 1, auth_header)
    end
  end

  @doc """
  記事一覧を全件取得する

  ## パラメータ

    - page: ページ番号
    - auth_header: 認証ヘッダー

  """
  @spec get_all_articles(tuple) :: list
  def get_all_articles(auth_header) do
    get_articles_cyclic(1, auth_header)
  end
end
```

全件取得を呼び出してみましょう

```elixir
# 全件取得
all_articles = Qiita.get_all_articles(auth_header)
```

再帰処理が分かりやすいように仕込んでいた `IO.inspect` により、
7回目の呼び出しで `[]` が API から返ってきて、再帰処理が停止したことが分かります

![スクリーンショット 2022-12-12 10.33.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bfdfea31-a7f1-6fff-bcc7-5b96565801ca.png)

件数を確認してみましょう

```elixir
Enum.count(all_articles)
```

![スクリーンショット 2022-12-12 10.34.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/aa74e4a1-49bf-3f0f-a772-fb095b5a7246.png)

確かに全件取得できたようです

## 記事一覧をデータフレーム化する

```elixir
qiita_df =
  all_articles
  |> Enum.map(fn item ->
    %{
      "title" => item["title"],
      # 限定公開フラグ
      "private" => item["private"],
      # 作成日 日付は NaiveDateTime に変換する
      "created_at" => NaiveDateTime.from_iso8601!(item["created_at"]),
      # 閲覧数
      "page_views_count" => item["page_views_count"],
      # いいね数
      "likes_count" => item["likes_count"],
      # いいね率 = いいね数 / 閲覧数
      "likes_rate" => item["likes_count"] / item["page_views_count"],
      # ストック数
      "stocks_count" => item["stocks_count"],
      # ストック率 = ストック数 / 閲覧数
      "stocks_rate" => item["stocks_count"] / item["page_views_count"],
      # タグ 複数のため、 `、` で結合する
      "tags" => item["tags"] |> Enum.map(& &1["name"]) |> Enum.join(","),
      # 記事の長さ（文字数）
      "length" => item["body"] |> String.length()
    }
  end)
  |> DataFrame.new()
  |> DataFrame.select([
      "title",
      "private",
      "created_at",
      "page_views_count",
      "likes_count",
      "likes_rate",
      "stocks_count",
      "stocks_rate",
      "tags",
      "length"
  ])

qiita_df
|> Kino.DataTable.new(sorting_enabled: true)
```

![スクリーンショット 2022-12-12 10.34.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f119f897-77c3-7010-c8e7-6611b75e4d28.png)

## 記事一覧を分析する

閲覧数、いいね数、ストック数の合計値、平均値、最大値、最小値を算出してみましょう

```elixir
qiita_df
|> DataFrame.group_by(["private"])
|> DataFrame.summarise(
  page_views_count_sum: sum(page_views_count),
  page_views_count_mean: mean(page_views_count),
  page_views_count_max: max(page_views_count),
  page_views_count_min: min(page_views_count),
  likes_count_sum: sum(likes_count),
  likes_count_mean: mean(likes_count),
  likes_count_max: max(likes_count),
  likes_count_min: min(likes_count),
  stocks_count_sum: sum(stocks_count),
  stocks_count_mean: mean(stocks_count),
  stocks_count_max: max(stocks_count),
  stocks_count_min: min(stocks_count)
)
|> DataFrame.filter(private == false)
|> DataFrame.to_columns()
|> Map.drop(["private"])
|> dbg()
```

![スクリーンショット 2022-12-12 10.35.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/18546e0b-f490-6558-0dd4-a3a6590f5f13.png)

合計閲覧数は 184,569 ということで、 18 万件を超えていました

平均閲覧数でも 2,006 なので結構閲覧されています（私としては）

いいね数の合計は 574　、 ストック数の合計は 192

個人的にはいい数値だと思います

では、最大閲覧数を取得した記事はなんでしょうか

```elixir
qiita_df
|> DataFrame.arrange(desc: page_views_count)
|> DataFrame.select(["title", "page_views_count", "likes_count", "stocks_count"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-12 10.36.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bb5860b5-b56e-071e-7905-a6ed55ecabf7.png)

最大閲覧数は以下の記事でした

https://qiita.com/RyoWakabayashi/items/bde84624bffc1f68071d

なんだかんだ Office 製品ユーザーは多く、かつ Git でどう変更管理しようか困っている人が多いようです

また、 AI 系の記事はやはり人気があるみたいですね

続いて最大いいね数です

```elixir
qiita_df
|> DataFrame.arrange(desc: likes_count)
|> DataFrame.select(["title", "likes_count", "page_views_count", "stocks_count"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-12 10.37.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b9e1b6e2-1537-5acf-a8c1-9ba860acbea2.png)

最大いいね数は以下の記事でした

https://qiita.com/RyoWakabayashi/items/dfb5495b9c25eff710a2

Elixir コミュニティの繋がりを感じますね

また、やはりこちらでも AI 系の人気の高さが窺えます

最大ストック数を見てみましょう

```elixir
qiita_df
|> DataFrame.arrange(desc: stocks_count)
|> DataFrame.select(["title", "stocks_count", "likes_count", "page_views_count"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-12 10.38.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f65d7b70-25be-f018-0741-9bfa821dd678.png)

最大ストック数はこちらの記事でした

https://qiita.com/RyoWakabayashi/items/9d9d4759353f206814c4

やはり AI 系はみんな自分で試したくなるようですね

## グラフ化する

VegaLite を使ってグラフ化してみましょう

データを DataFrame から取得する関数を定義します

```elixir
get_values = fn df, col ->
  df
  |> DataFrame.pull(col)
  |> Series.to_list()
end
```

記事毎の閲覧数をグラフ化してみます

```elixir
x = get_values.(qiita_df, "title")
y = get_values.(qiita_df, "page_views_count")

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(x: x, y: y)
|> Vl.mark(:bar)
|> Vl.encode_field(
  :x,
  "x",
  type: :nominal,
  title: "title",
  # 閲覧数の降順に並べる
  sort: %{"field" => "y", "order" => "descending"}
)
|> Vl.encode_field(
  :y,
  "y",
  type: :quantitative,
  title: "page_views_count"
)
```

![スクリーンショット 2022-12-12 10.39.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f98411e7-f27f-888d-55a5-9170cfc0dbf3.png)

見事にポアソン分布っぽい

たまたま人気が出ることもあるが、大抵は低い閲覧数、ということが分かりますね

いいね数でも大体同じ傾向です

```elixir
x = get_values.(qiita_df, "title")
y = get_values.(qiita_df, "likes_count")

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(x: x, y: y)
|> Vl.mark(:bar)
|> Vl.encode_field(
  :x,
  "x",
  type: :nominal,
  title: "title",
  sort: %{"field" => "y", "order" => "descending"}
)
|> Vl.encode_field(
  :y,
  "y",
  type: :quantitative,
  title: "likes_count"
)
```

![スクリーンショット 2022-12-12 10.40.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c3d32a99-7efa-7366-fcd6-bef3f3ecdace.png)

時系列で閲覧数を見てみましょう

```elixir
x = get_values.(qiita_df, "created_at")
y = get_values.(qiita_df, "page_views_count")

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(x: x, y: y)
|> Vl.mark(:line)
|> Vl.encode_field(
  :x,
  "x",
  type: :temporal,
  title: "created_at"
)
|> Vl.encode_field(
  :y,
  "y",
  type: :quantitative,
  title: "page_views_count"
)
```

![スクリーンショット 2022-12-12 10.40.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/549ebc22-462f-355c-c5ee-9fbb0f865424.png)

昔の方が多く見られていた、とか、最近の方が閲覧数は増えている、とかいうような傾向は全くみられませんね

記事の長さ（文字数）とスタック数に関連がありそうかを見てみましょう

```elixir
x = get_values.(qiita_df, "length")
y = get_values.(qiita_df, "stocks_count")

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(x: x, y: y)
|> Vl.mark(:point)
|> Vl.encode_field(
  :x,
  "x",
  type: :quantitative,
  title: "length"
)
|> Vl.encode_field(
  :y,
  "y",
  type: :quantitative,
  title: "likes_count"
)
```

![スクリーンショット 2022-12-12 10.40.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/824f204f-959e-fe4f-789d-6f4d4bb6266c.png)

サンプル数が少ないので微妙ではありますが、文字数が多ければスタックされやすい、というわけでもなさそうですね

## タグを分析する

タグ毎のデータの傾向を見たいので、データをタグ単位に変更します

```elixir
qiita_tag_df =
  all_articles
  |> Enum.flat_map(fn item ->
    item["tags"]
    |> Enum.map(fn tag ->
      %{
        "tag" => tag["name"],
        "title" => item["title"],
        "page_views_count" => item["page_views_count"],
        "likes_count" => item["likes_count"],
        "stocks_count" => item["stocks_count"]
      }
    end)
  end)
  |> DataFrame.new()
  |> DataFrame.select(["title", "tag", "page_views_count", "likes_count", "stocks_count"])

qiita_tag_df
|> Kino.DataTable.new(sorting_enabled: true)
```

![スクリーンショット 2022-12-12 10.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1b2cab16-ee9b-22d9-e2d9-0fccb0130ed8.png)

タグ毎に集計してみましょう

```elixir
qiita_tag_summarised_df =
  qiita_tag_df
  |> DataFrame.group_by(["tag"])
  |> DataFrame.summarise(
    page_views_count: count(page_views_count),
    page_views_count_sum: sum(page_views_count),
    page_views_count_mean: mean(page_views_count),
    likes_count_sum: sum(likes_count),
    likes_count_mean: mean(likes_count),
    stocks_count_sum: sum(stocks_count),
    stocks_count_mean: mean(stocks_count)
  )

qiita_tag_summarised_df
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-12 10.41.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3826731d-1362-3fd7-123e-4584a0800dd3.png)

タグ毎の投稿数をグラフ化してみます

```elixir
x = get_values.(qiita_tag_summarised_df, "tag")
y = get_values.(qiita_tag_summarised_df, "page_views_count")

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(x: x, y: y)
|> Vl.mark(:bar)
|> Vl.encode_field(
  :x,
  "x",
  type: :nominal,
  title: "tag",
  sort: %{"field" => "y", "order" => "descending"}
)
|> Vl.encode_field(
  :y,
  "y",
  type: :quantitative,
  title: "count"
)
```

![スクリーンショット 2022-12-12 10.42.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/39d3bcb7-e2eb-c607-3dbe-c43c5b14a0fb.png)

明らかに Elixir に偏っていますね

色々な集計値について見てみたいので、グラフ表示を関数化します

```elixir
plot_tag_bar = fn col, agg ->
  x = get_values.(qiita_tag_summarised_df, "tag")
  y = get_values.(qiita_tag_summarised_df, "#{col}_#{agg}")

  Vl.new(width: 800, height: 400)
  |> Vl.data_from_values(x: x, y: y)
  |> Vl.mark(:bar)
  |> Vl.encode_field(
    :x,
    "x",
    type: :nominal,
    title: "tag",
    sort: %{"field" => "y", "order" => "descending"}
  )
  |> Vl.encode_field(
    :y,
    "y",
    type: :quantitative,
    title: "#{col}_#{agg}"
  )
end
```

タグ毎の合計閲覧数です

```elixir
plot_tag_bar.("page_views_count", "sum")
```

![スクリーンショット 2022-12-12 10.43.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9eb0c639-15b7-f2ba-2e94-f7db80f13e96.png)

当然投稿数が多いので、 Elixir がトップです

では、タグ毎の平均閲覧数にしてみましょう

```elixir
plot_tag_bar.("page_views_count", "mean")
```

そうすると、１回しか付けていないのに閲覧数トップの記事に付いていた `Git` や `Excel` が上位を占めます

![スクリーンショット 2022-12-12 10.43.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/52b1b575-8f5b-d8fa-a4f8-88f8af81ca63.png)

いいね数も合計は Elixir が首位

```elixir
plot_tag_bar.("likes_count", "sum")
```

![スクリーンショット 2022-12-12 10.44.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/576ee3ff-a875-2273-dc68-6c79dffeab61.png)

いいね数の平均は Midjourney がトップ

これも１回しか付いていないからですね

```elixir
plot_tag_bar.("likes_count", "mean")
```

![スクリーンショット 2022-12-12 10.44.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/faf62782-d5af-b30a-cee1-6217eb70f8a1.png)

ストック数合計も Elixir が1位

```elixir
plot_tag_bar.("stocks_count", "sum")
```

![スクリーンショット 2022-12-12 10.45.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4ad1466b-64fe-d1b8-6489-60bc4e17052e.png)

ストック数の平均も Midjourney がトップ

```elixir
plot_tag_bar.("stocks_count", "mean")
```

![スクリーンショット 2022-12-12 10.45.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f0988b7a-fd21-2920-b242-e7816531f155.png)

１回しか付けていないようなタグは外れ値にした方が良さそうです

## まとめ

Livebook と Explorer で Qiita 投稿記事の様々な分析ができました

みなさんも、是非自分の投稿記事について Elixir で分析してみましょう