LoginSignup
7
12

More than 3 years have passed since last update.

初心者がPythonで1からVTuber通知Botを作った話

Last updated at Posted at 2020-12-29

はじめに

自己紹介

はじめまして。Yourein(ゆれいん)と言います。自己紹介なので以下にいろいろを貼っておきます。

出身地 : 北海道(現在も在住です)
お前は何者? : 現役の工業高校生です
プログラム経験は? : 授業でC言語の基礎の基礎を学びました。独学でC++を使っていましたが最近はPythonを使っています。

一応プログラムの経験はありますが、それでもこういったQiitaなどのサイトに上げることの無いようなとても小さな規模の開発を今までは自分用にしてきました。
今回この記事を書くことにした主な理由は、「今まで経験がないレベルのコード量を書いたから。」「初心者らしく各所で躓いたのでその備忘録を自分用にまとめたかったから。」です。

いくつか自分の書いたプログラムからコードを引っ張ってきて掲載する事にしましたが、初心者ですので見苦しい書き方などをしていた場合でも大目に見ていただければ嬉しいです。

やりたかったこと

VTuber通知Botとタイトルでは銘打っていますが実際のところはホロ箱通知Botとしたほうが今回は正しいです。
目標としてはHololive Notice Serverと同じような動作をするBotを自分で製作するということです。
Screenshot (479).png
これは生配信の通知などではないですが、イメージとしてはこんな感じだと思っていただければいいです。
機能要件は以下のとおりです。

  • 指定したライバーの最新動画もしくは最新の生放送を通知する。
  • 指定したライバーの最新動画何件かを取得する。
  • 現在箱のライバーによって配信されている生放送をリストにして通知する。
  • その他いくつかの機能

できなかったこと

実は以前SpotifyAPIを使ってアルバム情報や曲情報を取得するPythonアプリケーションを作成したことがあるのですが、全くもってAuthの仕組みを理解できませんでした。
今回もYoutubeDataAPIを使おうと思っていたのですが、Authへの苦手感とライバーの数からしていくつもプロジェクトを作成する必要がありそうだったので今回はYoutubeDataAPIを使わずに情報などを収集するBotになりました。

使用したサービス、開発環境など

開発環境/実行環境

OS : Windows10 Pro(1909 English(System Locale:Japan))
エディタ : Visual Studio Code
実行環境 : python(できた.pyファイルをそのまま開いて使っています。)/Discord

使用サービス

HoloToolsの機能がAPI化されて使用可能になったものです。詳しくはHoloFans APIのリンク先をご覧ください

  • Youtube(のRSSフィード)
https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID

とすることによってYoutubeチャンネルの動画ページのxmlを取得することができます。Youtubeではスクレイピングが禁止されていますが、RSSフィードの取得はイケるのでそれでお茶を濁そうという魂胆です。

  • Discord.py

言わずと知れたDiscord用のライブラリです。今回書いたコードの50%はこれに依存すると思います。

準備段階

先程示した機能要件に「指定したライバー」という言葉がありました。
今回作成するBotはとりあえず全員の通知をするわけではなく、自分で「このライバーの通知がほしい!」と指定したらそのライバーの通知を開始してくれるというような機能をもっているBotになります。
そのライバーを指定する指定子やその他もろもろの情報を持ったデータベースをJSONで用意します。

そのデータベースももっと情報が揃った(詰まった)状態でHoloFans APIにあるのでGit HubからForkして持ってくればいい話なのですが、JSONを自分で書いた経験がないので勉強がてら書いてみることにしました。

members.json
{
    "counts": 56,
    "channels":[
        {
            "id": 1,
            "yt_id": "UCp6993wxpyDPHUpavwDFqgg",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwngZmr_qbKhGIvHaHwLRmKhKxdeFfM7ZbK316vFNSw=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "tokino_sora",
            "ch_name": "SoraCh. ときのそらチャンネル",
            "name": "ときのそら",
            "specifier": "Sora",
            "part": "hololive",
            "color": "0x4D84E8"
        },
        {
            "id": 2,
            "yt_id": "UCDqI2jOz0weumE8s7paEk6g",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwnjiwtRNYHIo1zl37gOIiEeOh-2s7HUdhv6WB8M1vQ=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "robocosan",
            "ch_name": "Roboco Ch. - ロボ子",
            "name": "ロボ子さん",
            "specifier": "Roboco",
            "part": "hololive",
            "color": "0xEA00EB"
        },
        {
            "id": 3,
            "yt_id": "UC5CwaMl1eIgY8h02uZw7u8A",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwnjdAl5rn3IjWzl55_0-skvKced7znPZRuPC5xLB=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "suisei_hosimati",
            "ch_name": "Suisei Channel",
            "name": "星街すいせい",
            "specifier": "Suisei",
            "part": "hololive",
            "color": "0x4D84E8"
        },
...
        {
            "id": 54,
            "yt_id": "UCWsfcksUUpoEvhia0_ut0bA",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwnjwFEptYg7ed7Ze1nWT7Bj4bbXiOoNwzeM9-4g=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "holostarstv",
            "ch_name": "ホロスターズ公式",
            "name": "ホロスターズ",
            "specifier": "Holostars",
            "part":"Holostars 1st-gen",
            "color":"0x7979EF"
        },
        {
            "id": 55,
            "yt_id": "UCfrWoRGlawPQDQxxeIDRP0Q",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwnh523hdzQe8vPD2Du77mqxianT1HHR1McSLHXK4=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "hololive_Id",
            "ch_name": "hololive Indonesia",
            "name": "hololive Indonesia",
            "specifier": "Hololiveid",
            "part":"hololiveID 1st-gen",
            "color":"0xFF9800"
        },
        {
            "id": 56,
            "yt_id": "UCotXwY6s8pWmuWd_snKYjhg",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwnieM4gqtwmRtapt0va5VTi7BiKHhsYMxOu9qYRR=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "hololive_En",
            "ch_name": "hololive English",
            "name": "hololive English",
            "specifier": "Hololiveen",
            "part":"HololiveEN 1st-gen",
            "color":"0xFF0000"
        }
    ]
}

このようにJSONを定義しているので最初の方にあった"counts"を書き換えた後、JSONにデータを追加すればとりあえずこちらのデータベースは今後のメンバー加入に合わせて改変できることになります。
(ただし、HoloFans API側のデータベースに依存するコードが今回あるので当該APIのサービス終了に合わせてこのプログラムには使えなくなる関数があります。HoloFans APIは一応ローカル環境でもデプロイ可能になっているのでもしローカルでデプロイしているならばAPIのデータベースを自分で書き換えることが可能なのでサービス終了後も使い続けることは可能です。今回は面倒なのでしていませんが...)

変数の用意

variables
clientid = "CLIENTID"
client = commands.Bot(command_prefix = '.')
thumbnail_endpoint_1 = "https://i.ytimg.com/vi/"
thumbnail_endpoint_2 = "/maxresdefault.jpg"
stream_endpoint = "https://www.youtube.com/watch?v=" #Youtube video page
dischannel = CHANNELID(int) #Discord's channel id
members = json.load(open("members.json", mode = 'r', encoding = "utf-8")) #load hololive members dic
xml_endpoint = "https://www.youtube.com/feeds/videos.xml?channel_id=" #Youtube video feed xml

プログラムを書いている中で何度も使うことになった変数やそもそも実行に必要なcommand_prefixなどを置いています。

機能の追加

1.箱のライバーによって配信されている放送をリストにして通知する

Screenshot (493).png
最終的にこんな感じになりました。.listliveでこのリストを取得できます。

listlive
@client.command()
async def listlive(ctx): #make list of nowlive and return it.
    """Make a list of online-stream hosted by hololive & stars members"""
    templist = Holo.apilistlive() #requesting live list to Holoapi
    tempnum = len(templist) #getting range of the list
    embed_tosend = discord.Embed(title = f"{tempnum}streams on live!")

    #sending process
    for i in range(tempnum):
        embed_tosend.add_field(name = f"{templist[i][1]}'s stream", value = f"[{templist[i][0]}]({stream_endpoint}{templist[i][2]})")
    await ctx.send(embed = embed_tosend)
apilistlive
class holoapi(object):
    holoapi_endpoint = "https://api.holotools.app/v1/"

    def apilivenum(self): #get the number of nowlive
        ...

    def apilistlive(self): #make a list of nowlive
        lookup_url = f"{self.holoapi_endpoint}live"
        result = requests.get(lookup_url)
        live_dic = result.json()["live"]

        livelist = []
        for i in range(len(live_dic)):
            #get each stream information except desc. and some stats.
            temp = [live_dic[i]["title"], live_dic[i]["channel"]["name"], live_dic[i]["yt_video_key"]]
            livelist.append(temp)

        return livelist
https://api.holotools.app/v1/live

でHoloFans APIに登録されたライバーのうち現在配信中の枠情報を取得できます。
APIを叩いたら出てくるのはJSONなので辞書型に変換して加工し、2次元リスト化した後returnでコマンド.listliveが呼び出されたときに実行される関数にリストを返します。

for文の中でEmbedに現在配信中の枠分だけフィールドを追加してそこに「誰が配信しているのか」「配信タイトルはなにか」「配信URL」を記述してすべて記述し終わったら送信しています。
インライン表示にしている理由はこのコードを書いたときにインライン表示の無効化の方法を知らなかったからですが、その後インライン無効化を試してみたとき5以上の配信があったときの可読性が明らかに低かったからです。スマートフォンからであればこちらのほうが可読性は低いでしょうが、そもそもPCでしか見る予定ないので大丈夫です。(誰かに公開する予定とかもないので...)

2.指定したライバーの最新動画何件かを取得する

次はもっぱらアーカイブ視聴用の機能です。今回は指定したライバーの最新動画及び生配信5件を取得するようにしました。
こちらは先ほどと違ってAPIに依存しないタイプの関数なのでmembers.json(今回用意したデータベース)を更新すれば別にホロ箱内のライバー以外でも取得可能です。
なんならライバーじゃなくてもいけます。
Screenshot (495).png

こちらはこんな感じになりました。
動画を1件づつサムネイル付きでテキストチャンネルに送信しても良かったのですが、普通に見ずらいし邪魔なので素直に一つのEmbedにインライン無効化でまとめました。

コマンドは、

.recent 指定子(specifier)

です。

recent
@client.command()
async def recent(ctx, spec = "Default"):
    """Make the list of their recent 5 vids or streams and back"""
    profile_base = getprofile_withspec(spec)
    if profile_base == 0:
        embed_tosend = discord.Embed(title="Please input specifier!", descprition = "Check specifier with [.spechelp] command!")
        embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
        await ctx.send(embed = embed_tosend)
    elif profile_base == 1:
        embed_tosend = discord.Embed(title = "Wrong specifier!", description = "Check specifier with [.spechelp] command!")
        embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
        await ctx.send(embed = embed_tosend)
    else:
        youtube_name = profile_base["ch_name"]
        youtube_id = profile_base["yt_id"]
        channel_videopage = f"https://www.youtube.com/channel/{youtube_id}/videos"
        channel_xml = feedparser.parse(f"{xml_endpoint}{youtube_id}")
        embedcolor = profile_base["color"]
        if embedcolor != "undefined":
            embedcolor = int(embedcolor, 16)
        else:
            embedcolor = 0x19FFFF
        embed_tosend = discord.Embed(title = f"Recent video of {youtube_name}", url = channel_videopage, color = embedcolor)

        for i in range (5):
            title = channel_xml["entries"][i]["title"]
            title = f"**{title}**"
            videoid = channel_xml["entries"][0]["id"]
            videoid = videoid.lstrip("yt:video:")
            video_page = f"{stream_endpoint}{videoid}"
            embed_tosend.add_field(name = title, value = f"Click [here]({video_page}) to watch!", inline = False)

        await ctx.send(embed = embed_tosend)
getprofile_withspec
def getprofile_withspec(temp):
    temp = temp.capitalize()
    if temp == "Default":
        return 0
    else:
        for i in range(members["counts"]):
            if temp == members["channels"][i]["specifier"]:
                return members["channels"][i]
        return 1

関数getprofileのreturnをもうちょい工夫できなかったのかと言われそうですがこのくらいの実力しか僕にはありません。ご理解ください。
指定子は小文字大文字を区別しないようにするために、JSON内ですべて1文字目のみ大文字、その他は小文字と定義してgetprofile内でcapitalizeすることにしています。

return 0及び1はどちらもエラー発生時のreturnで、0は指定子が入力されなかったとき(デフォルト引数が使われたとき)にreturnされ、折りたたみされた中のようなエラーメッセージが表示されます。
Screenshot (482).png
1は入力された指定子がJSONファイル内で発見できなかったときにreturnされ以下のエラーメッセージが表示されます。
Screenshot (483).png

もし、指定子がJSON内で見つかった場合はそのライバーの情報が格納された部分がそのままreturnされます。
今回は

.recent aqua

で指定したので、指定子 "aqua" = 湊あくあ の情報がreturnされてくればいいわけです。

aqua
        {
            "id": 11,
            "yt_id": "UC1opHUrw8rvnsadT-iGp7Cg",
            "icon": "https://yt3.ggpht.com/ytc/AAUvwngM9Jmc29dvbOY43w7RWFbOZLU4tGtOkEwtt-g7PA=s800-c-k-c0x00ffffff-no-rj",
            "tw_id": "minatoaqua",
            "ch_name": "Aqua Ch. 湊あくあ",
            "name": "湊あくあ",
            "specifier": "Aqua",
            "part":"2nd-gen",
            "color": "0xFA99E0"
        },

returnされた情報からYoutubeのチャンネルIDを取り出し、feedparserでチャンネルのビデオフィードのxmlを取得しています。
xmlの説明は省きますが、動画1本1本がentryとして登録されているのでxmlを辞書型に沿った形でデータを取り出します。
今回は5本の動画を取得するので、5回のループを回して取得した情報を加工し、Embedにインライン無効化で追加した後送信しています。

また、ライバーがライブの時とかに使う色情報が登録されていれば("color" = hexnum)その色でembedを修飾して送信します。ない場合は全部水色で送信されます。
今回はキー"color"0xFA99E0が登録されているのでLight Pinkに近い色でEmbedが修飾されています。
にじさんじとかだと非公式Wikiでテーマカラーが公開されているのでそこからコピペしてくればいいのですが、ホロライブだとそういうことはないので、この前の2nd fes. Beyond the Stage時に公開されていた画像からPower Toysのカラーピッカー機能を使って取ってきています。

ここらへんからfストリングの力に頼りまくっています。しかたないよね。。。便利だもの。。。

3.指定したライバーの最新動画もしくは最新の生放送を通知する

正直一番簡単そうだと踏んでいたのですが、ここで本当に詰まりました。
YoutubeDataAPIを使わないのでリアルタイム更新とは行きませんが、何十分かおきにxmlフィードを更新することでゲリラ配信なども取得できるようにしています。そもそもxmlフィードがどの頻度で更新されるのか知らないので実際に運用してみなければわからないところではありますが。

機能要件として自分が通知を要求したライバーの通知のみを配信するということだったので、まずはその通知するライバーを管理する機能を実装するところから始めます。

editclist
@client.command()
async def editclist(ctx, mode = "add", spec = "Default"):
    """Add or delete members on checklist"""
    check = 0
    if mode == "add":
        check = RCh.addlist(spec)
        if check == '0':
            embed_tosend = discord.Embed(title="Please input specifier!", descprition = "Check specifier with [.spechelp] command!")
            embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
            await ctx.send(embed = embed_tosend)
        elif check == '1':
            embed_tosend = discord.Embed(title = "Wrong specifier!", description = "Check specifier with [.spechelp] command!")
            embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
            await ctx.send(embed = embed_tosend)
        elif check == '2':
            embed_tosend = discord.Embed(title="Success!", description="Member you chose was successfully added to checklist!")
            await ctx.send(embed = embed_tosend)
    elif mode == "delete":
        check = RCh.dellist(spec)
        if check == '0':
            embed_tosend = discord.Embed(title="Please input specifier!", descprition = "Check specifier with [.spechelp] command!")
            embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
            await ctx.send(embed = embed_tosend)
        elif check == '1':
            embed_tosend = discord.Embed(title = "Wrong specifier!", description = "Check specifier with [.spechelp] command!")
            embed_tosend.set_image(url = "https://i.ytimg.com/vi/7tQgpXgv570/maxresdefault.jpg")
            await ctx.send(embed = embed_tosend)
        elif check == '2':
            embed_tosend = discord.Embed(title="Success!", description="Member you chose was successfully deleted from checklist!")
            await ctx.send(embed = embed_tosend)
    elif mode == "check":
        RCh.checklist_debug()
        await ctx.send("**Check the console!**")
recentchecking1
class recentchecking(object):
    check_list = []
    def addlist(self, temp):
        temp = temp.capitalize()
        if temp == "Default":
            return '0'
        else:
            for i in range(members["counts"]):
                if temp == members["channels"][i]["specifier"]:
                    self.check_list.append([members["channels"][i]["name"], members["channels"][i]["yt_id"]])
                    return '2'
            return '1'

    def dellist(self, temp):
        temp = temp.capitalize()
        if temp == "Default":
            return "0"
        else:
            for i in range(members["counts"]):
                if temp == members["channels"][i]["specifier"]:
                    self.check_list.remove([members["channels"][i]["name"], members["channels"][i]["yt_id"]])
                    return "2"
            return "1"

    def checklist_debug(self):
        print(self.check_list)

if文のネストのオンパレードなコードですがここで通知の要求を管理します。

RCh.関数名という記述が何度か登場しますが、RChという変数でclass recentcheckingを参照しています。
クラスから帰ってくるreturnに関しては先程と同じで、0,1がエラー、2が正常終了になります。

.editclist モード 指定子

によって呼び出され、「通知要求ライバーの追加」「削除」「今誰が登録されているのか」の確認ができます。
Screenshot (484).png
ライバーがリストに追加されると上のような通知がDiscordに飛んできます。
ここで現在のリストの内容を確認すると、

checklist
[["湊あくあ", "UC1opHUrw8rvnsadT-iGp7Cg"]]

となります。
このデータを利用して、情報を取得していきます。

recentchecking2
class recentchecking(object):
    check_list = []
    recent_list = []
    old_recent_list = []

    def getmostrecent(self):
        self.recent_list.clear() #Reset recent_list for running
        for i in range(len(RCh.check_list)): #Add one's recent video to recent_list
            youtube_id = self.check_list[i][1]
            channel_xml = feedparser.parse(f"{xml_endpoint}{youtube_id}")
            recent_video_title = channel_xml["entries"][0]["title"]
            recent_video_id = channel_xml["entries"][0]["yt_videoid"]

            #check video or stream status
            returned = Holo.checkstatus(recent_video_id)
            status = returned[0]
            scheduled_time = returned[1]

            #embed_color
            embedcolor = getprofile_withytid(youtube_id)
            embedcolor = embedcolor["color"]
            if embedcolor != "undefined":
                embedcolor = int(embedcolor, 16)
            else:
                embedcolor = 0x19FFFF

            #appends latest stream information
            self.recent_list.append([recent_video_title, recent_video_id, status, scheduled_time, embedcolor, False])

    def compareon(self):
        if len(self.old_recent_list) < len(self.recent_list):
            for i in range(len(self.old_recent_list), len(self.recent_list)):
                self.recent_list[i][5] = True

        for i in range(len(self.old_recent_list)):
            if (self.old_recent_list[i][1] != self.recent_list[i][1]) or (self.old_recent_list[i][2] != self.recent_list[i][2]):
                self.recent_list[i][5] = True

    def checking(self):
        self.getmostrecent()
        self.compareon()
        self.old_recent_list = copy.deepcopy(self.recent_list)

コードブロック自体が長いので別に分けましたが実際は先程のchecklist追加(削除)部分と上のコードブロックは同じクラスに属しています。
関数getmostrecentでやっていることはさっきの5件動画をとってくるのと大して変わりありません。
checklistに格納されたyt_idを取り出してきて、xmlを取得。
そこから xml["entries"][0] として、最新の動画のみを取得しています
取得した情報は加工されrecent_listに格納されます。

recent_listの内容は以下のようになっていて、Discordに送信されるデータはこのリストの内容をもとに決定されます。
また、appendでrecent_listを更新しているので毎回実行されるときにrecent_listは空っぽである必要があります、のでgetmostrecent内で一番最初にself.recent_list.clear()を記述してrecent_listをリセットしています。

recent_list
[["動画のタイトル", "動画のID(視聴ページに飛ぶURL成形用)", "動画の状態(live, past, upcoming)", "配信開始時間(UTC+09)"(状態がlive or upcomingであるときのみ格納される),"Boolフラグ"(Discordに送信するかどうかのフラグTrueなら送信)]]

また、動画の状態を取得するためにHolofans APIを叩いていて、そのときに配信開始時間も同時にリスト化してチェック用の関数Holo.checkstatusにreturnしてもらっています。

checkstatus
def checkstatus(self, temp):
        lookup_url = f"{self.holoapi_endpoint}{self.holoapi_video_endpoint}{temp}"
        result = requests.get(lookup_url)
        video_dic = result.json()
        if video_dic["status"] == "live":
            st = video_dic["live_schedule"]
            st = st.replace('T', ' ')
            st = st.replace('Z', '')
            st = st.replace('.000', '')
            starttime = replace_JST(st)

            return_list = [video_dic["status"], starttime]
            print(return_list)
            return return_list
        elif video_dic["status"] == "past":
            return_list = [video_dic["status"], ""]
            print(return_list)
            return return_list
        elif video_dic["status"] == "upcoming":
            st = video_dic["live_schedule"]
            st = st.replace('T', ' ')
            st = st.replace('Z', '')
            st = st.replace('.000', '')
            starttime = replace_JST(st)

            return_list = [video_dic["status"], starttime]
            print(return_list)
            return return_list
replace_JST
#k0gane_pさん(@k0gane_p)が作った関数を改変して使わせていただいています。
#ここらへんの処理はかなり@k0gane_pさんの記事を参考にした部分があるので記事をこのセクションの最後にリンクしておきます。
def replace_JST(s):
    a = s.split("-")
    u = a[2].split(" ")
    t = u[1].split(":")
    time = [int(a[0]), int(a[1]), int(u[0]), int(t[0]), int(t[1]), int(t[2])]
    if(time[3] >= 15):
      time[2] += 1
      time[3] = time[3] + 9 - 24
    else:
      time[3] += 9
    return (str(time[0]) + "/" + str(time[1]).zfill(2) + "/" + str(time[2]).zfill(2) + " " + str(time[3]).zfill(2) + ":" + str(time[4]).zfill(2))

関数compareonでは実際にDiscordに送信する動画を選定しています。
1回前に取得したデータが格納されているold_recent_list(名前わかりずれぇ...)と新たに取得したrecent_listを比較し、送信可否を決めるBoolフラグを操作します。

if len(self.old_recent_list) < len(self.recent_list):
    if len(self.old_recent_list) < len(self.recent_list):
            for i in range(len(self.old_recent_list), len(self.recent_list)):
                self.recent_list[i][5] = True

        for i in range(len(self.old_recent_list)):
            if (self.old_recent_list[i][1] != self.recent_list[i][1]) or (self.old_recent_list[i][2] != self.recent_list[i][2]):
                self.recent_list[i][5] = True

ここではold_recent_listにない内容がrecent_listに追加されているとき(通知要求ライバーが新たに追加されたとき or Botを立ち上げて一回目の実行時)、old_recent_listに無い内容をすべて送信する設定にしています。

その次のfor文では単純にold_recent_listとrecent_listを比較し、新たな枠or動画を発見したときにDiscordにその内容を送信するようにする設定をしています。
また、同じ枠でも配信前、開始後、終了後(アーカイブ公開後)によって3回通知が行くようになっています。

で、その処理を定期的に実行する処理が必要なためDiscord.pyの拡張機能にある一定時間ループを採用しました。
(ここもまた参考記事があるのでセクションの最後にまとめてリンクさせて頂きます。)

loop
@tasks.loop(seconds=35)
async def loop():
    now = datetime.now().strftime('%M')
    if (int(now) % 30) == 0: #Do this every %n minutes
        print("Doing")
        channel = client.get_channel(dischannel)


        if RCh.check_list != []:
            RCh.checking()
            print(RCh.recent_list)
            for i in range(len(RCh.recent_list)):
                if RCh.recent_list[i][5] == True:
                    if RCh.recent_list[i][2] == "live":
                        video_page = f"{stream_endpoint}{RCh.recent_list[i][1]}"
                        video_name = RCh.recent_list[i][0]
                        embed_tosend = discord.Embed(title=f"{RCh.check_list[i][0]} now streaming!", description = f"[**{video_name}**]({video_page})", color = RCh.recent_list[i][4])
                        embed_tosend.add_field(name = "Start Time", value = RCh.recent_list[i][3])
                        embed_tosend.add_field(name = "Video ID", value = RCh.recent_list[i][1])
                        embed_tosend.set_image(url = f"{thumbnail_endpoint_1}{RCh.recent_list[i][1]}{thumbnail_endpoint_2}")
                    elif RCh.recent_list[i][2] == "past":
                        video_page = f"{stream_endpoint}{RCh.recent_list[i][1]}"
                        video_name = RCh.recent_list[i][0]
                        embed_tosend = discord.Embed(title=f"{RCh.check_list[i][0]}'s new video!", description = f"[**{video_name}**]({video_page})", color = RCh.recent_list[i][4])
                        embed_tosend.add_field(name = "Video ID", value = RCh.recent_list[i][1]) 
                        embed_tosend.set_image(url = f"{thumbnail_endpoint_1}{RCh.recent_list[i][1]}{thumbnail_endpoint_2}")
                    elif RCh.recent_list[i][2] == "upcoming":
                        video_page = f"{stream_endpoint}{RCh.recent_list[i][1]}"
                        video_name = RCh.recent_list[i][0]
                        embed_tosend = discord.Embed(title=f"{RCh.check_list[i][0]}'s future stream!", description = f"[**{video_name}**]({video_page})", color = RCh.recent_list[i][4])
                        embed_tosend.add_field(name = "Estimated Start Time", value = RCh.recent_list[i][3])
                        embed_tosend.add_field(name = "Video ID", value = RCh.recent_list[i][1])
                        embed_tosend.set_image(url = f"{thumbnail_endpoint_1}{RCh.recent_list[i][1]}{thumbnail_endpoint_2}")
                    await channel.send(embed = embed_tosend)

ここでようやく今まで成形してきたデータが送信されるわけです。
35秒ごとに現在時刻(分のみ)を取得し、その分が30で割り切れたらxmlを要求し今までの処理を開始します。要は30分毎に処理が実行されるよってことです。

ちなみになんで35秒毎に時刻を確認するのかと言うと、BotがNh時Nm分00秒に実行開始されたとき30秒毎であれば1分間に2回同じ処理を繰り返すことになります。
それは普通に無駄な動作なので、防ぐ目的で35秒にしています。
(だったら60でいいのでは?という意見には「ならべく早く情報欲しいじゃん」と答えさせていただきます。)

先程までいくつか関数を準備しましたが、実際に呼び出される関数は全く別のRCh.Recent_list()という関数になっています。
めちゃくちゃ単純で

RCh.recent_list
def checking(self):
     self.getmostrecent()
     self.compareon()
     self.old_recent_list = copy.deepcopy(self.recent_list)

これだけなのですがここで僕は5時間詰まりました

というのも、pythonの参照コピーと実体コピーを知らなかったからという理由ですが最初は以下のように記述していました。

Wrongcode
self.old_recent_list = self.recent_list

これでリストの内容をコピーできるものだと思いこんでいたのですが、どうやらold_recent_listにrecent_listのポインタらしきものが代入されて擬似的にコピーされたように見えただけで実際はどちらも同じ実体を表していたというオチです

C++だと構造体ですら = で中身をコピーできるのですっかりその気になってやっていました。

はやく気付ければよかったと思いましたが、そもそもあのままWebで検索しなければもっと長い時間原因の解らない根本的なミスと戦う事になったかと思うと遅かれ早かれ気付けて感謝でした。

うまく実行されると以下のような通知が飛んできます。
Screenshot (494).png

問題点としてはライバーを登録して1回目の実行では必ず通知が送信されてくることなのですが、そもそも用途的に鯖機でデプロイしてそれからずっと実行しっぱなしという用途なので大して問題はないかなと思っています。
僕の家はよくブレーカーが落ちるので何度か同じ通知を見る羽目にはなりそうですが。

参考記事

Python: Making a Discord Bot (Rewrite / v1.x)
ホロライブメンバーの配信予定を取得して配信開始時刻に通知するDiscord botを作った
リストのコピーでハマった話
[Python]Discordで指定時間に発言させるBOT
Discord.pyのEmbedにできることあれこれ(メモ)
Discord BOT 小ネタ Embedにリンクを表示する

その他数多くのサイト、動画等。
どれもとても参考にさせていただきました。ありがとうございました。

今後のロードマップ

  • コードの最適化

なんというか自分でも拙いコードだなと思っています。ので、いらない変数を削除したり構文を変えて行こうかと思っています。

  • 機能の追加

まだまだVer1.2のBotなのでこれから機能を追加していく必要もあると感じます。あくまでも通知Botなのでそこまでてんこもりにする必要もないですが。

  • HoloToolsに依存しないコードに書き換える

これが今後のロードマップの最終目標になります。
これを達成するにはYoutubeDataAPIの使い方を理解する必要があるわけで、今回からは格段にレベルが上がることが予想されます。が、今後のことも考えてぜひ挑戦してみたいと思っています。

これができるとホロライブだけでなく外箱のライバーなどの情報も取得が可能になるのでBot全体としての自由度があがります。
現在はこのBotの名前を「Holo checker」として稼働させていますがいつの日か「Vcheker」として動かせることを目標にしています。

あとがき

自分の拙いプログラミング能力を集結させておよそ1.5週間ほどでBot Ver1.0が完成しました。

現在この記事を書いているときのライン数は500なので(作ったJSONも合わせたら1100)一般的なプログラムから比べたらかなり短いほうですが、そもそもそこまでの規模及びこれからも使っていくという条件の下で開発をしたことがなかったのでかなり新鮮な体験でした。

プログラムでも勉強でもそうですが、やらなきゃ覚えないので今回このプログラムを組むことでPythonについて見えてきて、ようやく本格的にPythonという言語を扱えるようになってきた気がしています。(「実務じゃないのに?」という意見には自分も同意します。)

せっかく自分で作ったBotなので今後も末永く付き合っていこうと思います。
もしかしたら数カ月後、数年後には中身がまったく変わっているかもしれませんが...

最後に、大変長い記事でしたがここまで読んでいただきありがとうございました。
この記事がなにかの参考になることを祈っています。

7
12
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
7
12