mattintosh note

技術書典に出たい人生だった…

YouTube Data API と Elasticsearch を使って Aggregation Query を学ぶ

最近 VTuber にハマりつつある筆者です。

会社とかでたまに VTuber の話が出ることがあるんですが、だいたい「VTuber ってどれくらいいるの?」みたいに聞かれるので「これ見ればいいよ」的なものがあったらなぁと思って気がついたらウェブサイト作ってました。最初は単に YouTube Data API を試したくて Elasticsearch にデータを入れてただけなのにどうしてこうなった…。

vtubers.ga

f:id:mattintosh4:20190317214912p:plain
vtubers.ga

構成は前回の電子書籍ランキングと同じ AWS CloudFront + S3 + Route53 です。Elasticsearch は Raspberry Pi で動かしているため非力で耐えられないので予めクエリの結果を JSON ファイルでエクスポートしておいて JavaScript で整形しています。前回と違うのは SEO 無視で Vue.js にお任せしてるところです。フロントエンドはなかなか慣れないなぁ、という感じです。

YouTube のデータは YouTube Data API から取得できます。無料でも使えるので Elasticsearch 用のサンプルデータを取得するのにもいいのではないでしょうか。(一日のリクエスト回数には上限があります) 使い方は公式のドキュメントを見ればだいだいわかると思います。データを取得するだけなので使うメソッドは list です。チャンネルはいいんですが、動画になるとAPI の制限がなかなか厳しいですね。

Channels: list  |  YouTube Data API (v3)  |  Google Developers

YouTube Data API からデータを取得する

データの取り方は色々あると思うのですが、自分の場合は1回あたりの最大50件ずつで取得するようにしています。

取得する part は下記の3つです。

  • snippet
  • contentDetails
  • statistics

さらに上記から fields を使って必要な項目だけに絞ってます。この辺の項目絞りもクォータ量に影響するんだった気がしますがいまは覚えてません。

fields=items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists/uploads),statistics(viewCount,subscriberCount))

id はカンマ区切りで複数指定が出来るので50件まとめてしまいます。現時点で119チャンネルが取得対象なんですが、これなら3回のリクエストで終わります。

https://www.googleapis.com/youtube/v3/channels?maxResults=50&id=UCWMwHoGz5QhhRDc3K8SQ6cw,UC6UwdMiDJfyjEipxJ66ceUg,UCZ1WJDkMNiZ_QwHnNrVf7Pw,UCCebk1_w5oiMUTRxdNJq0sA,UC4YaOt1yT-ZeyB0OmxHgolA,UC53UDnhAAYwvNO7j_2Ju1cQ,UCIdEIHpS0TdkqRkHL5OkLtA,UCCVwhI5trmaSxfcze_Ovzfw,UCB1s_IdO-r0nUkY2mXeti-A,UCfiy-dr0s1O6LJRV6KHomLw,UC1suqwovbL1kzsoaZgFZLKg,UCfM_A7lE6LkGrzx6_mOtI4g,UCyof-1Ko_jy2sOtivyTpc4Q,UCQ0UDLQCjY0rmuxCDE38FGg,UCpPuEfqwYbpn7e2jWdQeWew,UCT1AQFit-Eaj_YQMsfV0RhQ,UCPvGypSgfDkVe7JG2KygK7A,UCQlLqVz0RFOkFpjrJv-k-Zg,UCD-miitqNY3nyukJ4Fnf4_A,UCmUjjW5zF1MMOhYUwwwQv9Q,UCARI2g7r-PHaxrIcAYsMfmA,UCBe_jjkUHhVNAj46bukAbJA,UC2ZVDmnoZAOdLt7kI7Uaqog,UCM6ZAX8qPfCzEkKcGOFWPMw,UCbFwe3COkDrbNsbMyGNCsDg,UCAr7rLi_Wn09G-XfTA07d4g,UCmTcayoDVo7HXAAV_mquHEg,UCbxANlIBzexmsg7-eucWNoA,UCsg-YqdqQ-KFF0LNk23BY4A,UCtpB6Bvhs1Um93ziEDACQ8g,UCCvInijwD6Qg9xwdtYJcYtQ,UC_GCs6GARLxEHxy1w40d6VQ,UC1zFJrfEKvCixhsjNSb1toQ,UC7fk0CB07ly8oSl0aqKkqFg,UCfiK42sBHraMBK6eNWtsy7A,UCXTpFs_3PqI41qX2d9tL2Rw,UCD8HOxPs4Xvsm8H0ZxXGiBw,UCpnvhOIJ6BN-vPkYU9ls-Eg,UCJQMHCFjVZOVRYafR6gY04Q,UCKYPwPHjmgLWrJwkcLhGvNg,UCHTnX0CSX_KObo5I9WuZ64g,UCmgWMQkenFc72QnYkdxdoKA,UCLhUvJ_wO9hOvv_yYENu4fQ,UCYKP16oMX9KKPbrNgo_Kgag,UCp-5t9SrOQwXMU7iIjQfARg,UC_4tXjqecqox5Uc05ncxpxg,UCwRKt_raV3N5KZgxcFyC1vw,UCkPIfBOLoO0hVPG-tI2YeGg,UC48jH1ul-6HOrcSSfoR02fQ,UC8NZiqKx6fsDT3AVcMiVFyA&part=snippet,contentDetails,statistics&fields=items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists%2Fuploads),statistics(viewCount,subscriberCount))&key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

自分は Python を使っているんですが 50件ずつの区切り方はこんな感じですね。チャンネル数が 50 で割り切れなかったら None で埋めておきます。その後、 zip() で 50 件ごとのリストに分け、最後に None を削っています。最初は None 埋めをピッタリやろうかなと思ったんですが、zip() の時点で勝手に削られるのであまり拘らないことにしました。

Python 3

MAX_LENGTH = 50

# TSV ファイルからチャンネル ID を読み込む
with open('vtuber.tsv', 'r') as f:
    channelIds = list(set([row[0] for row in csv.reader(f, delimiter='\t')]))

# リストが MAX_LENGTH で割り切れるかチェック
if len(channelIds) % MAX_LENGTH > 0:
    # 割り切れなかったら None で埋める
    channelIds += [None for i in range(MAX_LENGTH)]

# 50 件ごとのリストに分割しつつ None を除去
channelIdsSets = [[y for y in x if y is not None] for x in list(zip(*[iter(channelIds)] * MAX_LENGTH))]

あとはこのリストをまとめて urllib.parse.urlencode() とかで変換するんですが、ポイントとしては fields(),safe キーワードで指定しておかなきゃいけないところですかね。Python のコードの方はスクラッチで書いたものなのでいまのところあんまり凝って書いてはいません。

Python 3

    url = urllib.parse.urlunsplit([
        'https',
        'www.googleapis.com',
        '/youtube/v3/channels',
        urllib.parse.urlencode({
            'key'       : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
            'id'        : ','.join(s),
            'part'      : ','.join(['snippet', 'contentDetails', 'statistics']),
            'fields'    : 'items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists/uploads),statistics(viewCount,subscriberCount))',
            'maxResults': MAX_LENGTH,
        }, safe=',()'),
        None
    ])

では取れたデータを見てみます。実際には {"items": []} という配列の中に1チャンネルごとに入っています。statistics.subscriberCountstatistics.viewCount が string になってるのがちょっと注意ですかね。

{
  "id": "UCMxKcUjeTEcgHmC9Zzn3R4w",
  "snippet": {
    "thumbnails": {
      "medium": {
        "url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s240-mo-c-c0xffffffff-rj-k-no",
        "height": 240,
        "width": 240
      },
      "high": {
        "url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s800-mo-c-c0xffffffff-rj-k-no",
        "height": 800,
        "width": 800
      },
      "default": {
        "url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s88-mo-c-c0xffffffff-rj-k-no",
        "height": 88,
        "width": 88
      }
    },
    "publishedAt": "2018-08-08T05:20:43.000Z",
    "title": "由宇霧ちゃんねる"
  },
  "@timestamp": "2019-03-17T09:00:00+09:00",
  "statistics": {
    "subscriberCount": "26961",
    "viewCount": "940158"
  },
  "contentDetails": {
    "relatedPlaylists": {
      "uploads": "UUMxKcUjeTEcgHmC9Zzn3R4w"
    }
  }
}

string になっている数値は Python ではキャストせずに Elasticsearch のマッピングで対応しています。

{
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "count": {
            "match_mapping_type": "string",
            "match": "*Count",
            "mapping": {
              "type": "long"
            }
          }
        }
      ]
    }
  }
}

デイリーの差分を Aggregations で抽出する

Elasticsearch の良いところは内部で色々な計算が出来るところですね。YouTube のデイリーデータの活用方法をあまり見出せていませんが、とりあえず必要になるのは以下の2つでしょうか。

  • 再生回数の前日との差
  • チャンネル登録者数の前日との差

Kibana のテーブルで表現するとこんな感じのデータです。Kibana ではここから Serial Diff や Derivative の最大値を取ってソートキーとしては恐らく使用できないと思うのですが、Elasticsearch に投げるクエリでは指定が可能です。

f:id:mattintosh4:20190318234415p:plain
Kibana - Visualize

まずは上の画像の通りのデータを取り出します。これは Aggregation Query で前日と当日の max を取り、それらの Bucket を Pipeline Aggregations の serial_diffderivative に渡します。

terms: snippet.title
|
+-- date_histogram: @timestamp
    |
    +-- [0]
    |   |
    |   +-- max: statistics.subscriberCount -----.
    |   |                                        |
    |   +-- max: statistics.viewCount --------.  +--> serial_diff: subscriberCount
    |                                         |  |
    +-- [1]                                   +--|--> serial_diff: viewCount
        |                                     |  |
        +-- max: statistics.subscriberCount -----'
        |                                     |
        +-- max: statistics.viewCount --------'

Pipeline Aggregation は何かの結果を元に計算をするので field ではなく buckets_path で任意で決めたフィールド名を指定します。では前日と当日の差分を出すクエリを書いてみます。

※ここでは例として snippet.title で チャンネル名を使っていますが、チャンネル名は変わることがあるので id でチャンネル ID を使った方がいいこともあります。

terms.order 部分は {"_term": "Sort Order"} でしたが {"_key": "Sort Order"} に変わるようです。

Query

{
  "size": 0,
  "query": {
    "query_string": {
      "default_field": "@timestamp",
      "query": "[now-1d/d TO now]"
    }
  },
  "aggs": {
    "チャンネル": {
      "terms": {
        "field": "snippet.title",
        "size": 2,
        "order": {
          "_term": "asc"
        }
      },
      "aggs": {
        "バケット": {
          "date_histogram": {
            "field": "@timestamp",
            "interval": "day"
          },
          "aggs": {
            "チャンネル登録者数": {
              "max": {
                "field": "statistics.subscriberCount"
              }
            },
            "チャンネル登録者数差分": {
              "serial_diff": {
                "buckets_path": "チャンネル登録者数"
              }
            },
            "再生回数": {
              "max": {
                "field": "statistics.viewCount"
              }
            },
            "再生回数差分": {
              "serial_diff": {
                "buckets_path": "再生回数"
              }
            }
          }
        }
      }
    }
  }
}

Result

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 241,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "チャンネル" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 237,
      "buckets" : [
        {
          "key" : "A.I.Channel",
          "doc_count" : 2,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 1.96169102E8
                },
                "チャンネル登録者数" : {
                  "value" : 2475603.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 1.9636056E8
                },
                "チャンネル登録者数" : {
                  "value" : 2476558.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 955.0
                },
                "再生回数差分" : {
                  "value" : 191458.0
                }
              }
            ]
          }
        },
        {
          "key" : "A.I.Games",
          "doc_count" : 2,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 9.7609238E7
                },
                "チャンネル登録者数" : {
                  "value" : 1306572.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 9.7753671E7
                },
                "チャンネル登録者数" : {
                  "value" : 1307363.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 791.0
                },
                "再生回数差分" : {
                  "value" : 144433.0
                }
              }
            ]
          }
        }
      ]
    }
  }
}

Aggregations の結果を絞り込む

今度はグラフ用のデータを取り出す必要があったんですが、チャンネルの件数が多いので Aggregations の結果から「上位○○件」みたいな絞り込みをしようと思いました。やや複雑になりますが順番と書き方のコツさえ掴めばそんなに難しくはないはずです。

  1. ❶: 日付ごとの値を取り出す
  2. ❷: ❶の結果から差分を計算する
  3. ❸: ❷の結果の累積和を計算する
  4. ❹: ❸の結果の最大値を計算する
  5. ❺: ❹の結果を降順でソートする
  6. ❻: ❺の結果を上位 n 件で絞り込む

date_histograminterval ごとに分割された結果では親はどの値を元にソートすればいいかわからないため、分割のひとつ上の階層(date_histogram と同じ階層)から bucket の中を見て max 等で値を取り出して単体の要素にします。あとは buckets_sortfromsize が指定できるので絞り込む感じです。

buckets_path> 記号を使っていますが、これは比較演算子ではなくて Aggregation Separator です。stats などを使った場合は Metric Separator の . を使って .avg のように指定するようです。色々試したら Aggregation Separator を . で置き換えても動作するみたいですが、Metric Separator を > で置き換えるとエラーになりました。詳しくは https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html#buckets-path-syntax を参照するとよいかと。

Query

{
  "size": 0,
  "query": {
    "query_string": {
      "default_field": "@timestamp",
      "query": "[now-7d/d TO now/d]"
    }
  },
  "aggs": {
    "チャンネル": {
      "terms": {
        "field": "snippet.title",
        "size": 500
      },
      "aggs": {
        "バケット": { ❶
          "date_histogram": {
            "field": "@timestamp",
            "interval": "day"
          },
          "aggs": {
            "チャンネル登録者数": { ❶
              "max": {
                "field": "statistics.subscriberCount",
                "missing": 0
              }
            },
            "チャンネル登録者数差分": { ❷
              "serial_diff": {
                "buckets_path": "チャンネル登録者数"
              }
            },
            "チャンネル登録者数差分累積和": { ❸
              "cumulative_sum": {
                  "buckets_path": "チャンネル登録者数差分"
                }
            }
          }
        },
        "最大チャンネル登録者差分(参考値)": {
          "max_bucket": {
            "buckets_path": "バケット>チャンネル登録者数差分"
          }
        },
        "最大チャンネル登録者差分累積和": { ❹
          "max_bucket": {
            "buckets_path": "バケット>チャンネル登録者数差分累積和"
          }
        },
        "ソート条件": { ❺
          "bucket_sort": {
            "sort": [
              {
                "最大チャンネル登録者差分累積和": {
                  "order": "desc"
                }
              }
            ],
            "from": 0,
            "size": 2 ❻
          }
        }
      }
    }
  }
}

※上記のクエリで [now-7d/d TO now/d] としていますが、執筆時点では5日分しかデータが集まっていないため結果は5日分となっています。

Serial Diff や Derivative の場合、バケットの最初のオブジェクトには差分の対象がないためキーが存在しないので注意です。

Result

{
  "took" : 60,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 513,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "チャンネル" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "由宇霧ちゃんねる",
          "doc_count" : 5,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-14T00:00:00.000Z",
                "key" : 1552521600000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 15823.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 0.0
                }
              },
              {
                "key_as_string" : "2019-03-15T00:00:00.000Z",
                "key" : 1552608000000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 17682.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 1859.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 1859.0
                }
              },
              {
                "key_as_string" : "2019-03-16T00:00:00.000Z",
                "key" : 1552694400000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 22277.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 4595.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 6454.0
                }
              },
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 26961.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 4684.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 11138.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 35666.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 8705.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 19843.0
                }
              }
            ]
          },
          "最大チャンネル登録者差分(参考値)" : {
            "value" : 8705.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          },
          "最大チャンネル登録者差分累積和" : {
            "value" : 19843.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          }
        },
        {
          "key" : "御伽原 江良 / Otogibara Era【にじさんじ】",
          "doc_count" : 5,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-14T00:00:00.000Z",
                "key" : 1552521600000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 20036.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 0.0
                }
              },
              {
                "key_as_string" : "2019-03-15T00:00:00.000Z",
                "key" : 1552608000000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 20951.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 915.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 915.0
                }
              },
              {
                "key_as_string" : "2019-03-16T00:00:00.000Z",
                "key" : 1552694400000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 24114.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 3163.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 4078.0
                }
              },
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 26696.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 2582.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 6660.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 28731.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 2035.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 8695.0
                }
              }
            ]
          },
          "最大チャンネル登録者差分(参考値)" : {
            "value" : 3163.0,
            "keys" : [
              "2019-03-16T00:00:00.000Z"
            ]
          },
          "最大チャンネル登録者差分累積和" : {
            "value" : 8695.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          }
        }
      ]
    }
  }
}

上記の結果を Chart.js などでグラフにするとこうなります。(現在は Elasticsearch から全チャンネルのデータを取り出し JavaScript 側で数量を指定しています)

f:id:mattintosh4:20190319011243p:plain
vtubers.ga

Cumulative Sum を取得する関係で Serial Diff を抽出しているので Serial Diff の値からヒートマップを作れたりもします。

f:id:mattintosh4:20190319012427p:plain
vtubers.ga


次回は AWS と Vue.js について書く予定。