ニコニコ動画で視聴している動画をDiscord Rich Presenceで共有する拡張機能を作る

# TL;DR
ニコ動を視聴したら、自動的にDiscordのRich Presence[^1]に共有される拡張機能を作ります。
(本記事のコードはGithubに公開されています。[okaits/nicovideo2discord](https://github.com/okaits/nicovideo2discord))

# 構成
```mermaid
sequenceDiagram
actor user as ユーザー
participant browser as ブラウザー
participant script as コンテンツスクリプト
participant server as サーバープログラム
participant client as クライアントプログラム
participant api as ニコニコ動画API
participant discord as Discord
activate client
client->>+server: POST /login
server->>-client: JWT
deactivate client
activate client
client->>+server: GET /video
server->>-client: JSON
deactivate client
user->>+browser: ニコニコ動画プレイヤーを開く
browser->>+script: 実行
script->>+server: POST /login
server->>-script: JWT
Note over client: 時間経過
activate client
client->>+server: GET /video
server->>-client: JSON
deactivate client
user->>browser: 動画を再生
Note over script: 動画の再生を検知
script->>+server: POST /video
server->>-script: OK
Note over client: 時間経過
activate client
client->>+server: GET /video
server->>-client: JSON
Note over client: 動画の再生を検知
client->>+server: GET /videoinfo
server->>+api: GET /api/getthumbinfo/sm*
api->>-server: XML
Note over server: キャッシュする
server->>-client: XML
client->>+discord: Presenceの変更
discord->>-client: OK
deactivate client
user->>browser: 動画を停止
Note over script: 動画の停止を検知
script->>+server: POST /video
server->>-script: OK
Note over client: 時間経過
activate client
client->>+server: GET /video
server->>-client: JSON
Note over client: 動画の停止を検知
client->>+discord: Presenceの変更
discord->>-client: OK
deactivate client
user->>browser: ニコニコ動画のプレイヤーを終了
browser->>script: 終了
script->>+server: POST /video
deactivate script
server->>-script: OK
deactivate browser
Note over client: 時間経過
activate client
client->>+server: GET /video
server->>-client: JSON
Note over client: プレイヤーの終了を検知
client->>+discord: Presenceのクリア
discord->>-client: OK
deactivate client
```

# ブラウザ拡張機能を作る
## manifest.jsonを作る
manifest.jsonとは、拡張機能の名前やバージョン、実行するファイル名やコンテンツスクリプトのトリガーなどを指定するファイルです。
```json
{
    "manifest_version": 3,
    "name": "nicovideo2discord",
    "version": "0.0.1",
    "description": "ニコニコ動画を見ている際の動画情報をDiscordに表示します。",
    "host_permissions": [
        "http://localhost:5000/*"
    ],
    "content_scripts": [
        {
            "matches": ["*://*.nicovideo.jp/watch/s*"],
            "js": ["jquery.js", "main.js"]
        }
    ]
}
```
### 解説
* `"manifest_version": 3`
    manifest.jsonのバージョンです。今回はv3を使います。
* `"name": "nicovideo2discord"`
    拡張機能の名前です。それっぽいのにします。
* `"version": "0.0.1"`
    拡張機能のバージョンです。
* `"description": "(略)"`
    拡張機能の概要です。
* `""host_permissions": ["http://localhost:5000/*"]`
    拡張機能からアクセスするAPIなどを指定します。これがないとAPIにアクセスする際にCORS要求が飛ぶのですが、一部ブラウザではCORS要求に失敗してAPIにアクセスできないことがあるので指定しています。
* `"content_script"`
    * `"matches": ["*://*.nicovideo.jp/watch/sm*"]`
        コンテンツスクリプトのトリガーです。ここに指定されたサイトを開くとコンテンツスクリプトが実行されます。今回はニコニコ動画の視聴ページです。
    * `"js": ["jquery.js", "main.js"]`
        トリガーを引いた際に実行されるJavascriptファイルです。今回はjQueryを使用してサーバープログラムのAPIを叩くので、先に`jquery.js`を実行させて、それから`main.js`を実行させています。
## JQueryを使えるようにする
拡張機能からサーバーにアクセスする処理を簡単に書くため、JQueryをダウンロードしてmanifest.jsonと同じディレクトリにjquery.jsとして置いておきます。
## 認証
今回は、サーバープログラムに再生状態を送信・受信するための認証方法として、JWTを使います。
## 拡張機能本体を書く
拡張機能本体（動画の再生状態を検知して送信する）を書きます。

```js
token = ""
function beforeunload(token) {
    // ページを閉じた時にサーバーに通知する
    body = JSON.stringify({"status": "closed"});
    fetch("http://localhost:5000/video", {
        method: 'POST',
        body: JSON.stringify({"status": "closed"}),
        headers: {
            "Content-type": "application/json",
            "Authorization": "Bearer " + this.token
        },
        keepalive: true
    });
};

function ajaxerror(xhr, testStatus, errorThrown) {
    // トークンの有効期限が切れたら再読込する
    if (xhr.status == 401) {
        console.log("Reloading token...")
        token = gettoken("password")
    };
};

function sleep(ms) {
    // sleep関数を作る
    return new Promise(resolve => setTimeout(resolve, ms));
};

async function gettoken(password) {
    // トークンを取得する
    tokenrecv = $.ajax(
        {
            type: "POST",
            url: "http://localhost:5000/login",
            data: JSON.stringify({"user": "user1", "password": password}),
            contentType: "application/json; charset=UTF-8"
        }
    );
    while (true) {
        await sleep(50);
        if (tokenrecv.responseJSON == undefined) {
            continue;
        } else {
            token = tokenrecv.responseJSON["token"];
            window.addEventListener('beforeunload', {token: token, handleEvent: beforeunload});
            return token
        };
    };
};

token = gettoken("password");

async function loop() {
    // 再生状態をチェックして、変化があった場合サーバーに送信する
    var beforepaused = true;
    while (true) {
        await sleep(500);
        var player = document.getElementById("MainVideoPlayer").firstElementChild;
        var ispaused = player.paused;
        var time = player.currentTime;
        var hour = Math.floor(time / 3600);
        var min = Math.floor(time % 3600 / 60);
        var sec = time % 60;
        if (ispaused == beforepaused) {
            continue;
        };
        console.debug("Playing: " + !ispaused);
        var videoid = window.location.pathname.split("/").pop();
        $.ajax({
            type: "POST",
            url: "http://localhost:5000/video",
            data: JSON.stringify({'status': 'opened', 'videoid': videoid, 'playing': !ispaused, 'hour': hour, 'min': min, 'sec': sec}),
            headers: {"Authorization": "Bearer " + token},
            contentType: "application/json; charset=UTF-8",
            error: ajaxerror});
        beforepaused = ispaused;
    };
};
console.debug("start")
loop();
```
# 拡張機能以外の部分を作る
## サーバーを作る
サーバープログラムを作ります。
```python
"""
niconico-discord: server.py
データを扱うサーバープログラムです。
"""

import math
import json
import urllib.request
import xmltodict
import hashlib

from flask import Flask, jsonify, request, Response
from flask_cors import CORS
from flask_jwt_extended import jwt_required, create_access_token, JWTManager, get_jwt_identity

app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = "secret-password"
jwt = JWTManager(app)
CORS(app)
id_dict = {"user1": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"} #ユーザー名とパスワードのsha256ハッシュ
class Data():
    """ キャッシュと再生状態を記録するクラス """
    status = {"user1": {"status": "closed"}}
    cache = {}


def jwt_unauthorized_loader_handler(_):
    return jsonify({"msg": "unauthorized"}), "401 Unauthorized"
jwt.unauthorized_loader(jwt_unauthorized_loader_handler)

@app.route("/login", methods=["POST"])
def login():
    if request.content_type.split(";")[0] == "application/json": # 送られてきた認証情報の形式を確認
        data = request.json
    elif request.content_type.split(";")[0] == "text/plain": # Content-typeをapplication/jsonにするとCORSの関係で送れなくなるブラウザのためにtext/plainにも対応する
        data = json.loads(request.data)
    else:
        return jsonify({"msg": "bad content-type"}), "415 Unsupported media type"
    try:
        if data["user"] in id_dict: # 該当ユーザーが存在するか検証する
            if data["password"] == id_dict[data["user"]]: # 送られてきたパスワードがもともとハッシュ値で、そのまま認証データと比較できる場合やってしまう
                user = data["user"]
            else:
                pwhash = hashlib.sha256() # 送られてきたパスワードがハッシュ値でなくても認証するために送られてきたパスワードをハッシュ値にしてからもう一度比較する
                pwhash.update(data["password"].encode())
                if pwhash.hexdigest() == id_dict[data["user"]]:
                    user = data["user"]
                else:
                    return jsonify({"msg": "Unauthorized"}), "401 Unauthorized"
        else:
            return jsonify({"msg": "Unauthorized"}), "401 Unauthorized"
    except KeyError:
        return jsonify({"msg": "Unprocessable json"}), "400 Bad request" # ユーザーとパスワードのどちらかがなかった場合400を返す
    token = create_access_token(identity=user)
    return jsonify({"msg": "ok", "token": token}), "200 OK"

@app.route("/video", methods=["POST", "GET"])
@jwt_required()
def video():
    """ API /video: 動画の再生状態を記録・取得するAPI """
    if request.method == "POST":
        if request.content_type.split(";")[0] == "application/json":
            data = request.json
        elif request.content_type.split(";")[0] == "text/plain":
            data = json.loads(request.data)
        else:
            return jsonify({"msg": "bad content-type"}), "415 Unsupported media type"
        try:
            if data["status"] == "closed":
                Data.status[get_jwt_identity()] = {"status": data["status"]}
                return jsonify({"msg": "success"}), "201 Created"
            Data.status[get_jwt_identity()] = {"status": data["status"],"id": data["videoid"], "playing": data["playing"], "hour": data["hour"], "min": data["min"], "sec": str(math.floor(int(data["sec"])))}
        except KeyError:
            print(data)
            return jsonify({"msg": "missing value"}), "400 Bad Request"
        return jsonify({"msg": "success"}), "201 Created"
    elif request.method == "GET":
        return jsonify(Data.status[get_jwt_identity()]), "200 OK"

@app.route("/videoinfo", methods=["GET"])
def videoinfo():
    """ API /videoinfo: ニコニコ動画APIにアクセスして動画情報を取得してキャッシュする """
    vid = request.args.get("vid")
    if vid is None:
        return jsonify({"msg": "missing vid."}), "400 Bad Request"
    if vid in Data.cache:
        app.logger.debug("Using cache.") # キャッシュが存在した場合それを返す
        return Response(Data.cache[vid], status="200 OK", mimetype="application/xml")
    url = f"https://ext.nicovideo.jp/api/getthumbinfo/{vid}"
    app.logger.info("Not found in cache. Getting from nicovideo server.")
    with urllib.request.urlopen(url) as thumbinfo:
        thumbinfo_orig = thumbinfo.read()
        thumbinfo = xmltodict.parse(thumbinfo_orig)
    if thumbinfo["nicovideo_thumb_response"]["@status"] == "fail":
        if thumbinfo["nicovideo_thumb_response"]["error"]["code"] == "NOT_FOUND":
            return Response(thumbinfo_orig, status='404 Not found', mimetype="application/xml") # なかった場合404を返す
        elif thumbinfo["nicovideo_thumb_response"]["error"]["code"] == "DELETED":
            return Response(thumbinfo_orig, status="410 Gone", mimetype="application/xml") # 削除された場合410を返す
        else:
            return Response(thumbinfo_orig, status="400 Bad Request", mimetype="application/xml") # それ以外のエラーでは400を返す
    Data.cache[vid] = thumbinfo_orig # キャッシュする
    return thumbinfo_orig


if __name__ == "__main__":
    app.run(debug=True)

```
## クライアントプログラムを作る
サーバーから動画の再生状態を取得して必要に応じてDiscord Rich Presenceを変更するプログラムを作ります。
`CLIENT_ID`変数では、Discordでのゲームやボットを識別するIDを入れます。
これはDiscordのdevelopper potalで取得しますが、ここで扱うと長くなるので他の記事に頼ります。
[この記事](https://qiita.com/masayoshi4649/items/46fdb744cb8255f5eb98)を参考に、CLIENT IDを取得して`CLIENT_ID`変数にセットしてください。(masayoshi4649さんすみません)
なお、この時のアプリケーション名はDiscordでアクティビティ名として表示されるので、「ニコニコ動画」みたいな感じに設定しておきましょう。また、CLIENT SECRETは必要ありません。
```py
""" niconico2discord client program """
from __future__ import annotations

import datetime
import hashlib
import json
import time
import urllib.request

import pypresence
import xmltodict

CLIENT_ID = "CLIENT_ID" # 自分の物に置き換える
RPC = pypresence.Presence(CLIENT_ID)
RPC.connect()

class Auth():
    """ Class about Auth information """
    class User():
        """ Class about user information """
        def __init__(self, username: str, password: str):
            self.username = username
            self.password = hashlib.sha256()
            self.password.update(password.encode())
            self.password = self.password.hexdigest()
    class Token():
        """ JMT class """
        def __init__(self):
            self.token = ""
        def get(self, user: Auth.User):
            """ Get JMT from server """
            tokenrequest = urllib.request.Request("http://localhost:5000/login", headers={"Content-type": "application/json"}, data=json.dumps({"user": user.username, "password": user.password}).encode())
            self.token = json.load(urllib.request.urlopen(tokenrequest))["token"]

token = Auth.Token()
token.get(Auth.User("user1", "password"))

videodata_request = urllib.request.Request("http://localhost:5000/video", headers={"Authorization": f"Bearer {token.token}"})
beforevideodata = {}
beforeestimatedendtime = datetime.timedelta(seconds=0)
while True:
    try:
        videodata = json.loads(urllib.request.urlopen(videodata_request).read().decode())
    except urllib.error.HTTPError:
        token.get(Auth.User("user1", "password"))
        videodata_request = urllib.request.Request("http://localhost:5000/video", headers={"Authorization": f"Bearer {token.token}"})
        continue
    if videodata != beforevideodata:
        print(videodata)
        if videodata["status"] == "closed":
            RPC.clear()
            beforevideodata = videodata
            continue
        video = xmltodict.parse(urllib.request.urlopen(f'http://localhost:5000/videoinfo?vid={videodata["id"]}').read().decode())
        video = video["nicovideo_thumb_response"]["thumb"]
        title = video["title"]
        vid = video["video_id"]
        thumbnail_url = video["thumbnail_url"]
        url = video["watch_url"]
        videolength = video["length"].split(":")
        if len(videolength) == 1:
            videolength = datetime.timedelta(seconds=int(videolength[0]))
        elif len(videolength) == 2:
            videolength = datetime.timedelta(minutes=int(videolength[0]), seconds=int(videolength[1]))
        elif len(videolength) == 3:
            videolength = datetime.timedelta(hours=int(videolength[0]), minutes=int(videolength[1]), seconds=int(videolength[2]))
        playingtime = datetime.timedelta(hours=int(videodata["hour"]), minutes=int(videodata["min"]), seconds=int(videodata["sec"]))
        startedtime = datetime.datetime.now().replace(microsecond=0) - playingtime
        estimatedendtime = datetime.datetime.now().replace(microsecond=0) + videolength - playingtime
        if estimatedendtime == beforeestimatedendtime and videodata["sec"] == beforevideodata["sec"]:
            beforevideodata = videodata
            time.sleep(1)
            continue
        try:
            author = video["user_nickname"]
        except KeyError:
            author = video["ch_name"]
        if videodata["playing"] is True:
            statemsg = f'{video["title"]}'
        else:
            statemsg = f'{video["title"]} (一時停止中)'
        detailsmsg = f'投稿者: {author}'
        if videodata["playing"] is True:
            RPC.update(
                state=statemsg,
                details=detailsmsg,
                large_image=thumbnail_url,
                large_text=vid,
                start=startedtime.timestamp(),
                end=estimatedendtime.timestamp(),
                buttons=[{"label": "動画を視聴する", "url": url}],
                instance=True
            )
        else:
            RPC.update(
                state=statemsg,
                details=detailsmsg,
                large_image=thumbnail_url,
                large_text="Thumbnail",
                buttons=[{"label": "動画を視聴する", "url": url}]
            )
    beforevideodata = videodata
    time.sleep(1)

```
## 必須ライブラリをインストールする
サーバープログラムとクライアントプログラムには必須ライブラリがあります。次のコマンドでインストールしてください。
```bash
python3 -m pip install xmltodict https://github.com/qwertyquerty/pypresence/archive/master.zip
```
# 拡張機能をインストール
Chromeにmanifest.jsonを読み込ませて、拡張機能をインストールします。
まず[拡張機能のページ](chrome://extensions/)を開いて、右上のデベロッパーモードを有効化してください。
そしたらページ上部にある「パッケージ化されていない拡張機能をインストール」を押してmanifest.jsonを選択してください。
# 完成
完成です。これでサーバープログラムとクライアントプログラムを実行した状態でニコ動を開くとDiscordのプロフィールに出てくるはずです。[^2]
# 余談
筆者は初心者なのでクソコードが大量に存在します。インデントの修正や慣習に沿っていない記述方法などや、ミスなどありましたら編集リクエスト/コメントで指摘、または[Githubレポジトリ](https://github.com/okaits/nicovideo2discord)でのPR/issue作成をしていただけると嬉しいです。
[^1]: Discord Presenceとは、Discordが自動的に遊んでいるゲームを共有してくれる機能のことです。ゲームをしているとプロフィールに出てきて、他のユーザーがなんのゲームをしてるのかわかったりします。
[^2]: 筆者自身、あまり自身[^3]がありません。何か問題が発生したらコメントへお願いします。
[^3]: ほら、やっぱりミスしてる。