最近 VTuber にハマりつつある筆者です。
会社とかでたまに VTuber の話が出ることがあるんですが、だいたい「VTuber ってどれくらいいるの?」みたいに聞かれるので「これ見ればいいよ」的なものがあったらなぁと思って気がついたらウェブサイト作ってました。最初は単に YouTube Data API を試したくて Elasticsearch にデータを入れてただけなのにどうしてこうなった…。
構成は前回の電子書籍ランキングと同じ 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()
の時点で勝手に削られるのであまり拘らないことにしました。
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 のコードの方はスクラッチで書いたものなのでいまのところあんまり凝って書いてはいません。
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.subscriberCount
と statistics.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 に投げるクエリでは指定が可能です。
まずは上の画像の通りのデータを取り出します。これは Aggregation Query で前日と当日の max
を取り、それらの Bucket を Pipeline Aggregations の serial_diff
か derivative
に渡します。
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"}
に変わるようです。
{ "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": "再生回数" } } } } } } } }
{ "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 の結果から「上位○○件」みたいな絞り込みをしようと思いました。やや複雑になりますが順番と書き方のコツさえ掴めばそんなに難しくはないはずです。
- ❶: 日付ごとの値を取り出す
- ❷: ❶の結果から差分を計算する
- ❸: ❷の結果の累積和を計算する
- ❹: ❸の結果の最大値を計算する
- ❺: ❹の結果を降順でソートする
- ❻: ❺の結果を上位 n 件で絞り込む
date_histogram
で interval
ごとに分割された結果では親はどの値を元にソートすればいいかわからないため、分割のひとつ上の階層(date_histogram
と同じ階層)から bucket の中を見て max
等で値を取り出して単体の要素にします。あとは buckets_sort
で from
と size
が指定できるので絞り込む感じです。
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 を参照するとよいかと。
{ "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 の場合、バケットの最初のオブジェクトには差分の対象がないためキーが存在しないので注意です。
{ "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 側で数量を指定しています)
Cumulative Sum を取得する関係で Serial Diff を抽出しているので Serial Diff の値からヒートマップを作れたりもします。
次回は AWS と Vue.js について書く予定。