C# と YouTube API を使って VTuber の配信の流行を調べてみる

この記事はカバー株式会社 Advent Calendar 2023 23 日目の記事です。

はじめまして、ホロアースのエンジニアリングマネージャーを担当している新入社員の M です。

今回は最近 YouTube で VTuber さんが配信したコンテンツについて、C# と YouTube API を使って配信回数のランキングを作成してみましたので、その結果と集計用のプログラムをご紹介します。

まずはランキングの集計結果から見てみましょう。

## 集計結果（配信回数ランキング）
| **順位** | **コンテンツ** | **配信された回数** |
|-------|------------|--------|
| 1     | 雑談 | 2,449   |
| 2     | 歌   | 1,880   |
| 3     | Grand Theft Auto | 860    |
| 4     | 参加型 | 814   |
| 5     | 8番出口 | 714  |
| 6     | 朝活 | 585    |
| 7     | ポケットモンスター | 431    |
| 8     | ASMR | 426    |
| 9     | 雀魂 | 419    |
| 10    | Apex Legends | 391    |
| 11    | コラボ | 354  |
| 12    | 原神 | 319    |
| 13    | 同時視聴 | 318 |
| 14    | Minecraft | 292 |
| 15    | ドラゴンクエスト | 272    |
| 16    | スプラトゥーン   | 256    |
| 17    | 耐久         | 215    |
| 18    | VALORANT | 163    |
| 19    | スイカゲーム     | 140    |
| 20    | Refind Self: 性格診断ゲーム | 135    |
| 21    | 睡眠導入       | 132    |
| 22    | ウマ娘 プリティーダービー | 131    |
| 23    | モンスターハンター | 115    |
| 24    | ウツロマユ     | 103    |
| 25    | ARK        | 93     |

このようなランキングになりました。
雑談や歌、人気ゲームはやはり配信回数が多いですね。
個人的には朝活が想像以上に盛んにおこなわれていることに少し驚きました。

e スポーツ系のゲームの順位が低く見えますが、これは調査対象が YouTube だからですね。
Twitch も調査対象に含めると結果は大きく変わってくると思います。

※調査期間は 2023 年 12 月 1 日から 12 月 18 日まで
※調査対象は主に日本向けに YouTube で活動している VTuber さん 約 2,500 人
※集計結果には動画コンテンツも含まれますがひとまとめに配信として表記しています

## 調査に使用したコード
実行には YouTube API やスプレッドシートへアクセスするための API キーやクレデンシャルファイルが必要になりますが、それらは Google Cloud のプロジェクトを作成してそこから無料で発行することが可能です。

処理の流れは下記のようになっています。

1. スプレッドシートから配信者のチャンネル ID 一覧を取得する
2. スプレッドシートからコンテンツの辞書を取得する
3. チャンネル ID 一覧から配信タイトルの一覧を取得する
4. 配信タイトルとコンテンツの辞書から配信されたコンテンツを集計する
5. ソートして表示する

``` csharp
private async Task Main()
{
	// スプレッドシートから配信者のチャンネル ID 一覧を取得する
	var channelIds = await GetYouTubeChannelIdsAsync();

	// スプレッドシートからコンテンツの辞書を取得する
	var contentDictionary = await GetContentDictionaryAsync();

	// チャンネル ID 一覧から配信タイトルの一覧を取得する
	var titles = await GetStreamingTitlesByChannelIdsAsync(channelIds);
	
	// 配信タイトルとコンテンツの辞書から配信されたコンテンツを集計する
	var result = AggregateContent(titles, contentDictionary);
	
	// ソートして表示する
    // Dump は LINQPad 用のメソッドなので他の環境で実行する場合は Console.WriteLine などを使います
	result.OrderByDescending(x => x.Value).Dump();
}

// スプレッドシートから配信者のチャンネル ID 一覧を取得する
private async Task<string[]> GetYouTubeChannelIdsAsync()
{
	var range = "channels!A2:A3000";
	var response = await GetSpreadsheetsValuesAsync(SpreadsheetsId, range);
	var vTuberYouTubeChannnelIds = response.Values.SelectMany(x => x.Select(y => y.ToString())).ToArray();
	return vTuberYouTubeChannnelIds;
}

// スプレッドシートからコンテンツの辞書を取得する
private async Task<Dictionary<string, ContentKeyword>> GetContentDictionaryAsync()
{
	var range = "contents!A2:C1000";
	var response = await GetSpreadsheetsValuesAsync(SpreadsheetsId, range);
	return response.Values.ToDictionary(
		x => x[0].ToString(),
		x => new ContentKeyword(Norm(x[1].ToString()).Split("\n"), x.Count > 2
          ? Norm(x[2].ToString()).Split("\n")
          : Array.Empty<string>()));
}

// スプレッドシートからデータを取得する
private async Task<ValueRange> GetSpreadsheetsValuesAsync(string spreadsheetsId, string range)
{
	var googleCredential = await GoogleCredential.FromFileAsync(CredentialPath, CancellationToken.None);
	var sheetsService = new SheetsService(new BaseClientService.Initializer { HttpClientInitializer = googleCredential });
	var request = sheetsService.Spreadsheets.Values.Get(spreadsheetsId, range);
	return await request.ExecuteAsync();
}

// 配信者のチャンネル ID から配信タイトルの一覧を取得する（取得する件数や日数は適時調整）
private async Task<string[]> GetStreamingTitlesByChannelIdsAsync(IEnumerable<string> channelIds)
{
	var youTubeService = new YouTubeService(new BaseClientService.Initializer { ApiKey = YouTubeApiKey });

	List<string> allTitles = new();

	foreach (var channelId in channelIds)
	{
		var activitiesRequest = youTubeService.Activities.List("snippet");
		activitiesRequest.ChannelId = channelId;
		activitiesRequest.MaxResults = 50;
		activitiesRequest.PublishedAfterDateTimeOffset = DateTime.UtcNow - TimeSpan.FromDays(19);
		var activitiesResponse = await activitiesRequest.ExecuteAsync();
		var items = activitiesResponse.Items.Where(x => x.Snippet.Type == "upload").ToArray();
		if (items.Length == 0)
			continue;

		var titles = items.Select(x => x.Snippet.Title).ToArray();
		allTitles.AddRange(titles);
	}
	
	return allTitles.ToArray();
}

// 配信タイトルとコンテンツの辞書を使ってデータを集計する
private Dictionary<string, int> AggregateContent(IEnumerable<string> streamingTitles, Dictionary<string, ContentKeyword> contentDictionary)
{
	var result = new Dictionary<string, int>();

	foreach (var content in contentDictionary)
	{
		var matchedTitles = streamingTitles.Select(x => Norm(x))
			.Where(title => 
				content.Value.Keywords.Any(x => title.Contains(x)) &&
				content.Value.IgnoreKeywords.All(x => !title.Contains(x)))
			.ToArray();

		if (matchedTitles.Any())
			result[content.Key] = matchedTitles.Length;
	}
	
	return result;
}

// 文字列のスペースを削除して小文字にして全角にしてひらがなにする
private string Norm(string s) => KanaConverter.ToHiragana(KanaConverter.ToWide(s.Replace(" ", "").ToLower()));

// コンテンツのキーワード用のレコード型
record ContentKeyword(string[] Keywords, string[] IgnoreKeywords);

// YouTubeAPI Key
const string YouTubeApiKey = "Your YouTube API Key";

// Google Credential File Path
const string CredentialPath = "Your Google Credential File Path";

// Spreadsheets Id
const string SpreadsheetsId = "Your Spreadsheets ID";

// 使用ライブラリ
// - Google.Apis.YouTube.v3 Client Library https://github.com/googleapis/google-api-dotnet-client
// - Google.Apis.Sheets.v4 Client Library https://github.com/googleapis/google-api-dotnet-client
// - Kana.NET https://github.com/rucio-rucio/Kana.NET
```

このコードを LINQPad に張り付けて実行すると下記のように結果が得られます。
余談ですが LINQPad は C# でこうした簡単な作業を行うときに便利でおすすめです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627526/a5b11680-56d8-f53c-f7e9-ed7ce513d9f9.png)

## スプレッドシートのデータ

スプレッドシートのチャンネル ID のリストは以下のように入力されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627526/26a62984-bf2d-0699-2013-afee295fa8f5.png)

コンテンツのリストは以下のように入力されています。

配信のタイトルに B 列のいずれかが含まれ、かつ C 列のいずれも含まれていない場合に、それを A 列のコンテンツの配信としてカウントしています。

今回の調査では 8～9 割程度の精度があれば十分ですので、ざっくりと入力しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627526/d25475a7-18a9-6cd7-90d8-a5b276b08afc.png)

なお今回の調査ではこのスプレッドシートの用意が一番大変でした。

## 最後に
私は VTuber さんの配信を見るのが好きなのですが、自分が好きなもののデータを可視化して見てみるとまた違った面白さがあって良いですね。

明日の記事は「FluentD + Dockerでアプリのログを計測する」です。そちらもぜひご覧ください！