🌟

MK8DX Loungeの特定プレイヤーのMMRをBeautifulSoup+Webhookで追う

2024/12/06に公開

MMA Advent Calendar 2024 7日目の記事です

はじめに

友人がMK8DX Loungeに参加しています.ランク帯が変わったらお祝いしたいです.

しかし,毎日ホームページを見に行って確認は面倒です.

そこで,PythonのBeautifulSoupでWebスクレイピングをして,MMRが変わったらDiscord WebHookで追うようにしてみましょう.

環境構築

Pythonのインストール方法は割愛します.

requests, beautifulsoupはpipで導入します.

以下の内容をrequirements.txtというファイル名で保存します.

requirements.txt
requests
beautifulsoup4

そして,requirements.txtを使ってインストールします.

$ pip install -r requirements.txt

これで環境構築は完了しました.

HTML構造の分析

今回は例として,以下のプレイヤーのサイトをスクレイピングしてみます.

mango runさん

curlで素のHTMLを入手してみましょう.curlはWindowsでも利用できます.

$ curl -o out.html https://www.mk8dx-lounge.com/PlayerDetails/13177

HTMLがout.htmlに吐き出されるので,エディターで開いてみます.

VS Codeで開いてみた例

インデントは無茶苦茶ですが,MMR等の情報はタグで囲まれていて,扱いやすそうなのがわかります.

このHTML構造を参考に,BeautifulSoupで情報を抜き取ってみます.

BeautifulSoupで情報の切り出し

まず,requestsで素のHTMLを取得し,BeautifulSoupに渡します.

test.py
from bs4 import BeautifulSoup
import requests

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

プレイヤー名の取得

プレイヤー名は<head><title>か,<body><h1>から取得できそうです.

今回は<h1>から取得できます.

ハイフンの後ろのランク帯の情報はいらないので,reモジュールを使って正規表現で消去します.

test.py
from bs4 import BeautifulSoup
import requests
import re

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

h1_text = soup.find("h1").text
player_name = re.sub(r" - [A-Z][a-z]+ [0-9] $", "", h1_text)

print(f"Player name: {player_name}")
出力
$ python3 test.py
Player name: mango run

無事取得できました.

ランク帯

プレイヤー名と同様に<body><h1>などから取得できます.

プログラムもプレイヤー名の時とほぼ同じです.

test.py
from bs4 import BeautifulSoup
import requests
import re

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

h1_text = soup.find("h1").text
division = re.sub(r"^.* - ", "", h1_text)

print(f"Division: {division}")
出力
$ python3 test.py
Division: Diamond 2

MMR

MMRは,<dt>MMR</dt>のつぎの<dd>タグに数値があります.

そのため,プログラムではこのように取得できます.

test.py
from bs4 import BeautifulSoup
import requests
import re

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

mmr_label = soup.find("dt", string="MMR")
mmr = int(mmr_label.find_next_sibling("dd").text)

print(f"MMR: {mmr}")
出力
$ python3 test.py
MMR: 15305

Peak MMR

MMRと同様です.

test.py
from bs4 import BeautifulSoup
import requests
import re

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

peak_mmr_label = soup.find("dt", string="Peak MMR")
peak_mmr = int(peak_mmr_label.find_next_sibling("dd").text)

print(f"Peak MMR: {peak_mmr}")
出力
$ python3 test.py
Peak MMR: 15503

直近で参加したイベントの日時

最近参加したイベントの日時を取得します.

参加したイベント一覧は<table>タグで表になっています.
一番上の行を,最近とみてよさそうです.

<span>タグのdata-time属性に,ISOフォーマットで書かれた日時の文字列があります.それを使えば,Pythonのdatetimeモジュールでそのまま読み込めます.

curlやrequestsで取得する素のHTMLは,日時情報がUTCになっているので注意が必要です.
timezonetimedeltaを使ってJSTに直します.

test.py
from bs4 import BeautifulSoup
import requests
from datetime import datetime, timezone, timedelta

url = "https://www.mk8dx-lounge.com/PlayerDetails/13177"
soup = BeautifulSoup(requests.get(url).content, "html.parser")

table = soup.find("table")
first_row = table.find_all("tr")[1]
cells = first_row.find_all("td")

datetime_text = cells[1].find("span")["data-time"]

last_joined_time = datetime.fromisoformat(datetime_text)
last_joined_time_jst = last_joined_time.astimezone(timezone(timedelta(hours=9)))

print(f"Last Joined: {last_joined_time_jst}")
出力
$ python3 test.py
Last Joined: 2024-11-30 00:42:18.730734+09:00

プレイヤー情報をクラス化

このままでは情報がありすぎて煩雑なので,クラスにします.

ついでにmk8dxlounge.pyという名前の別ファイルにします.

mk8dxlounge.py
from bs4 import BeautifulSoup
import requests
import re
from datetime import datetime, timezone, timedelta

class MK8DXLoungeEvent:
    def __init__(self):
        self.name = ""
        self.id = 0
        self.time: datetime = None
        self.mmr_delta = 0
        self.mmr = 0
    
    def parse_html_table_row(row: BeautifulSoup):
        obj = MK8DXLoungeEvent()
        cells = row.find_all("td")
        name_raw = cells[0].text.strip()
        obj.name = re.sub(r" \(ID: [0-9]+\)$", "", name_raw)
        obj.id = int(re.sub(r"^/TableDetails/", "", cells[0].find("a")["href"]))

        datetime_text = cells[1].find("span")["data-time"]
        obj.time = datetime.fromisoformat(datetime_text)
        obj.mmr_delta = int(cells[2].text)
        obj.mmr = int(cells[3].text)
        return obj

class MK8DXLoungePlayerDetails:
    def __init__(self, lounge_id, season=None):
        self.lounge_id = lounge_id
        self.season = season
        if self.season:
            self.url = f"https://www.mk8dx-lounge.com/PlayerDetails/{lounge_id}?season={season}"
        else:
            self.url = f"https://www.mk8dx-lounge.com/PlayerDetails/{lounge_id}"
        self.update()

    def update(self):
        self.soup = BeautifulSoup(requests.get(self.url).content, "html.parser")

    def get_player_name(self):
        h1_text = self.soup.find("h1").text
        return re.sub(r" - [A-Z][a-z]+ [0-9] $", "", h1_text)
    
    def get_division(self):
        h1_text = self.soup.find("h1").text
        return re.sub(r"^.* - ", "", h1_text)
    
    def get_last_joined_event(self) -> MK8DXLoungeEvent:
        table = self.soup.find("table")
        rows = table.find_all("tr")
        return MK8DXLoungeEvent.parse_html_table_row(rows[1])
    
    def get_peak_mmr(self):
        peak_mmr_element = self.soup.find("dt", string="Peak MMR")
        if peak_mmr_element:
            return int(peak_mmr_element.find_next_sibling("dd").text)
        raise RuntimeError("Peak MMR not found")
    
    def get_mmr(self):
        mmr_element = self.soup.find("dt", string="MMR")
        if mmr_element:
            return int(mmr_element.find_next_sibling("dd").text)
        raise RuntimeError("MMR not found")
    
    def get_last_online_time(self, timezone=timezone.utc):
        last_joined_event = self.get_last_joined_event()
        last_online_time = last_joined_event.time
        return last_online_time.astimezone(timezone)

これにより,メインのPythonから以下のように扱えます.

test.py
from mk8dxlounge import MK8DXLoungePlayerDetails

LOUNGE_ID = 13177

def main():
    player = MK8DXLoungePlayerDetails(LOUNGE_ID)
    print(f"Player: {player.get_player_name()}")
    print(f"Division: {player.get_division()}")
    print(f"MMR: {player.get_mmr()}")
    print(f"Peak MMR: {player.get_peak_mmr()}")
    print(f"Last online time: {player.get_last_online_time()}")

if __name__ == "__main__":
    main()
$ python3 test.py
Player: mango run
Division: Diamond 2 
MMR: 15305
Peak MMR: 15503
Last online time: 2024-11-29 15:42:18.730734+00:00

定期的に取得して更新時のみ通知

60秒おきに取得して,MMRか最終参加日時に変化があったら標準出力に出力するには以下の通りになります.

最終参加日時のみを監視していると,MMRが変化する前に通知されてしまうことがあったため,MMRも監視するようにしています.

test.py
from mk8dxlounge import MK8DXLoungePlayerDetails
from datetime import datetime, timedelta, timezone
import time

LOUNGE_ID = 13177

def main():
    player = MK8DXLoungePlayerDetails(LOUNGE_ID)
    previous_mmr = None
    previous_last_online_time = None

    while True:
        player.update()
        new_mmr = player.get_mmr()
        new_last_online_time = player.get_last_online_time(timezone=timezone(timedelta(hours=9)))

        if new_mmr != previous_mmr \
            or new_last_online_time != previous_last_online_time:
            previous_mmr = new_mmr
            previous_last_online_time = new_last_online_time
            print(f"[UPDATE] Player: {player.get_player_name()} MMR: {new_mmr} Last online time: {new_last_online_time}")

        time.sleep(60)

if __name__ == "__main__":
    main()
$ python3 test.py
[UPDATE] Player: mango run MMR: 15305 Last online time: 2024-11-30 00:42:18.730734+09:00

Discord Webhookで通知

Discord Webhookのセットアップ

Discordの通知したいチャンネルのチャンネルの編集ボタンをクリックします.

左側のリストから連携サービスを選び,右側のペインからウェブフックを選びます.

新しいウェブフックを選びます.

ウェブフックが新たに作られるので,適当に名前を決めましょう.

ウェブフックURLをコピーからURLをコピーできます.

PythonでWebHookを叩くようにする.

POSTするボディは以下だけでできます.

{
    "content": "somebody"
}

次のように変更します.DISCORD_WEBHOOK_URLに先ほどコピーしたURLを貼り付けてください.

test.py
from mk8dxlounge import MK8DXLoungePlayerDetails
from datetime import datetime, timedelta, timezone
import time
import requests

LOUNGE_ID = 13177
DISCORD_WEBHOOK_URL = "<WebフックのURL>"

def post_discord_message(content):
    requests.post(DISCORD_WEBHOOK_URL, json={"content": content})

def main():
    player = MK8DXLoungePlayerDetails(LOUNGE_ID)
    previous_mmr = None
    previous_last_online_time = None

    while True:
        player.update()
        new_mmr = player.get_mmr()
        new_last_online_time = player.get_last_online_time(timezone=timezone(timedelta(hours=9)))

        if new_mmr != previous_mmr \
            or new_last_online_time != previous_last_online_time:
            previous_mmr = new_mmr
            previous_last_online_time = new_last_online_time
            post_discord_message(f"{player.get_player_name()} ({player.get_division()} MMR:{new_mmr}) Last online time: {new_last_online_time}")

        time.sleep(60)

if __name__ == "__main__":
    main()

これで,以下のようにDiscordに通知が届きます.

スマホにDiscordを入れればどこにいても通知が受け取れます.

embedsでもっといい感じに

例えばこのようにすると

test.py
from mk8dxlounge import MK8DXLoungePlayerDetails
from datetime import datetime, timedelta, timezone
import time
import requests

LOUNGE_ID = 13177
DISCORD_WEBHOOK_URL = "<WebフックのURL>"

def post_discord_embed(title, url, timestamp: datetime, color, inline_fields: dict):
    fields = []
    for key, value in inline_fields.items():
        fields.append({"name": key, "value": value, "inline": True})

    requests.post(DISCORD_WEBHOOK_URL,
                    json={"embeds": 
                            [
                                {
                                    "title": title, 
                                    "url": url, 
                                    "timestamp": timestamp.isoformat(), 
                                    "color": color, 
                                    "fields": fields
                                }
                            ]
                        })

def main():
    player = MK8DXLoungePlayerDetails(LOUNGE_ID)
    previous_mmr = None
    previous_last_online_time = None

    while True:
        player.update()
        new_mmr = player.get_mmr()
        new_last_online_time = player.get_last_online_time(timezone=timezone(timedelta(hours=9)))

        if new_mmr != previous_mmr \
            or new_last_online_time != previous_last_online_time:
            previous_mmr = new_mmr
            previous_last_online_time = new_last_online_time
            post_discord_embed(
                                f"{player.get_player_name()} - {player.get_division()}", 
                                player.url, 
                                new_last_online_time, 
                                0x00ff00, 
                                {
                                    "MMR": new_mmr, 
                                    "Peak MMR": player.get_peak_mmr()
                                }
                            )

        time.sleep(60)

if __name__ == "__main__":
    main()

以下の感じになります.

おわりに

実際にとあるサーバーでこのプログラムを動かしているのですが,ホームページの更新のタイミングがまちまちでBotの通知も遅れ気味...

友人がGold帯にたどり着いたとき,自分が通知に気づいてお祝いする前に向こうから知らせが来ました😅

ともあれ,これで毎日自分でホームページを見に行かなくてよくなりました.

Discussion