バーチャルYouTuberが投稿した動画、公開した生放送を自動で再生リストに登録してツイートするボットを作った話

# バーチャルYouTuberが投稿した動画、公開した生放送を自動で再生リストに登録してツイートするボットを作った話

## 概要

　バーチャルYouTuberさんについて広く知りたいけど、いろんなチャンネル、Twitter見て新着動画探すの大変だよー。

　となったのでバーチャルYouTuberが投稿した動画、公開した生放送を自動で再生リストに登録してツイートするボットを作りました。紹介します。

[日刊VTuber動画(@vtuber_movies)さん | Twitter](https://twitter.com/vtuber_movies)
https://twitter.com/vtuber_movies

　こんな感じにツイートされます。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">再生リスト「2019年7月14日に投稿されたバーチャルYouTuberの動画・生放送」を作成しました。(119件登録済み)<a href="https://t.co/pxGURk5J0J">https://t.co/pxGURk5J0J</a></p>&mdash; 日刊VTuber動画 (@vtuber_movies) <a href="https://twitter.com/vtuber_movies/status/1150557735697539072?ref_src=twsrc%5Etfw">2019年7月15日</a></blockquote>

　アイコン絶賛募集中です。


　ざっくり下記の流れでプログラムが動きます。

1. バーチャルYouTuberのチャンネル一覧をクロールして取得する
2. YouTubeのチャンネルRSSにアクセスして新着動画を取得する
3. YouTubeのAPI叩いて再生リストを生成、動画を登録する
4. 生成した再生リストをTwitterのボットでツイートする


　GitHubで公開済みです。

[kokeiro001/YouTubeNotifier](https://github.com/kokeiro001/YouTubeNotifier)
https://github.com/kokeiro001/YouTubeNotifier

### 使用技術概要

　だいたいこの辺の技術使って実現しました。

- C# / VsialStudio / .NET Core 2.2
- Docker
- Chrome / Selenium
- Google API
- Twitter API
- Azure Storage


## 1.バーチャルYouTuberのチャンネル一覧をクロールして取得する

　そもそもどんなバーチャルYouTuberさんが動画投稿、生放送を実施しているか知る必要があります。自動で。

　今回はVtuber Insightさんのデータをクロールして利用させていただくことにしました。

[Vtuber Insight](https://vtuberinsight.com/)
https://vtuberinsight.com/

　さて、このサイト。チャンネル名やチャンネルのIDを取得できるのですがWebSocketを用いて実現しています。普段Webページからのデータ取得、クロールには適当なHttpClientを用いてGetやらPostで取得しているのですがWebSocketではそうも行きません。

　そこで、今回はヘッドレスモードのChrome＋Seleniumを用いてざっくり下記のようにデータを取得しました。

```c#
private async Task<string> GetPageSource()
{
    var driverPath = "/opt/selenium/";
    var driverExecutableFileName = "chromedriver";

    var options = new ChromeOptions();
    options.AddArguments("headless");
    options.AddArguments("--disable-gpu");
    options.AddArguments("no-sandbox");
    options.AddArguments("--window-size=1,1");
    options.AddArguments("--disable-desktop-notifications");
    options.AddArguments("--disable-extensions");
    options.AddArguments("--blink-settings=imagesEnabled=false");
    options.BinaryLocation = "/opt/google/chrome/chrome";

    using (var service = ChromeDriverService.CreateDefaultService(driverPath, driverExecutableFileName))
    using (var driver = new ChromeDriver(service, options, TimeSpan.FromSeconds(60)))
    {
        driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5);
        driver.Manage().Window.Minimize();

        var url = @"https://vtuber-insight.com/index.html";

        driver.Navigate().GoToUrl(url);

        // WebSocket経由のデータ取得のため適当に20秒ほど待つ
        for (int i = 20; i > 0; i--)
        {
            log.Infomation($"** Wait {i} seconds for web socket");
            await Task.Delay(TimeSpan.FromSeconds(1));
        }

        return driver.PageSource;
    }
}
```

　Chromeの起動時オプションでいろいろな機能を無効にしてます。このプログラムはConoHaのメモリ512MBのVPSで動かしてるのですが、少なくとも画像読み込まないようにしないとメモリ使い潰してしまいます。多分headlessにした時点で無効になってるパラメータもあって、重複して無効にしようとしているオプションあるんだろうなとは思います。が、動いてるのでおｋ．気が向いたらしっかりドキュメント読みつつ検証します。

　DockerイメージにChrome/Seleniumをインストールするにあたっては下記リポジトリを参考にさせていただきました。こっちのDockerファイルもあまり理解しないままほぼ丸コピしてるだけなので気が向いたらしっかり読みます。

https://github.com/devpabloassis/seleniumdotnetcore/blob/master/Dockerfile

## 2.YouTubeのチャンネルRSSにアクセスして新着動画を取得する

　次はVtuber Insightから取得したYouTubeのチャンネルIDを用いて、それぞれのチャンネルにアクセスして新着動画を取得していきます。

　新着動画を取得するにあたって、実現方法は大きく２パターンを想定していました。

- 1.YouTubeのAPIを叩いて動画を取得する
- 2.YouTubeチャンネルのRSSにアクセスして動画を取得する

　はじめは素直に1で実装していました。任意の期間に投稿された動画をAPIリクエストに入れられたのでそもそも実現も楽そうでしたし、何より「普通こっちだよなぁ」みたいな直感があったので。

　が、YouTubeのAPIのクォータを消費してしまうことがわかったため辞めました。

[YouTube Data API の概要  |  YouTube Data API (v3)  |  Google Developers](https://developers.google.com/youtube/v3/getting-started?hl=ja#quota)
https://developers.google.com/youtube/v3/getting-started?hl=ja#quota

>YouTube Data API では、デベロッパーが目的どおりにサービスを使用できることと、不当にサービス品質を低下させたり他のリソースへのアクセスを制限したりしてしまうアプリケーションの作成を防ぐことを目的としてクォータを使用します。

　動画取得のためにAPIを叩いてしまうと、このプログラムの本命である「再生リストに動画を登録する」APIを叩いてる際にクォータを消費しきってしまい、新着動画を登録しきれなくなることが判明しました。(この利用上限を開放するためには英語による手続き、審査が必要なので面倒臭い。。)

　そこで、クォータを消費しないRSSを用いて新着動画を取得することにしました。下記のようなURLでそれぞれのチャンネルのRSSにアクセスできます。

```c#
var url = $"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}";
```

　取得後、自分でRSSをパースする手間が増えますがこれでいくらチャンネル情報を取得してもクォータ利用制限で死ななくなります。

```c#
private List<YouTubeRssItem> GetRssItems(string channelId)
{
    var url = $"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}";

    var list = new List<YouTubeRssItem>();

    using (var xmlReader = XmlReader.Create(url))
    {
        var feed = SyndicationFeed.Load(xmlReader);
        foreach (var item in feed.Items)
        {
            var movieUri = item.Links.First().Uri;
            var movieId = movieUri.Query.Substring(3);

            var youtubeRssItem = new YouTubeRssItem
            {
                Url = movieUri.ToString(),
                VideoId = movieId,
                Title = item.Title.Text,
                PublishDateUtc = item.PublishDate.DateTime,
            };

            list.Add(youtubeRssItem);
        }
    }

    return list;
}
```

　自分用のコードはガンガンハードコードするマン。

　ちょっとクォータ使用してないことに抜け道感はありますが、おそらくYouTubeのサーバー的にもRSSへのアクセスのが低負荷でそんなに悪い子としてないと自分を信じ込ませましょう。現在読み込むチャンネル数は300にしていますが、適当にディレイ挟みながらアクセスしてるのでそこまでしんどくないはず。300って数字は気分で決めました。


## 3.YouTubeのAPI叩いて再生リストを生成、動画を登録する

　素直にnugetから `Google.Apis.YouTube.v3` をインストールしてAPI叩いていきます。この辺のAPI使うにはGoogleの開発者登録必要です。必要に応じて登録してください。

　今回作ったプログラムは一日に一回cronで起動し「昨日の再生リスト」を生成することを想定していますが、作ろうとしている名前と同名の再生リストをがあったら新規作成しない、既に登録済みの動画があった場合は登録しない、という制御を自前で入れています。自分で制御しないと重複した名前の再生リスト作ったり、同じ再生リストに同じ動画が複数入ったりするので注意が必要です。デバッグ中は何度も実行するので、この辺の制御入れとかないと手動で消す羽目になります(n敗)。

　また、しっかり受け取るパラメータをFieldプロパティで明示して制限しないとあっという間にクォータ上限いっちゃって次の日までデバッグできないのも注意です(n敗)。

```c#
private async Task<(Playlist playlist, List<string> videoIds)> GetOrInsertPlaylist(string playlistTitle)
{
    log.Infomation($"GetOrInsertPlaylist playlistTitle={playlistTitle}");

    var listPlaylistRequest = youtubeService.Playlists.List("id,snippet");
    listPlaylistRequest.Mine = true;
    listPlaylistRequest.MaxResults = 50;
    listPlaylistRequest.Fields = "items/id,items/snippet/title";

    log.Infomation("listPlaylistRequest.ExecuteAsync");
    var listPlaylistResponse = await listPlaylistRequest.ExecuteAsync();

    var playlist = listPlaylistResponse.Items
        .Where(x => x.Snippet.Title == playlistTitle)
        .FirstOrDefault();

    if (playlist != null)
    {
        var videoIds = await GetPlaylistItemVideoIds(playlist);

        return (playlist, videoIds);
    }

    // not found playlist. insert playlist.
    log.Infomation($"NotFound playlistTitle={playlistTitle}");

    log.Infomation($"InsertPlayList {playlistTitle}");

    var insertPlaylistRequest = youtubeService.Playlists.Insert(new Playlist
    {
        Snippet = new PlaylistSnippet
        {
            Title = playlistTitle,
        },
        Status = new PlaylistStatus
        {
            PrivacyStatus = "public",
        },
    }, "snippet,status");

    insertPlaylistRequest.Fields = "id,snippet/title";

    log.Infomation("insertPlaylistRequest.ExecuteAsync");
    var insertPlaylistResponse = await insertPlaylistRequest.ExecuteAsync();

    return (insertPlaylistResponse, new List<string>());
}


private async Task<List<string>> GetPlaylistItemVideoIds(Playlist playlist)
{
    var pageToken = default(string);

    var list = new List<string>();

    do
    {
        var playlistItemsRequest = youtubeService.PlaylistItems.List("snippet");
        playlistItemsRequest.Fields = "items/snippet/id";
        playlistItemsRequest.PageToken = pageToken;
        playlistItemsRequest.PlaylistId = playlist.Id;
        playlistItemsRequest.MaxResults = 50;

        var playlistItemsResponse = await playlistItemsRequest.ExecuteAsync();

        var videoIds = playlistItemsResponse.Items.Select(x => x.Id);

        list.AddRange(videoIds);

        pageToken = playlistItemsResponse.NextPageToken;

    } while (!string.IsNullOrEmpty(pageToken));

    return list;
}
```

　余談ですが小規模な趣味開発では「GetOrInsertPlaylist」みたいな複数の関心事をモリモリやらせてるんだろうなぁ！みたいなメソッドを躊躇せず作ります。小規模で誰にも引き継がないプロジェクトであれば結局そっちのが実装が早くかつメンテナンスしやすいと思ってます。


## 4.生成した再生リストをTwitterのボットでツイートする

　こちらも素直にnugetから `CoreTweet` をインストールしてAPI叩きます。こっちもTwitterでの開発者登録が必要です。ググってください。

```c#
class TwitterService
{
    private readonly Tokens tokens;

    public TwitterService(Settings settings)
    {
        tokens = Tokens.Create(
            settings.Twitter.ApiKey,
            settings.Twitter.ApiSecret,
            settings.Twitter.AccessToken,
            settings.Twitter.AccessTokenSecret
        );
    }

    public async Task TweetGeneratedPlaylist(string playlistId, string playlistTitle, int videoCount)
    {
        var playlistUrl = $"https://www.youtube.com/playlist?list={playlistId}";

        var stringBuilder = new StringBuilder();

        stringBuilder.AppendLine($"再生リスト「{playlistTitle}」を作成しました。({videoCount}件登録済み)");

        stringBuilder.AppendLine("");
        stringBuilder.Append(playlistUrl);

        await tokens.Statuses.UpdateAsync(new
        {
            status = stringBuilder.ToString(),
        });
    }    
}
```


## 感想

　バーチャルYouTuberさんたちって日々こんなに動画あげてるのかー、生放送やってるのかーってのを日次で知ることが出来るようになりました。大きな収穫です。めちゃめちゃ多い。なるほど手動じゃ追いきれんわな。

　また、ヘッドレスモードのChromeを用いたクロールやらYouTubeのRSSの取得、再生リストの作成、動画の登録などなど、今まで触ったことのなかった技術やAPIにたくさん触れることが出来て楽しかったです。メモリ512MBのLinux上でヘッドレスモードのChrome動かせるってのが分かったのも収穫。今後のクロール手札が増えました。

　実はもともと非コンテナアプリとしてAzure Functions向けに作ってたのですが、ヘッドレスブラウザを利用する必要が出てきたため断念してコンテナ化。コンテナをAzureで動かすにはまだランニングコスト10円/月とかは難しいって認識で、以前つから使ってたConoHaのVPSに統合する形となりました。Azure Storageを利用しているのはその頃の名残です。知らないAzureのコンテナ動かすサービスありそうだなぁ。。





　ちなみに推しはポン子です。