推しVのYoutubeアーカイブの各種情報を保存するためにどうすればいいのかを考えた

推しVがいなくなった。理由もわからずに。


Vtuberを推していると、いつかはVがいなくなるという事態に直面することになるだろう。引退、卒業、**契約解除**―呼び方は色々とあるだろうが、実態としてはVの死と表現するのが相応しい。そして残念ながら今のV界では、Vの死が訪れた際にはチャンネルごと抹消されるという事例が多く見られる。そうなれば彼、彼女（あるいはその他生物）の生きた記録の多くは残らず、思い出を振り返ることすらできない。

だから思い出を記録として残すため、Youtubeのアーカイブを保存するという手段を検討する。「そんなことして何になる」だとか、「余計に悲しくなるだけ」といった意見もあるのは知っているが、究極の自己満足として、具体的にどうやれば残せるのかを考えてみた。

### 何を残したいのか
まず、保存するべき対象を設定した。

1. 動画に付随する情報（詳細や再生数や高評価数）
2. サムネイル
3. 動画のコメント
4. 動画のライブチャット
5. 動画本体


こんなところか。
動画本体はもちろんであるが、在りし日の足跡を追うには再生数やコメント、さらにはライブチャットのコメントといった情報も欠かせないだろう。
また、この作業を行うためには、チャンネルに登録されている動画の一覧をリスト化できればより効率的になるだろう。

この記事では、上記の情報をPythonとYoutube APIを用いて取得してみることにする。
ただし、ここでは動画自身を保存する方法については触れない。

### 前提
1. Python3をインストールする。ここでは3.7.5を用いた。導入方法は[公式サイト](https://www.python.jp/install/install.html)を参照。
2. Google APIでYoutube Data API v3を使えるようにして、Youtube API KEYを入手する。方法は上の検索窓にて調べた。

### Pythonの外部パッケージ導入
流石に標準ライブラリだけでは作業が難しいので、外部パッケージをインストールした。
まずは以下のスクレイピングでよく使うものを導入。
`requests lxml beautifulsoup4`
次にYoutube APIにアクセスしやすくするために以下を導入する。
`google-api-python-client`

別にrequestsで直接URLを叩いても問題はない。
導入方法はpip。
`pip install xxxxx`

### 動画の一覧をリストとして取得する
以下のサイトを参考にした。
[YouTube Data api v3をPythonから使って特定のチャンネルの動画を取得する](https://qiita.com/yasudadesu/items/df76947f5b6ac955521f)

ここでは、動画IDのリストを取得するためにYoutube APIのうち、"list"を利用する。

```python
from googleapiclient.discovery import build
import sys

def GetVideolList(api_key, channelId):
    youtube = build('youtube', 'v3', developerKey = api_key)
    res = youtube.search().list(
                    part='snippet',#snippetを取得
                    channelId=channelId,
                    type='video', #動画だけを検索
                    order="date", #新しい順に取得
                    maxResults=50 #50件ずつ取得
                ).execute()


    while True:
        list_items = res["items"] #返ってくるデータのうち["items"]だけをリストとして保存

        # nextPageTokenがある限り検索する。
        if "nextPageToken" in res:
            pageToken = res["nextPageToken"]
            res = youtube.search().list(
                            part='snippet',
                            channelId=channelId,
                            type='video',
                            order="date",
                            pageToken=pageToken, #PageTokenを入れると検索結果の続きが取得できる
                            maxResults=50
                    ).execute()
        else:
            break

    # videoidとサムネイルのURLとタイトルのみ出力。サムネURLはHighを選ぶようにしている。  
    with open( f"{channelId}_list.txt", "w") as fp:
        for item in list_items:
            videoId = item["id"]["videoId"]
            thumburl = item["snippet"]["thumbnails"]["high"]["url"]
            title = item["snippet"]["title"]
            fp.write(f"{videoId},{thumburl},{title}¥n")

if __name__  == "__main__":
    YOUTUBE_API_KEY = "xxxxxx" #ここにAPIKEYを入れる
    CHANNELID = "UCOefINa2_BmpuX4BbHjdk9A" #ここに任意のチャンネルIDを入れる。Youtubeのチャンネル画面から見れる。

    GetVideolList(YOUTUBE_API_KEY, CHANNELID)
```
ここではリストを「チャンネルID_list.txt」としてファイル出力するようにした。含まれるものは動画IDとサムネイルのURL、動画タイトルとした。
なお、ここで各動画のdescriptionも取得できるのだが、長いものは省略されているため、後で別に取得するようにした。

サムネイルのURLはここでは取得しているだけだが、実際に画像として取得したいなら以下のような形で実行できる。

```python
def GetThumbnail(url, filename):
    res = requests.get(url)
        
    with open(filename, "wb") as fp:
        fp.write(res.content)
```

返ってくる値の詳細は以下のリファレンスを参照した。
[YouTube Data API Search: list](https://developers.google.com/youtube/v3/docs/search/list?hl=ja)


# 動画からコメントと詳細を取得する
リストでは動画の情報の一部でしか取得できないため、再生数やその他情報を取得するためには動画単体から情報を取得する必要がある。
これはAPIの"videos"を用いることで実現できる。同様に動画についたコメントも動画IDを元に"commentThreads API"で取得することができる。

先程手に入れたリストから情報を取得したい動画IDを引数になるようにしてスクリプトを実行すると、動画情報、コメントを含んだ情報をJSONとして保存した。
実際に使う局面ではリストを読み込ませて多数の動画から一気に情報を出力すると思うので、保存用のフォルダを作成し、そこに情報を保存する処理(makedirs)を入れている。

```python
import json
from googleapiclient.discovery import build
import os
import os.path

VIDEOINFOPATH="videoinfo_data"
COMMENTDATAPATH="comment_data"

def GetVideoInfo(api_key, videoId, folder_name=VIDEOINFOPATH):
    os.makedirs(folder_name, exist_ok=True) #保存用ディレクトリを作成する

    youtube = build('youtube', 'v3', developerKey = api_key)
    res = youtube.videos().list(
            part='snippet,statistics,liveStreamingDetails', #統計情報を取得する
            id=videoId
        ).execute()

    with open(os.path.join(folder_name,f"{videoId}_videoinfo.json"),"w") as fp:
        json.dump(res,fp, ensure_ascii=False)

def GetCommentThreads(api_key, videoId, folder_name=COMMENTDATAPATH):
    os.makedirs(folder_name, exist_ok=True) #保存用ディレクトリを作成する

    youtube = build('youtube', 'v3', developerKey = api_key)
    res = youtube.commentThreads().list(
        part="snippet,replies", #リプライも取得する
        videoId=videoId,
        textFormat="plainText", #デフォルトはHTML。ここでは好みでテキストで取得する
        maxResults=100 #100件まで取得(最大値)
        ).execute()

    comment_items = []
    while True:
        comment_items.append([res["items"]])

        if "nextPageToken" in res:
            pageToken = res["nextPageToken"]
            res = youtube.commentThreads().list(
                part="snippet,replies",
                videoId=videoId,
                textFormat="plainText",
                pageToken=pageToken,
                maxResults=100
                ).execute()
        else:
            break


    #一度辞書型にして保存
    items_dic = {"items": comment_items}
    with open( os.path.join(folder_name,f"{videoId}_comment.json"), "w") as fp:
        json.dump(items_dic, fp, ensure_ascii=False) #ensure_asciiをFalseにしないと日本語がエスケープされる

if __name__ == "__main__":
    YOUTUBE_API_KEY = "xxxxxx" #ここにAPIKEYを入れる
    VIDEOID = "A7_P0wIRc2o" "ここにVIDEOIDを入れる
    GetVideoInfo(YOUTUBE_API_KEY, VIDEOID)
    GetCommentThreads(YOUTUBE_API_KEY, VIDEOID)
```
mainで先程のリストを読み込んでforで回す等を入れると複数の動画に対応できる。保存したのはただのJSONなので、再生数や詳細等、欲しい情報は後から抽出・加工することができる。
Vの動画ではあまり無いと思うが、コメントが禁止されている動画等では挙動がおかしくなるかもしれない（エラー処理はしていないので）。

ここで保存したファイルには以下のような内容が含まれている。

```json

            "snippet": {
                "publishedAt": "2019-10-03T14:35:59.000Z",
                "channelId": "UCOefINa2_BmpuX4BbHjdk9A",
                "title": "【マイクラ】カメの島を作りたい！【アイドル部】",
                "description": "今日の目標°˖✧◝(⁰▿⁰)◜✧˖°\n\n・カメを育てる\n・カメの島を作る場所を決める\n\n以上！\n\n\n\n-------------------------------------\n\n\n【タグ】\n#アイドル部　　　アイドル部のお話\n#夜桜たま　　　　私のお話\n#TamaArt　 　　　恐れ多いファンアート\n\nTwitter ： https://twitter.com/YozakuraTama\n公式サイト ： https://vrlive.party/member/\n\n_",
                "thumbnails": {
                    "default": {
                        "url": "https://i.ytimg.com/vi/A7_P0wIRc2o/default.jpg",
                        "width": 120,
                        "height": 90
                    },
                    "medium": {
                        "url": "https://i.ytimg.com/vi/A7_P0wIRc2o/mqdefault.jpg",
                        "width": 320,
                        "height": 180
                    },
                    "high": {
                        "url": "https://i.ytimg.com/vi/A7_P0wIRc2o/hqdefault.jpg",
                        "width": 480,
                        "height": 360
                    }
                },
                "channelTitle": "夜桜たま",
                "categoryId": "20",
                "liveBroadcastContent": "none",
                "localized": {
                    "title": "【マイクラ】カメの島を作りたい！【アイドル部】",
                    "description": "今日の目標°˖✧◝(⁰▿⁰)◜✧˖°\n\n・カメを育てる\n・カメの島を作る場所を決める\n\n以上！\n\n\n\n-------------------------------------\n\n\n【タグ】\n#アイドル部　　　アイドル部のお話\n#夜桜たま　　　　私のお話\n#TamaArt　 　　　恐れ多いファンアート\n\nTwitter ： https://twitter.com/YozakuraTama\n公式サイト ： https://vrlive.party/member/\n\n_"
                },
                "defaultAudioLanguage": "ja"
            },
            "statistics": {
                "viewCount": "98474",
                "likeCount": "3511",
                "dislikeCount": "213",
                "favoriteCount": "0",
                "commentCount": "420"
            },
            "liveStreamingDetails": {
                "actualStartTime": "2019-10-03T13:00:14.000Z",
                "actualEndTime": "2019-10-03T14:01:02.000Z",
                "scheduledStartTime": "2019-10-03T13:00:00.000Z"
            }

```
動画のdescriptionは、APIの"search"でも取得できるが、こちらのほうが省略なしのバージョンとなる。
また、再生数や高評価は"statistics"の部分に情報が含まれている。
配信の情報はliveStreamingDetailsの部分に格納されている。と言ってもライブの開始終了時刻くらいしか後からはわからない。

このような情報は後から加工し、分析に使えたりするかもしれない。

参考としたリファレンス
[YouTube Data API Videos: list](https://developers.google.com/youtube/v3/docs/videos/list?hl=ja)
[YouTube Data API CommentThreads: list](https://developers.google.com/youtube/v3/docs/commentThreads/list?hl=ja)

### ライブチャットのデータを取得する
ライブチャットのデータは、ライブ中であれば上記の"liveStreamingDetails"に含まれる"activeLiveChatId"を元にLiveStreamingAPIから取得できるようだが、終わってしまうともう取得できない。もちろん現実にはアーカイブを再生すればライブチャットのリプレイを見ることができる。つまり情報は残っているのだ。
以下を参考にして、スクレイピングを用いてチャットの情報を取得した。（ほぼまんまだけど）
[20181008 PythonでYouTube Liveのアーカイブからチャット（コメント）を取得する（改訂版）](http://watagassy.hatenablog.com/entry/2018/10/08/132939)

```python
from bs4 import BeautifulSoup
import requests
import sys
import os, os.path

LIVECOMMENTPATH="livecomment_data"

def GetLiveComment(target_id, folder=LIVECOMMENTPATH):
    target_url = "https://www.youtube.com/watch?v=" + target_id
    comment_data = []
    session = requests.Session()
    headers = {"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36"}

    html = requests.get(target_url)
    soup = BeautifulSoup(html.text, "html.parser")

    for iframe in soup.find_all("iframe"):
        if("live_chat_replay" in iframe["src"]):
            next_url = iframe["src"]
            break

    while True:
        try:
            html = session.get(next_url, headers=headers)
            soup = BeautifulSoup(html.text,"lxml")

            for scrp in soup.find_all("script"):
                if "window[\"ytInitialData\"]" in scrp.text:
                    dict_str = scrp.text.split(" = ")[1]
                    break
            
            dict_str = dict_str.replace("false", "False")
            dict_str = dict_str.replace("true", "True")

            dict_str = dict_str.rstrip("; \n")
            dics = eval(dict_str)
            
            for comment in dics["continuationContents"]["liveChatContinuation"]["actions"][1:]:
                comment_data.append(str(comment) + "\n")
                #次のURLを検索
                continuation = dics["continuationContents"]["liveChatContinuation"]["continuations"][0]["liveChatReplayContinuationData"]["continuation"]

            temp = "https://www.youtube.com/live_chat_replay?continuation=" + continuation
            if temp == next_url:
                #時々何回取得してもnext_urlが更新されなくなるときがある。そうなると打ち切り。
                break
            next_url = temp
        #next_urlがなくなる等で例外になるのでそこでbreak
        except:
            break

    
    os.makedirs(folder,exist_ok=True) 
    path = os.path.join(folder, target_id +"_livecomment.txt")
    with open(path,"w") as fp:
        fp.writelines(comment_data)

if __name__ == "__main__":
    VIDEOID="A7_P0wIRc2o"
    GetLiveComment(VIDEOID)

```
これを実行すると動画IDに紐づくライブチャットやユーザー名、発言時刻等の情報を含んだデータをテキストファイルとして出力する。ライブチャットの存在しないただの動画やライブチャットの再生できないアーカイブで実行すると何も取得できない。

なお、上記の動画IDでは、コメントが1万個以上あったこともあり、取得まで5分程度かかった。（コメントは100個ずつしか取れないようだ）多数の動画から取得したい場合はそれなりに時間がかかることが予想される。もしライブチャットのコメントを保存したいのであれば余裕を持って実行するべきだろう。

### まとめ
以上のような手段を以て、推しのVのデータを取得することができた。
チャンネルに付随する動画のリスト、各動画の統計的情報、コメント、ライブチャットの取得方法は別の機会にも活かすことができるだろう。そんな日が来ないことを願っているが。
ここで得たデータの加工方法については今後も考えてみようと思う。

…正直、アーカイブ消す風潮はなくなってほしい。