MK8DX Loungeの特定プレイヤーのMMRをBeautifulSoup+Webhookで追う
MMA Advent Calendar 2024 7日目の記事です
はじめに
友人がMK8DX Loungeに参加しています.ランク帯が変わったらお祝いしたいです.
しかし,毎日ホームページを見に行って確認は面倒です.
そこで,PythonのBeautifulSoupでWebスクレイピングをして,MMRが変わったらDiscord WebHookで追うようにしてみましょう.
環境構築
Pythonのインストール方法は割愛します.
requests, beautifulsoupはpipで導入します.
以下の内容を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に渡します.
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
モジュールを使って正規表現で消去します.
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>
などから取得できます.
プログラムもプレイヤー名の時とほぼ同じです.
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>
タグに数値があります.
そのため,プログラムではこのように取得できます.
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と同様です.
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になっているので注意が必要です.
timezone
とtimedelta
を使ってJSTに直します.
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
という名前の別ファイルにします.
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から以下のように扱えます.
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も監視するようにしています.
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を貼り付けてください.
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でもっといい感じに
例えばこのようにすると
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