主な動画と共にコメントが流れるサイトのコメント取得・動画情報取得まとめ

# はじめに

　この度、[タイトルにあるような技術を使ったアプリ](https://kumas.booth.pm/items/1879669)を作ったので、そのやり方について説明します。Twitchの動画情報取得以外は非公式のAPIを使っているのでいつの間にか変更されているかもしれません。Twitchも2020年4月30日にAPIが変わるみたいです。なお、情報は**2020年2月8日**の時点です。
　実装はC#でしましたが、具体的な実装方法についてはここに書きません。（参考にしたところだとC#とPythonが多かった）
　※2022/05/15：各サイトが更新されて使えなくなっていたため内容を修正。

# コメントスクレイピングの基礎

　スクレイピングはそれなりに相手のサーバーに負荷をかけます。スクレイピング間隔を設け、気をつけましょう。

## ①公式APIを探す

　まずはここから探すのが基本です。まとまった形式のデータを取得するのにも、相手のサーバーの負荷を減らすのにもまずはここから調べましょう。

## ②通信から調べる

　公式APIに目的のものが無く、ほしい情報があるときは、Chromeの開発者ツールのNetworkingを監視します。特にコメントの場合は、常にダウンロードしなければコメントが流れていきません。目的の情報をここで見つけましょう。

## ③リクエストヘッダを確認

　どのところでコメント取得しているか確認したら、リクエストヘッダを見て何の情報が必要か確認します。場合によっては、クエリパラメータ、ボディ、クッキーが必要になることもあります。

## ④必要な情報を取得

　困ったら、元のhtmlを見るといいかもしれません。意外と中に書いてあるかも。

# YouTube Live

## 動画情報

　動画の情報は、動画のページをGETで取得するとすべて書かれています。どこに書かれているかというと、以下から始まる`<script>`要素のところです。

```html
<script>var ytplayer = ytplayer || {};ytplayer.config= //以下省略
```

　そして、この`ytplayer.config`にJSONを代入しているわけなんですが、そのJSONの`args.player_response`に文字列化されたJSONとして動画情報（というよりページの情報）が入っています。そこから抽出しましょう。

　抽出には、以下のような正規表現を使うといいかと思います。

```csharp
Regex.Match("文字列","\\\"目的のキー\\\":\\\"[^\\]+\\\"");  // 「\」 多すぎw
```

　動画の長さを調べる場合には以下のようになります。

```csharp
Regex.Match("文字列","\\\"lengthSeconds\\\":\\\"[^\\]+\\\"");
// \"lengthSeconds\":\"***\"
```

## コメント

　まず、YouTube Liveのコメントはブラウザからでないと見れません。なので、リクエストヘッダの`User-Agent`にブラウザの物に変えてやります。Chromeであれば、`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36`です。
　YouTube Liveのコメントはどこにアクセスして取得すればいいのかというと、動画のページに埋め込まれています。`live-chat-iframe`というidの振られた要素の`src`属性がコメントのあるURLです。そこにGETリクエストを送ればコメント（の一部）が取得できます。
　レスポンスはhtmlで返ってきて、コメントは、`script`要素で、`window["ytInitialData"]`から始まるものにJSONとして代入されています。`window["ytInitialData"] = `と`;`を取り除いてJSONとして処理しましょう。
　コメントのデータはそのJSONの`continuationContents.liveChatContinuation.actions`の配列の最初のものを飛ばしたものとなっています。コメントは3種類あって、普通のコメント、スパチャ1、スパチャ2です。

### 普通のコメント

 コメントは、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message`に中に入っていて、`simpletext`または`runs`の配列となっています。
　投稿された時間は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampText.simpleText`に、xx:xxという形で入っています。
 投稿主は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText`の中に入っています。

### スパチャ1

　コメントは、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidMessageRenderer.message`に中に入っていて、`simpletext`または`runs`の配列となっています。
　投稿された時間は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidMessageRenderer.timestampText.simpleText`に、xx:xxという形で入っています。
 投稿主は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidMessageRenderer.authorName.simpleText`の中に入っています。
　スパチャ金額は、
`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidMessageRenderer.purchaseAmountText.simpleText`の中に￥x,xxxという形で入っています。

### スパチャ2

　コメントは、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidStickerRenderer.message`に中に入っていて、`simpletext`または`runs`の配列となっています。
　投稿された時間は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidStickerRenderer.timestampText.simpleText`に、xx:xxという形で入っています。
 投稿主は、`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidStickerRenderer.authorName.simpleText`の中に入っています。
　スパチャ金額は、
`continuationContents.liveChatContinuation.actions[n].replayChatItemAction.actions[0].addChatItemAction.item.liveChatPaidStickerRenderer.purchaseAmountText.simpleText`の中に￥x,xxxという形で入っています。

### 次のURL

　動画ページにのっているURLからは動画が始まってから一定数のコメントしか取得できません。次のURLに移る必要があります。その次のURLは`https://www.youtube.com/live_chat_replay?continuation=`に`continuationContents.liveChatContinuation.continuations[0].liveChatReplayContinuationData.continuation`の中身を足したものです。

## 参考サイト

[PythonでYouTube Liveのアーカイブからチャット（コメント）を取得する](http://watagassy.hatenablog.com/entry/2018/10/06/002628)
[PythonでYouTube Liveのアーカイブからチャット（コメント）を取得する（改訂版）](http://watagassy.hatenablog.com/entry/2018/10/08/132939)

# ニコ動

## 動画情報

　動画の情報は、非公式APIの`https://ext.nicovideo.jp/api/getthumbinfo/`に動画のid(smxxxxxxxとかいうやつ)を足したところにGETリクエストを送るとXMLで帰ってきます。以下がレスポンスの例。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<nicovideo_thumb_response status="ok">
  <thumb>
    <video_id>ビデオid</video_id>
    <title>タイトル</title>
    <description>動画説明</description>
    <thumbnail_url>サムネイルのあるURL</thumbnail_url>
    <first_retrieve>投稿日時</first_retrieve>
    <length>x:xx</length>
    <movie_type>mp4</movie_type>
    <size_high>11155011</size_high>
    <size_low>10519950</size_low>
    <view_counter>視聴回数</view_counter>
    <comment_num>コメント数</comment_num>
    <mylist_counter>マイリス数</mylist_counter>
    <last_res_body>最後に投稿されたコメント</last_res_body>
    <watch_url>動画のURL</watch_url>
    <thumb_type>video</thumb_type>
    <embeddable>1</embeddable>
    <no_live_play>0</no_live_play>
    <tags domain="jp">
      <tag category="1" lock="1">いろんなタグ</tag>
      <tag lock="1">いろんなタグ</tag>
      <tag>いろんなタグ</tag>
      <tag>いろんなタグ</tag>
    </tags>
    <genre>ジャンル</genre>
    <user_id>投稿者のuser_id</user_id>
    <user_nickname>投稿者の名前</user_nickname>
    <user_icon_url>投稿者のアイコン</user_icon_url>
  </thumb>
</nicovideo_thumb_response>
```

## コメント

　コメントを取得する非公式APIのエンドポイントは ~~`https://nmsg.nicovideo.jp/api.json/`~~ `https://nvcomment.nicovideo.jp/legacy/api.json/` です。(legacyついているから変更されそう。)ここに適切なコマンドをPOSTするとその時点での動画に表示されるコメントが返ってきます。約1000コメントが要約されて返ってきます。

### コマンドを作るための情報取得

　コマンドを作るための情報は、動画ページのhtmlにのっています。`js-initial-watch-data`とidの振られた要素の`data-api-data`属性の値にhtmlエンコードされたJSONとして情報がのっています。
　使うのは、 ~~`context.userkey`~~ `comment.keys.userKey`と ~~`commentComposite.threads`~~ `comment.threads`の各要素です。`comment.threads`で使うのは、`isActive`、`label`、`id`、`fork`、`isLeafRequired`、`isThreadkeyRequired`です。

### コマンド作成

　コマンドは、`isActive`が`true`になっているものだけ作ります。最小のコマンドを基礎とし、`isLeafRequired`、`isThreadkeyRequired`を見てさらにコマンドを追加します。

### 最小コマンド

　コマンドの中で最小（というか基礎）となるのは以下の通りです。

```js
[
    {
        "ping": {
            "content": "rs:0"  //コマンド送信ごとにインクリメント
        }
    },
    {
        "ping": {
            "content": "ps:0" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "thread": {
            "thread": "", // id  を入れる
            "version": "20090904",
            "fork": , // fork を入れる
            "language": 0,
            "user_id": "", // ニコニコのユーザーidを入れる。空欄だとゲスト。
            "with_global": 1,
            "scores": 1,
            "nicoru": 3,
            "userkey": "" // context.userkey を入れる
        }
    },
    {
        "ping": {
            "content": "pf:0" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "ping": {
            "content": "rf:0" //コマンド送信ごとにインクリメント
        }
    }
]
```

### isLeafRequiredがtrueのとき

　isLeafRequiredがtrueだと、基礎のコマンドに加え、追加のコマンドが必要です。その結果、以下のようになります。

```js
[
    {
        "ping": {
            "content": "rs:1"  //コマンド送信ごとにインクリメント
        }
    },
    {
        "ping": {
            "content": "ps:6" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "thread": {
            "thread": "", // id  を入れる
            "version": "20090904",
            "fork": , // fork を入れる
            "language": 0,
            "user_id": "", // ニコニコのユーザーidを入れる。空欄だとゲスト。
            "with_global": 1,
            "scores": 1,
            "nicoru": 3,
            "userkey": "" // context.userkey を入れる
        }
    },
    {
        "ping": {
            "content": "pf:6" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "ping": {
            "content": "ps:7" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "thread_leaves": {
            "thread": "", // id  を入れる
            "language": 0,
            "user_id": "", // ニコニコのユーザーidを入れる。空欄だとゲスト。
            "content": "0-x:100,y,nicoru:100",  //xは動画の長さ（分切り上げ）、yは x<=1 だと100、x<=5 だと 250、x<=10だと500、それ以上だと1000っぽい。
            "scores": 1,
            "nicoru": 3,
            "userkey": "" // context.userkey を入れる
        }
    },
    {
        "ping": {
            "content": "pf:7" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "ping": {
            "content": "rf:1" //コマンド送信ごとにインクリメント
        }
    }
]
```

### isThreadkeyRequiredがtrueのとき

　要は公式動画のときですが、`threadkey`と `force184` が必要になります。 ~~`http://flapi.nicovideo.jp/api/getthreadkey?thread=`に`id`を足したところにGETリクエストを送るとレスポンスとして以下のように返ってきます。~~ 

```
threadkey=****&force_184=*
```
　先ほど解析したhtmlの `comment.threads` の `threadkey` と `is184Forced` にあります。
　これらを使います。そして、コマンドが以下のように変わります。isLeafRequiredもtrueのときとして書いているので、isLeafRequiredがfalseのときは前半のみで大丈夫です。

```js
[
    {
        "ping": {
            "content": "rs:2" //コマンド送信ごとにインクリメント
        }
    },
    {
        "ping": {
            "content": "ps:12" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "thread": {
            "thread": "", // id  を入れる
            "version": "20090904",
            "fork": ,  // fork を入れる
            "language": 0,
            "user_id": "", // ニコニコのユーザーidを入れる。空欄だとゲスト。
            "force_184": "", // trueのとき1、falseのとき0を入れる
            "with_global": 1,
            "scores": 1,
            "nicoru": 3,
            "threadkey": "" // threadkeyを入れる
        }
    },
    {
        "ping": {
            "content": "pf:12" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "ping": {
            "content": "ps:13" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "thread_leaves": {
            "thread": "", // id  を入れる
            "language": 0,
            "user_id": "", // ニコニコのユーザーidを入れる。空欄だとゲスト。
            "content": "0-x:100,y,nicoru:100",  //xは動画の長さ（分切り上げ）、yは x<=1 だと100、x<=5 だと 250、x<=10だと500、それ以上だと1000っぽい。
            "scores": 1,
            "nicoru": 3,
            "force_184": "", // trueのとき1、falseのとき0を入れる
            "threadkey": "" // threadkeyを入れる
        }
    },
    {
        "ping": {
            "content": "pf:13" //コマンドごとにインクリメント。送信で5とか10とかインクリメント
        }
    },
    {
        "ping": {
            "content": "rf:2" //コマンド送信ごとにインクリメント
        }
    }
]
```

## さらにコメントを取得する

　コメントをさらに取得することもできます。ニコニコには、時期を戻ってコメントを取得する機能があるので、そちらを使えば別のコメントも取得できます。（ログイン必須となりました。）それには`when`と`waybackkey`が必要です。
　ログインは、`https://account.nicovideo.jp/login/redirector`へフォームとして `mail_tel:ログインするメールアドレス`、`password:パスワード`を送信すればセッションのクッキーが返ってくるので、それを使えばログイン状態を維持できます。
　`when`は、戻りたいときのUNIX時間、`waybackkey`は`https://flapi.nicovideo.jp/api/getwaybackkey?thread=`に`id`を足したところにGETリクエストを送ると以下のようなレスポンスが返ってきます。

```
waybackkey=*
```

この`waybackkey`です。

`when`は`language`プロパティの後に数値として、`waybackkey`はコマンドの最後に文字列としてそれぞれ追加したコマンドを作成すると更なるコメントを取得できます。

##参考サイト
[ニコニコ動画のコメントをJSONで取得したり投稿したり](https://qiita.com/tor4kichi/items/74939b49954d3e72d789)
[python3系でニコニコ動画のコメントを取得してみた.](https://qiita.com/gabdro/private/636c5e9e02f143b273e6)


# ニコ生

## 動画情報

　ニコ生で動画の情報は、動画のあるhtmlページに埋め込まれています。`embedded-data`というidの`data-props`という属性にJSONがhtmlエンコードされて埋まっています。動画情報は、`program`以下にあります。

## コメント

　ニコ生でのコメント取得ではWebSocket+JSONで行います。WebSocketですが、ニコ動と同じようにコマンドを送り、レスポンスが返ってきます。まずは、動画情報等が入っているJSONの`site.relive.webSocketUrl` + `"&frontend_id="` + `site.frontendId`というところにWebSocketで接続してコメントを取得するためのデータを取得します。以下のようなコマンドを送ると、いくつか正常なレスポンスが返ってきます。

```json:旧データ
{
    "type": "watch",
    "body": {
        "command": "getpermit",
        "requirement": {
            "broadcastId": "320976328",
            "route": "",
            "stream": {
                "protocol": "hls",
                "requireNewStream": true,
                "priorStreamQuality": "low",
                "isLowLatency": true,
                "isChasePlay": false
            },
            "room": {
                "isCommentable": true,
                "protocol": "webSocket"
            }
        }
    }
}
```

```json:更新時のデータ
{
    "type": "startWatching",
    "data": {
        "reconnect": "false",
        "room": {
            "commentable": true,
            "protocol": "webSocket"
            }
        },
        "stream": {
            "chasePlay": "false",
            "latency": "low",
            "protocol": "hls",
            "abr": "abr"
        }
    }
}
```

　必要なレスポンスは以下の通りです。

```json:旧データ
{
    "type": "watch",
    "body": {
        "room": {
            "messageServerUri": "",
            "messageServerType": "niwavided",
            "roomName": "アリーナ 最前列",
            "threadId": "",
            "forks": [
                0
            ],
            "importedForks": [],
            "isFirst": true,
            "waybackkey": ""
        },
        "command": "currentroom"
    }
}
```

```json:更新時のデータ
{
    "type": "room",
    "data": {
        "name": "アリーナ",
        "messageServer": {
            "uri": "",
            "type": "niwavided"
        },
        "threadId": "",
        "isFirst": true,
        "waybackkey": "waybackkey", // waybackkeyとなっているときは取得していない
        "vposBaseTime": ""
    }
}
```

　ここで必要なのは、 ~~`body.room.messageServerUri`、`body.room.threadId`、`body.room.waybackkey`~~ `data.threadId`、`data.messageServer.uri`です。

```json:旧データ
{
    "type": "watch",
    "body": {
        "command": "watchinginterval",
        "params": [
            "30"
        ]
    }
}
```

```json:更新時のデータ
{
    "type": "seat",
    "data": {
        "keepIntervalSec": "30"
    }
}
```

　この ~~`body.params`~~ `data.keepIntervalSec`の値が、コメントを取得する間隔（秒数）です。この場合だと、30秒ずつのコメントが取得されるので、コメントの取得する部分は30秒ずつずらします。

　また、一定時間ごとにpingが飛んでくるのでpongを返しましょう。

　さて、ここからコメント取得をしていきます。そのためには動画ページのJSONの`program.beginTime`も使います。まずは、`data.messageServer.uri`にWebSocketでアクセスします。そして、以下のようなコマンドを送ります。

```js
[
    {
        "ping": {
            "content": "rs:0"  //例のごとくコマンドを送信するとインクリメントされる
        }
    },
    {
        "ping": {
            "content": "ps:0"  //例のごとくコマンドごとにインクリメントされ、コマンド送信ごとに5とか10増える
        }
    },
    {
        "thread": {
            "thread": "",  // data.threadIdを入れる
            "version": "20061206",
            "when": , //どの時点からコメントをさかのぼるか。最初はprogram.beginTimeを入れればいい
            "user_id": "",  //ニコニコのユーザーidを入れる。空欄だとゲスト。
            "res_from": , //どのコメントまでさかのぼるか。0以下だと、コメント数、1以上だとコメントNo。最初は-200とか入れればいい。
            "with_global": 1,
            "scores": 1,
            "nicoru": 0,
            "waybackkey": "" // data.waybackkeyを入れる
        }
    },
    {
        "ping": {
            "content": "pf:0"  //例のごとくコマンドごとにインクリメントされ、コマンド送信ごとに5とか10増える
        }
    },
    {
        "ping": {
            "content": "rf:0"  //例のごとくコマンドを送信するとインクリメントされる
        }
    }
]
```

　そうすると、以下のレスポンスが返ってきます。

```json
{
    "thread": {
        "resultcode": 0,
        "thread": 1653481833,
        "last_res": 511,
        "ticket": "0x8116d80",
        "revision": 1,
        "server_time": 1581592898
    }
}
```

　この、`thread.last_res`が次のコマンドを作るのに必要です。この`thread.last_res`は受信する最後のコメントNoです。次にコマンドを送信する際は、`thread.res_from`を`thread.last_res`に1足して、`thread.when`を`body.params`だけ足したコマンドを送信すれば連続的なコメントを取得できます。

　コメント自体は以下のようなJSONで送られます。

```js
{
    "chat": {
        "thread": ,
        "vpos": , //コメントが表示される時間。基準はprogram.vposBaseTimeであって、動画での時間ではないのに注意
        "date": ,
        "date_usec":,
        "mail": "",
        "user_id": "",
        "anonymity": 1,
        "locale": "ja-jp",
        "content": ""  //コメントの内容
    }
}
```

# Twitch

　Twitchは[公式API](https://dev.twitch.tv/docs/)がしっかりしているので、そちらをまず参考にしましょう。
　まずは、[Twitch Developer](https://dev.twitch.tv/dashboard/apps/create)として登録します。そして、アプリを登録します。OAuthはnew APIでは必要になってくるので作りましょう。認証についての公式の説明は[こちら](https://dev.twitch.tv/docs/authentication)です。
 アプリごとに、クライアントIDが振られるので記録しておきます。また、クライアントの秘密（シークレットキー）を生成できます。こちらはユーザーの届かないところで使いましょう。これを使うことによってアプリケーションキーを生成でき、これをAPIの認証（Bearer）に使うことによってAPIの使用できる量が大幅に増します。

## 動画情報

　※2020年4月30日に置き換わるAPIについて解説していきます。すでに使えます。
　動画情報を得るnew APIのURLは`https://api.twitch.tv/helix/videos`です。クエリパラメータ―として、`video_id`、`user_id`、`game_id`を与えることができます。`video_id`は動画のURLの末尾の数字です。
　このURLに、リクエストヘッダとして`Client-ID:xxx`を付け加えて（Bearer認証は任意）GETリクエストを送ると以下のようなレスポンスが返ってきます。

```js
{
  "data": [{
    "id": "", //動画のid
    "user_id": "", //user_id
    "user_name": "", //ユーザーの名前
    "title": "", //動画のタイトル
    "description": "", //動画の説明
    "created_at": "", //動画が作られた日時
    "published_at": "", //動画が公開された日時
    "url": "", //動画のURL
    "thumbnail_url": "", //サムネイルのURL
    "viewable": "public", //公開されているかどうか
    "view_count": , //視聴回数
    "language": "en", //言語
    "type": "archive",
    "duration": "" //動画の長さ
  }],
  "pagination":{"cursor":"eyJiIjpudWxsLCJhIjoiMTUwMzQ0MTc3NjQyNDQyMjAwMCJ9"}
}
```

## コメント

　コメントについては公式APIがありませんでした。なのでブラウザと同じように動かします。
　コメントを取得するには、`https://api.twitch.tv/v5/videos/` + 動画id + `/comments`にGETリクエストを送ると取得できます。クエリパラメーターでどこのコメントを拾うか決めれます。
 クエリパラメーターは`content_offset_seconds=`に数値でコメントの拾い始める時間を、`cursor=`にコメントidでそのコメント（も含めた）以降のコメントを取得できます。
　コメントは、`comments`に配列として入っています。レスポンスの次のコメントidは`_next`に入っています。
　`comments.content_offset_seconds`に動画内の位置、`comments.commenter.display_name`に表示名、`comments.commenter.name`に登録名、`comments.message.body`にコメントの中身が入っています。
　チアーは、コメントの中身に`Cheer` + 数字が入っているかで判断するしかありませんでした。

# おわりに

　スクレイピング記事にすると見づらいし、長いですね…