2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ブラウザ拡張機能Advent Calendar 2022

Day 2

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

Last updated at Posted at 2022-12-01

TL;DR

ニコ動を視聴したら、自動的にDiscordのRich Presence1に共有される拡張機能を作ります。
(本記事のコードはGithubに公開されています。okaits/nicovideo2discord)

構成

ブラウザ拡張機能を作る

manifest.jsonを作る

manifest.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を使います。

拡張機能本体を書く

拡張機能本体(動画の再生状態を検知して送信する)を書きます。

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();

拡張機能以外の部分を作る

サーバーを作る

サーバープログラムを作ります。

"""
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で取得しますが、ここで扱うと長くなるので他の記事に頼ります。
この記事を参考に、CLIENT IDを取得してCLIENT_ID変数にセットしてください。(masayoshi4649さんすみません)
なお、この時のアプリケーション名はDiscordでアクティビティ名として表示されるので、「ニコニコ動画」みたいな感じに設定しておきましょう。また、CLIENT SECRETは必要ありません。

""" 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)

必須ライブラリをインストールする

サーバープログラムとクライアントプログラムには必須ライブラリがあります。次のコマンドでインストールしてください。

python3 -m pip install xmltodict https://github.com/qwertyquerty/pypresence/archive/master.zip

拡張機能をインストール

Chromeにmanifest.jsonを読み込ませて、拡張機能をインストールします。
まず拡張機能のページを開いて、右上のデベロッパーモードを有効化してください。
そしたらページ上部にある「パッケージ化されていない拡張機能をインストール」を押してmanifest.jsonを選択してください。

完成

完成です。これでサーバープログラムとクライアントプログラムを実行した状態でニコ動を開くとDiscordのプロフィールに出てくるはずです。2

余談

筆者は初心者なのでクソコードが大量に存在します。インデントの修正や慣習に沿っていない記述方法などや、ミスなどありましたら編集リクエスト/コメントで指摘、またはGithubレポジトリでのPR/issue作成をしていただけると嬉しいです。

  1. Discord Presenceとは、Discordが自動的に遊んでいるゲームを共有してくれる機能のことです。ゲームをしているとプロフィールに出てきて、他のユーザーがなんのゲームをしてるのかわかったりします。

  2. 筆者自身、あまり自身3がありません。何か問題が発生したらコメントへお願いします。

  3. ほら、やっぱりミスしてる。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?