はじまり
さて、今までどんなゲームを遊んだかなあ。
Pythonでデータを可視化でございますか。
PythonからSteamの情報を覗く。
なんか最近、Pythonを再び触り始めたので、今回は少し趣向を変えてやっていこうと思います。
そこで今回は、自分のSteamアカウントに紐付いている情報を引っ張ってきて、そのデータを何らかのグラフというかチャートの形でプロットしていきたいと思います。
まあでも最近あんまりゲームしていないんで見栄えの良いグラフになるかが不安です・・・。それじゃあ行ってみましょう。
今回使うパッケージ
Pythonのバージョンは、3.12です。
今回は、Steam Web APIを直接叩くのではなく、PyPIにある「python-steam-api」パッケージを使って、Steamの情報を抜き出していきたいと思います。このパッケージは、「Steam Web API」という公式APIを叩くためのラッパーのようなものです。
このパッケージ、リファレンスは見つけられなかったのですが、パッケージの使用例の部分が沢山書いてあるので、リファレンスを見る必要はないかなと思いました。気になったところはレスポンスをもう少し深く見れば良いし。豊富なドキュメントで助かります。
可視化するためのパッケージとしては、matplotlibの結果をFastAPIとPanelで表示する感じで行きます。
Steam Web API Keyを生成する。
Steam Web APIを叩くために、API Keyを生成します。
まずは、Steamアプリを自分のスマホにインストールしてログインして、「Steamガードモバイル認証」をオンにする必要があります。ここの手順は省略します。
そしたら、このページからSteam Web API Keyを生成できます。
そのページで、ドメイン名を入力して、「Steam Web API利用規約に同意します」にチェックを入れて、「登録」します。
すると、「確認が必要です(Confirmation Required)」という文言が表示されるます。
そしたら次に、自分のスマホのSteamアプリを開きます。そしたら、画面下部のメニューバーから一番右の「縦並びの3本の横線」ボタンをタップして、「Confirmations」をタップします。
「Confirmations」をタップすると、先程のSteam Web API Keyを生成するためのページからのリクエストが届いているので、これを「Confirm」します。
そして、PCのページの方を確認すると、Steam Web API Keyが生成されました。生成したキーはこのページで確認することができます。
Steamの情報を取得する。
ユーザーの情報を取得する。
それでは、先程生成したSteam Web API Keyで「python-steam-api」パッケージを使っていきます。ちなみに、Steam Web APIから情報を取得する対象のSteamアカウントは、外部へと公開している設定になっている必要があります。
こっちのパッケージの方が、Steam Web APIから直接叩くよりもレスポンスの内容が整理されていて助かります。
まずは、「python-steam-api」パッケージをインストールします。
pip install python-steam-api
そしたら最初に、ユーザーの基本情報を取得していきます。
import os
from pprint import pprint
from steam_web_api import Steam
STEAM_API_KEY = os.environ.get("STEAM_API_KEY")
steam = Steam(STEAM_API_KEY)
user = steam.users.search_user("the12thchairman")
pprint(user)
レスポンスが取得できました。アバター画像やら最後にログオフした日時などが取れました。クランの概念を今まで知らなかった・・・。
{'player': {'avatar': 'https://avatars.steamstatic.com/427ef7d5f8ad7b21678f69bc8afc95786cf38fe6.jpg',
'avatarfull': 'https://avatars.steamstatic.com/427ef7d5f8ad7b21678f69bc8afc95786cf38fe6_full.jpg',
'avatarhash': '427ef7d5f8ad7b21678f69bc8afc95786cf38fe6',
'avatarmedium': 'https://avatars.steamstatic.com/427ef7d5f8ad7b21678f69bc8afc95786cf38fe6_medium.jpg',
'communityvisibilitystate': 3,
'loccountrycode': 'US',
'personaname': 'The12thChairman',
'personastate': 0,
'personastateflags': 0,
'primaryclanid': '103582791429521408',
'profilestate': 1,
'profileurl': 'https://steamcommunity.com/id/the12thchairman/',
'steamid': '76561198995017863',
'timecreated': 1570311509}}
次に、”steamid”からユーザー情報を紐づけていきます。
import os
from pprint import pprint
from steam_web_api import Steam
STEAM_API_KEY = os.environ.get("STEAM_API_KEY")
steam = Steam(STEAM_API_KEY)
STEAM_ID = os.environ.get("MY_STEAM_ID")
user = steam.users.get_user_details(STEAM_ID)
pprint(user)
同様に、ユーザー情報に関するレスポンスが取得できました。
{'player': {'avatar': 'https://avatars.steamstatic.com/d7fee5bba9e4a5aaacff74551c145d58289041df.jpg',
'avatarfull': 'https://avatars.steamstatic.com/d7fee5bba9e4a5aaacff74551c145d58289041df_full.jpg',
'avatarhash': 'd7fee5bba9e4a5aaacff74551c145d58289041df',
'avatarmedium': 'https://avatars.steamstatic.com/d7fee5bba9e4a5aaacff74551c145d58289041df_medium.jpg',
'communityvisibilitystate': 3,
'lastlogoff': 1733937104,
'loccountrycode': 'JP',
'locstatecode': '40',
'personaname': 'kinkinbeer135ml',
'personastate': 1,
'personastateflags': 0,
'primaryclanid': '103582791429521408',
'profilestate': 1,
'profileurl': 'https://steamcommunity.com/id/kinkingame24bit/',
'steamid': 'XXXXXXXXXXXXXXXXXX',
'timecreated': 1540612096}}
そのSteamアカウントのフレンド一覧を取得することも可能です。フィールドの数が煩雑になってきたのでpydantic
で型安全を担保します。(レスポンスの内容は省略します。)
src/config.py
import os
def get_env_variable(key: str) -> str:
# return os.environ[key]
return os.getenv(key)
def get_steam_api_key():
return get_env_variable("STEAM_API_KEY")
def get_my_steam_id():
return get_env_variable("MY_STEAM_ID")
src/steam.py
import os
from pprint import pprint
from typing import List, Optional
from pydantic import BaseModel, HttpUrl
import inspect
from steam_web_api import Steam
# Local packages
import config as cfg
class SteamPlayerProfile(BaseModel):
avatar: HttpUrl
avatarfull: HttpUrl
avatarhash: str
avatarmedium: HttpUrl
communityvisibilitystate: int
personaname: str
personastate: int
personastateflags: int
primaryclanid: str
profilestate: int
profileurl: HttpUrl
steamid: str
timecreated: int
class SteamFriendProfile(SteamPlayerProfile):
friend_since: int
relationship: str
def get_steam_client() -> Steam:
return Steam(cfg.get_steam_api_key())
def retrieve_user(steam: Steam, steam_id: str) -> SteamPlayerProfile:
pprint(f"{func_name}: start retrieving steam player info")
user = steam.users.get_user_details(steam_id)
steam_profile = SteamPlayerProfile(**user["player"])
func_name = inspect.currentframe().f_code.co_name
pprint(f"{func_name}: finish retrieving steam player info")
pprint(steam_profile)
return steam_profile
def retrieve_friends(steam: Steam, steam_id: str)-> List[SteamPlayerProfile]:
pprint(f"{func_name}: start retrieving steam friends info")
user = steam.users.get_user_friends_list(steam_id)
def to_profile_class(profile: dict) -> SteamFriendProfile:
return SteamFriendProfile(**profile)
steam_profiles = list(map(to_profile_class, user["friends"]))
func_name = inspect.currentframe().f_code.co_name
pprint(f"{func_name}: finish retrieving steam friends info")
pprint(steam_profiles)
return steam_profiles
if __name__ == "__main__":
steam = get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
user = retrieve_user(steam, STEAM_ID)
friends = retrieve_friends(steam, STEAM_ID)
ゲームの情報を取得する。
そしたら次に、ユーザーが所持しているゲームの一覧を取得してみます。加えて、先程のようにPydanticベースのクラスで型検証して、デコレータで関数の実行部分の可視化もします。
from pprint import pprint
from typing import List, Optional
from pydantic import BaseModel, HttpUrl
from functools import wraps
def function_decorator(func):
@wraps(func)
def wrapTheFunction(*args, **kwargs):
pprint(f"{func.__name__}(): start executing...")
result = func(*args, **kwargs)
# print(f"I am doing some bullshit before executing func.__name__()")
pprint(f"{func.__name__}(): finish executing.")
return result
return wrapTheFunction
class SteamGameProfile(BaseModel):
appid: int
img_icon_url: str
name: str
playtime_2weeks: Optional[int] = None
playtime_deck_forever: int
playtime_disconnected: int
playtime_forever: int
playtime_linux_forever: int
playtime_mac_forever: int
playtime_windows_forever: int
rtime_last_played: int
@function_decorator
def retrieve_owned_games(steam: Steam, steam_id: str):
res = steam.users.get_owned_games(steam_id)
def to_profile_class(profile: dict) -> SteamGameProfile:
return SteamGameProfile(**profile)
steam_games = list(map(to_profile_class, res["games"]))
pprint(steam_games)
pprint(res["game_count"])
pprint(len(steam_games))
return steam_games
if __name__ == "__main__":
steam = get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
games = retrieve_owned_games(steam, STEAM_ID)
実行すると色々とゲームに関する情報を取得できました。2週間以内に起動されたゲームだけには、"playtime_2weeks"
というフィールドがありました。PydanticではそのフィールドだけオプショナルでNoneに設定しています。
'retrieve_owned_games(): start executing...'
[SteamGameProfile(appid=400, img_icon_url='cfa928ab4119dd137e50d728e8fe703e4e970aff', name='Portal', playtime_2weeks=None, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=205, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=205, rtime_last_played=1691772949),
SteamGameProfile(appid=40100, img_icon_url='96e22cb9c9b063c9f0398f248fef850a679ced5a', name='Supreme Commander 2', playtime_2weeks=None, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=0, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=0, rtime_last_played=0),
SteamGameProfile(appid=620, img_icon_url='2e478fc6874d06ae5baf0d147f6f21203291aa02', name='Portal 2', playtime_2weeks=None, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=2, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=2, rtime_last_played=1696106875),
...
SteamGameProfile(appid=1601580, img_icon_url='5e66161686d4e2503a8a42aab9e8bc1c46c68fc1', name='Frostpunk 2', playtime_2weeks=None, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=1569, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=1569, rtime_last_played=1727178622),
SteamGameProfile(appid=983870, img_icon_url='8fe2f591098d69505dab3154c7397ca2f7594bc2', name='FOUNDRY', playtime_2weeks=None, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=0, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=0, rtime_last_played=0),
SteamGameProfile(appid=2854710, img_icon_url='4f408e9f135d7d2d117af3697c7f1a4bfaf10771', name='Chocolate Factory', playtime_2weeks=4, playtime_deck_forever=0, playtime_disconnected=0, playtime_forever=2017, playtime_linux_forever=0, playtime_mac_forever=0, playtime_windows_forever=2017, rtime_last_played=1733042847)]
185
185
'retrieve_owned_games(): finish executing.'
それではそのゲームで自分が積み重ねたstatsやachievementsを確認してみます。
class Achievement(BaseModel):
achieved: int
name: str
class Stat(BaseModel):
name: str
value: int
class SteamGameData(BaseModel):
achievements: Optional[List[Achievement]] = None
gameName: str
stats: Optional[List[Stat]] = None
steamID: str
@function_decorator
def retrieve_game_data(steam: Steam, steam_id: str, app_id: int):
game_data = None
try:
res = steam.apps.get_user_stats(steam_id, app_id)
game_data = SteamGameData(**res["playerstats"])
except Exception as e:
pprint(e.args[0])
if "400 Bad Request" not in e.args[0]:
raise e.args[0] # Raise content of exception.
pass
pprint(game_data)
return game_data
if __name__ == "__main__":
steam = get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
game_data = retrieve_game_data(steam, STEAM_ID, 1888160)
print("\n")
game_data = retrieve_game_data(steam, STEAM_ID, 1259790)
print("\n")
game_data = retrieve_game_data(steam, STEAM_ID, 2854710)
stats
フィールドだけ無いゲームや、stats
フィールドとachievements
フィールドの両方とも無いのでリクエストすると例外が発生するゲームもあります。なので、レスポンスで「400 Bad Request
」が返ってくる場合にはNone
とすることにしました。
個人的に、テトリスのstatsは見てみたかったですね。
‘retrieve_game_data(): start executing…’
SteamGameData(achievements=[Achievement(achieved=1, name=’ACH02′), Achievement(achieved=1, name=’ACH03′), Achievement(achieved=1, name=’ACH04′), Achievement(achieved=1, name=’ACH05′), Achievement(achieved=1, name=’ACH06′), Achievement(achieved=1, name=’ACH07′), Achievement(achieved=1, name=’ACH08′), Achievement(achieved=1, name=’ACH09′), Achievement(achieved=1, name=’ACH10′), Achievement(achieved=1, name=’ACH11′), Achievement(achieved=1, name=’ACH12′), Achievement(achieved=1, name=’ACH13′), Achievement(achieved=1, name=’ACH14′), Achievement(achieved=1, name=’ACH15′), Achievement(achieved=1, name=’ACH16′), Achievement(achieved=1, name=’ACH17′), Achievement(achieved=1, name=’ACH18′), Achievement(achieved=1, name=’ACH19′), Achievement(achieved=1, name=’ACH20′), Achievement(achieved=1, name=’ACH21′), Achievement(achieved=1, name=’ACH22′), Achievement(achieved=1, name=’ACH23′), Achievement(achieved=1, name=’ACH24′), Achievement(achieved=1, name=’ACH25′), Achievement(achieved=1, name=’ACH26′), Achievement(achieved=1, name=’ACH27′), Achievement(achieved=1, name=’ACH28′), Achievement(achieved=1, name=’ACH29′)], gameName=’ARMORED CORE VI FIRES OF RUBICON’, stats=None, steamID=’XXXXXXXXXXXXX’)
‘retrieve_game_data(): finish executing.’
‘retrieve_game_data(): start executing…’
SteamGameData(achievements=[Achievement(achieved=1, name=’ID_02_WonKnockoutPast03′), Achievement(achieved=1, name=’ID_03_WonKnockoutPast05′), Achievement(achieved=1, name=’ID_04_WonKnockoutPast10′), Achievement(achieved=1, name=’ID_29_TetoMinoErase1000′), Achievement(achieved=1, name=’ID_30_TetoMinoErase10000′), Achievement(achieved=1, name=’ID_31_TetoTETRISx50′), Achievement(achieved=1, name=’ID_32_TetoTETRISx100′), Achievement(achieved=1, name=’ID_33_TetoB2Bx25′), Achievement(achieved=1, name=’ID_34_TetoB2Bx50′), Achievement(achieved=1, name=’ID_35_Teto5REN’), Achievement(achieved=1, name=’ID_36_Teto8REN’), Achievement(achieved=1, name=’ID_37_TetoPERFECT1′), Achievement(achieved=1, name=’ID_38_TetoPERFECT3′), Achievement(achieved=1, name=’ID_39_TetoMinoErase50000′), Achievement(achieved=1, name=’ID_40_TetoTETRISx200′), Achievement(achieved=1, name=’ID_41_TetoB2Bx100′), Achievement(achieved=1, name=’ID_42_TetoMinoErase100000′)], gameName=’Tenpex’, stats=[Stat(name=’stat_knockoutPastMaxCount’, value=14), Stat(name=’stat_adventureAchivement’, value=1), Stat(name=’stat_puyoEraseCount’, value=327), Stat(name=’stat_puyoChain3Count’, value=3), Stat(name=’stat_puyoChain4Count’, value=2), Stat(name=’stat_puyoChain5Count’, value=1), Stat(name=’stat_puyoAllEraseCount’, value=1), Stat(name=’stat_tetoMinoEraseCount’, value=855830), Stat(name=’stat_tetoTetrisCount’, value=3533), Stat(name=’stat_tetoB2BCount’, value=2832), Stat(name=’stat_tetoRENMaxValue’, value=8), Stat(name=’stat_tetoPerfectCount’, value=216), Stat(name=’stat_sVS’, value=1728), Stat(name=’stat_sSWAP’, value=6), Stat(name=’stat_sMIX’, value=4), Stat(name=’stat_sMARATHON’, value=1542)], steamID=’XXXXXXXXXXX’)
‘retrieve_game_data(): finish executing.’
‘retrieve_game_data(): start executing…’
‘400 Bad Request {}’
None
‘retrieve_game_data(): finish executing.’
「steam.apps.get_user_achievements("<steam_id>", "<app_id>")
」と検索することで、achievements
のみを取得することも可能みたいです。
class Achievement(BaseModel):
achieved: int
apiname: str
description: str
name: str
unlocktime: int
class SteamGameAchievements(BaseModel):
achievements: List[Achievement]
gameName: str
steamID: str
success: bool
@function_decorator
def retrieve_achievements(steam: Steam, steam_id: str, app_id: int):
achievements = None
try:
res = steam.apps.get_user_achievements(steam_id, app_id)
achievements = SteamGameAchievements(**res["playerstats"])
except Exception as e:
pprint(e.args[0])
if "400 Bad Request" not in e.args[0]:
raise e.args[0] # Raise content of exception.
pass
pprint(achievements)
return achievements
if __name__ == "__main__":
steam = get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
achievements = retrieve_achievements(steam, STEAM_ID, 1888160)
achievements = retrieve_achievements(steam, STEAM_ID, 1259790)
achievements = retrieve_achievements(steam, STEAM_ID, 2854710)
「steam.apps.get_user_achievements(“<steam_id>”, “<app_id>”)」と検索する方が、achievementに関する情報がより詳細に格納されています。
‘retrieve_achievements(): start executing…’
SteamGameAchievements(achievements=[Achievement(achieved=0, apiname=’ACH00′, description=’Unlocked all achievements.’, name=’Armored Core’, unlocktime=0), Achievement(achieved=0, apiname=’ACH01′, description=”, name=’The Perfect Mercenary’, unlocktime=0), Achievement(achieved=1, apiname=’ACH02′, description=”, name=’Stargazer’, unlocktime=1696261035), Achievement(achieved=1, apiname=’ACH03′, description=”, name=’Master of Arena’, unlocktime=1695489096), Achievement(achieved=1, apiname=’ACH04′, description=”, name=’Asset Holder’, unlocktime=1695996405), Achievement(achieved=1, apiname=’ACH05′, description=”, name=’Tuning Expert’, unlocktime=1697118086), Achievement(achieved=1, apiname=’ACH06′, description=”, name=’The Fires of Raven’, unlocktime=1694797344), Achievement(achieved=1, apiname=’ACH07′, description=”, name=’Liberator of Rubicon’, unlocktime=1695064984), Achievement(achieved=1, apiname=’ACH08′, description=”, name=’Alea Iacta Est’, unlocktime=1696261035), Achievement(achieved=1, apiname=’ACH09′, description=”, name=’Weapon Collector’, unlocktime=1695996404), Achievement(achieved=1, apiname=’ACH10′, description=”, name=’External Parts Collector’, unlocktime=1695996378), Achievement(achieved=1, apiname=’ACH11′, description=”, name=’Internal Parts Collector’, unlocktime=1695996392), Achievement(achieved=1, apiname=’ACH12′, description=”, name=’Expansion Collector’, unlocktime=1695104565), Achievement(achieved=1, apiname=’ACH13′, description=”, name=’Combat Log Collector’, unlocktime=1695576602), Achievement(achieved=1, apiname=’ACH14′, description=”, name=’Data Log Collector’, unlocktime=1693499027), Achievement(achieved=1, apiname=’ACH15′, description=”, name=’Testing Complete’, unlocktime=1694468979), Achievement(achieved=1, apiname=’ACH16′, description=”, name=’Illegal Entry’, unlocktime=1693148748), Achievement(achieved=1, apiname=’ACH17′, description=”, name=’Operation Wallclimber’, unlocktime=1693497884), Achievement(achieved=1, apiname=’ACH18′, description=”, name=’Contact’, unlocktime=1693516862), Achievement(achieved=1, apiname=’ACH19′, description=”, name=’Ocean Crossing’, unlocktime=1693772650), Achievement(achieved=1, apiname=’ACH20′, description=”, name=’A New Threat’, unlocktime=1694181014), Achievement(achieved=1, apiname=’ACH21′, description=”, name=’Ayre and the Coral’, unlocktime=1694185005), Achievement(achieved=1, apiname=’ACH22′, description=”, name=’Into Unknown Territory’, unlocktime=1694448640), Achievement(achieved=1, apiname=’ACH23′, description=”, name=’Re-education’, unlocktime=1694620467), Achievement(achieved=1, apiname=’ACH24′, description=”, name=’The Floating City’, unlocktime=1694623817), Achievement(achieved=1, apiname=’ACH25′, description=”, name=’MIA’, unlocktime=1695570379), Achievement(achieved=1, apiname=’ACH26′, description=”, name=’Training Complete’, unlocktime=1694113205), Achievement(achieved=1, apiname=’ACH27′, description=’Assembled an AC.’, name=’Hardware Engineer’, unlocktime=1693232881), Achievement(achieved=1, apiname=’ACH28′, description=”Upgraded your AC’s OS.”, name=’Software Engineer’, unlocktime=1693499667), Achievement(achieved=1, apiname=’ACH29′, description=’Changed the coloration of your AC.’, name=’Graphic Designer’, unlocktime=1693149176)], gameName=’ARMORED CORE™ VI FIRES OF RUBICON™’, steamID=’XXXXXXXXXXXXXXXXXXX’, success=True)
‘retrieve_achievements(): finish executing.’
‘retrieve_achievements(): start executing…’
SteamGameAchievements(achievements=[Achievement(achieved=0, apiname=’ID_01_AllRulePlayed’, description=’Played all 6 offline modes’, name=’Competitor’, unlocktime=0), Achievement(achieved=1, apiname=’ID_02_WonKnockoutPast03′, description=’Defeated 3 opponents in an Endurance match’, name=’Duelist (x3)’, unlocktime=1691515720), Achievement(achieved=1, apiname=’ID_03_WonKnockoutPast05′, description=’Defeated 5 opponents in an Endurance match’, name=’Duelist (x5)’, unlocktime=1691515720), Achievement(achieved=1, apiname=’ID_04_WonKnockoutPast10′, description=’Defeated 10 opponents in an Endurance match’, name=’Duelist (x10)’, unlocktime=1691517402), Achievement(achieved=0, apiname=’ID_05_AllTokoPuyoRulePlayed’, description=’Played all 3 Puyo Puyo Challenge modes’, name=’Competitor (Puyo Puyo)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_06_AllTokoTetoRulePlayed’, description=’Played all 3 Tetris Challenge modes’, name=’Competitor (Tetris)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_07_ClearAdventure’, description=’Completed the main story within Adventure mode and watched the ending’, name=’Historian’, unlocktime=0), Achievement(achieved=0, apiname=’ID_08_WonLeagueMatch01′, description=’Won your first Puzzle League match’, name=’Gladiator (x1)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_09_WonClubMatch’, description=’Won your first Free Play match’, name=’Prize Fighter’, unlocktime=0), Achievement(achieved=0, apiname=’ID_10_AdventureAchievement50′, description=’Achieved 50% completion in Adventure mode’, name=’Wanderer’, unlocktime=0), Achievement(achieved=0, apiname=’ID_11_WonKnockoutPast15′, description=’Defeated 15 opponents in an Endurance match’, name=’Duelist (x15)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_12_AdventureAchievement70′, description=’Achieved 70% completion in Adventure mode’, name=’Devotee’, unlocktime=0), Achievement(achieved=0, apiname=’ID_13_WonLeagueMatch10′, description=’Won ten Puzzle League matches’, name=’Gladiator (x10)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_14_AdventureAchievement100′, description=’Achieved 100% completion in Adventure mode’, name=’Completionist’, unlocktime=0), Achievement(achieved=0, apiname=’ID_15_PuyoErase1000′, description=’Popped 1,000 Puyos in completed matches’, name=’Puyo King (x1,000)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_16_PuyoErase10000′, description=’Popped 10,000 Puyos in completed matches’, name=’Puyo King (x10,000)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_17_Puyo3Chain100′, description=’Performed a 3-Chain 100 times in completed matches’, name=’3-Chain Master’, unlocktime=0), Achievement(achieved=0, apiname=’ID_18_Puyo4Chain100′, description=’Performed a 4-Chain 100 times in completed matches’, name=’4-Chain Master’, unlocktime=0), Achievement(achieved=0, apiname=’ID_19_Puyo5Chain100′, description=’Performed a 5-Chain 100 times in completed matches’, name=’5-Chain Master’, unlocktime=0), Achievement(achieved=0, apiname=’ID_20_Puyo6Chain100′, description=’Performed a 6-Chain 100 times in completed matches’, name=’6-Chain Master’, unlocktime=0), Achievement(achieved=0, apiname=’ID_21_PuyoAllErase50′, description=’Performed an All Clear 50 times in completed matches.’, name=’Screen Cleaner (x50)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_22_PuyoAllErase100′, description=’Performed an All Clear 100 times in completed matches’, name=’Screen Cleaner (x100)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_23_Puyo3ColorErase’, description=’Cleared 3 different-colored Puyo groups simultaneously in a completed match’, name=’Chromatic Popper’, unlocktime=0), Achievement(achieved=0, apiname=’ID_24_Puyo4ColorErase’, description=’Cleared 4 different-colored Puyo groups simultaneously in a completed match’, name=’Prismatic Popper’, unlocktime=0), Achievement(achieved=0, apiname=’ID_25_PuyoErase50000′, description=’Popped 50,000 Puyos in completed matches’, name=’Puyo King (x50,000)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_26_Puyo7Chain150′, description=’Performed a 7-Chain 150 times in completed matches’, name=’7-Chain Master’, unlocktime=0), Achievement(achieved=0, apiname=’ID_27_PuyoAllErase150′, description=’Performed an All Clear 150 times in completed matches’, name=’Screen Cleaner (x150)’, unlocktime=0), Achievement(achieved=0, apiname=’ID_28_PuyoErase100000′, description=’Popped 100,000 Puyos in completed matches’, name=’Puyo King (x100,000)’, unlocktime=0), Achievement(achieved=1, apiname=’ID_29_TetoMinoErase1000′, description=’Cleared 1,000 Minos in completed matches’, name=’Mino King (x1,000)’, unlocktime=1688252641), Achievement(achieved=1, apiname=’ID_30_TetoMinoErase10000′, description=’Cleared 10,000 Minos in completed matches’, name=’Mino King (x10,000)’, unlocktime=1688292201), Achievement(achieved=1, apiname=’ID_31_TetoTETRISx50′, description=’Performed a Tetris Line Clear 50 times in completed matches’, name=’Tetris Champ (x50)’, unlocktime=1688254876), Achievement(achieved=1, apiname=’ID_32_TetoTETRISx100′, description=’Performed a Tetris Line Clear 100 times in completed matches’, name=’Tetris Champ (x100)’, unlocktime=1688291516), Achievement(achieved=1, apiname=’ID_33_TetoB2Bx25′, description=’Performed a Back-to-Back 25 times in completed matches’, name=’Show Off (x25)’, unlocktime=1688254876), Achievement(achieved=1, apiname=’ID_34_TetoB2Bx50′, description=’Performed a Back-to-Back 50 times in completed matches’, name=’Show Off (x50)’, unlocktime=1688292629), Achievement(achieved=1, apiname=’ID_35_Teto5REN’, description=’Performed a 5-Combo in a completed match’, name=’Combo Maker’, unlocktime=1688302367), Achievement(achieved=1, apiname=’ID_36_Teto8REN’, description=’Performed an 8-Combo in a completed match’, name=’Combo Master’, unlocktime=1688401629), Achievement(achieved=1, apiname=’ID_37_TetoPERFECT1′, description=’Performed a Perfect Clear in a completed match’, name=’Perfectionist (x1)’, unlocktime=1721329100), Achievement(achieved=1, apiname=’ID_38_TetoPERFECT3′, description=’Performed a Perfect Clear 3 times in completed matches’, name=’Perfectionist (x3)’, unlocktime=1721329241), Achievement(achieved=1, apiname=’ID_39_TetoMinoErase50000′, description=’Cleared 50,000 Minos in completed matches’, name=’Mino King (x50,000)’, unlocktime=1688324982), Achievement(achieved=1, apiname=’ID_40_TetoTETRISx200′, description=’Performed a Tetris Line Clear 200 times in completed matches’, name=’Tetris Champ (x200)’, unlocktime=1688297436), Achievement(achieved=1, apiname=’ID_41_TetoB2Bx100′, description=’Performed a Back-to-Back 100 times in completed matches’, name=’Show Off (x100)’, unlocktime=1688318590), Achievement(achieved=1, apiname=’ID_42_TetoMinoErase100000′, description=’Cleared 100,000 Minos in completed matches’, name=’Mino King (x100,000)’, unlocktime=1688456802), Achievement(achieved=0, apiname=’ID_00_AllGet’, description=’For obtaining all trophies.’, name=’All Trophies Obtained’, unlocktime=0)], gameName=’Puyo Puyo™ Tetris® 2′, steamID=’XXXXXXXXXXXXXXXX’, success=True)
‘retrieve_achievements(): finish executing.’
‘retrieve_achievements(): start executing…’
(‘400 Bad Request {“playerstats”:{“error”:”Requested app has no ‘
‘stats”,”success”:false}}’)
None
‘retrieve_achievements(): finish executing.’
Steamの情報を可視化する。
それでは獲得したSteamの情報をWebページで表示できるようにしていきます。
まずは、FastAPIおよびPanelのパッケージをインストールします。
pip install FastAPI
pip install Panel[FastAPI]
app.pyでFastAPIのルーター的なものを実装して、panel_creator.pyで実際にSteamの情報を表示する処理を書いていきます。
src/app.py
# Builtin packages
import os
# Third party packages
from fastapi import FastAPI
from panel.io.fastapi import add_applications
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
# Local packages
import panel_creator
app = FastAPI()
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # List of allowed origins
allow_credentials=True,
allow_methods=["*"], # Allow all methods
allow_headers=["*"], # Allow all headers
)
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/steam")
async def read_steam():
return {"Hello": "Steam"}
add_applications({
"/pnl_app21": panel_creator.create_game_thumbnails()
}, app=app)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
src/panel_creator.py
# Builtin packages
from pprint import pprint
from typing import List
# Third party packages
import panel as pn
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.figure
import datetime as dt
from bokeh.models import CustomJS, Slider
# Local packages
import steam
import config as cfg
pn.extension(comms="vscode") # to use on VSCode
def create_game_thumbnails() -> pn.template.base.BaseTemplate.servable:
steam_client = steam.get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
games = steam.retrieve_owned_games(steam_client, STEAM_ID)
dashboard = create_dashboard_with_html_header()
def get_img_icon(game: steam.SteamGameProfile) -> pn.pane.image.Image:
img_icon = f"https://avatars.akamai.steamstatic.com/{game.img_icon_url}_full.jpg"
pn.pane.Image()
return pn.pane.Image(img_icon, alt_text=f"{game.name}_icon", width=20, height=20)
imgs = tuple(map(get_img_icon, games))
NUMBER_OF_ICON_PER_ROW = 30
def create_img_panels(elements: List[pn.pane.image.Image], number_of_icon_per_row: int) -> List[pn.layout.base.Row]:
columns = []
col = []
for i, component in enumerate(elements):
col.append(component)
if (i + 1) % number_of_icon_per_row == 0: # Create a new column per 30 components
columns.append(pn.Row(*col, sizing_mode="scale_width"))
col = []
if col: # Append rest components into the last column.
columns.append(pn.Row(*col, sizing_mode="scale_width"))
panels = pn.Column(*columns, sizing_mode="scale_width")
return panels
panels = create_img_panels(imgs, NUMBER_OF_ICON_PER_ROW)
dashboard.main.append(pn.Column(*panels, sizing_mode="scale_width"))
return dashboard.servable()
def create_dashboard_with_html_header() -> pn.template.material.MaterialTemplate:
custom_header = """
<link rel="icon" type="image/x-icon" href="./../static/favicon_08bit.ico">
"""
# Create Panel template to append custom HTML
dashboard = pn.template.MaterialTemplate(
title="My Panel App",
header=custom_header
)
return dashboard
ゲームのアイコンが一覧で取れるようになりました。
次に、「src/panel_creator.py」にサムネイルを一覧で取得するように実装します。
# Builtin packages
from pprint import pprint
from typing import List
# Third party packages
import panel as pn
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.figure
import datetime as dt
from bokeh.models import CustomJS, Slider
# Local packages
import steam
import config as cfg
pn.extension(comms="vscode") # to use on VSCode
@steam.function_decorator
def create_dashboard_with_html_header() -> pn.template.material.MaterialTemplate:
custom_header = """
<link rel="icon" type="image/x-icon" href="./../static/favicon_08bit.ico">
"""
# Create Panel template to append custom HTML
dashboard = pn.template.MaterialTemplate(
title="My Panel App",
header=custom_header
)
return dashboard
# @steam.function_decorator
def _get_game_img(game: steam.SteamGameProfile, img_category: str = None) -> pn.pane.image.Image:
img_icon = ""
width=20
height=20
margin=(10, 10)
if img_category is None:
raise ValueError("'img_category' must not be None.")
elif img_category == "icon":
img_icon = f"https://avatars.akamai.steamstatic.com/{game.img_icon_url}_full.jpg"
# Not change width, height and margin.
elif img_category == "thumbnail":
img_icon = f"https://shared.fastly.steamstatic.com//store_item_assets/steam/apps/{game.appid}/library_600x900.jpg"
width=200
height=300
margin=(0, 0)
else:
raise ValueError("something went wrong.")
pn.pane.Image()
return pn.pane.Image(img_icon, alt_text=f"{game.name}_icon", width=width, height=height, margin=margin)
@steam.function_decorator
def create_img_panels(elements: List[pn.pane.image.Image], number_of_icon_per_row: int, sizing_mode: str = "stretch_both") -> List[pn.layout.base.Row]:
columns = []
col = []
for i, component in enumerate(elements):
col.append(component)
if (i + 1) % number_of_icon_per_row == 0: # Create a new column per 30 components
columns.append(pn.Row(*col, sizing_mode=sizing_mode, margin=(0, 0)))
col = []
if col: # Append rest components into the last column.
columns.append(pn.Row(*col, sizing_mode=sizing_mode, margin=(0, 0)))
panels = pn.Column(*columns, sizing_mode=sizing_mode)
return panels
@steam.function_decorator
def create_game_icons() -> pn.template.base.BaseTemplate.servable:
steam_client = steam.get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
games = steam.retrieve_owned_games(steam_client, STEAM_ID)
dashboard = create_dashboard_with_html_header()
iterable_01 = lambda x, y: [x] * len(y)
imgs = tuple(map(_get_game_img, games, iterable_01("icon", games)))
NUMBER_OF_ICON_PER_ROW = 30
panels = create_img_panels(imgs, NUMBER_OF_ICON_PER_ROW, "scale_width")
pprint(panels)
dashboard.main.append(pn.Column(*panels, sizing_mode="scale_width"))
return dashboard.servable()
@steam.function_decorator
def create_game_thumbnails() -> pn.template.base.BaseTemplate.servable:
steam_client = steam.get_steam_client()
STEAM_ID = cfg.get_my_steam_id()
games = steam.retrieve_owned_games(steam_client, STEAM_ID)
dashboard = create_dashboard_with_html_header()
iterable_01 = lambda x, y: [x] * len(y)
imgs = tuple(map(_get_game_img, games, iterable_01("thumbnail", games)))
NUMBER_OF_ICON_PER_ROW = 12
panels = create_img_panels(imgs, NUMBER_OF_ICON_PER_ROW, "stretch_both")
pprint(panels)
dashboard.main.append(pn.Column(*panels, sizing_mode="scale_width"))
return dashboard.servable()
def create_game_achievements():
dashboard = pn.Column(layout)
return dashboard.servable()
今度は、ゲームのサムネイルが一覧で取れるようになりました。リンク切れになっているタイトルがチラホラあったりもします。
そして、ゲーム毎にサムネイル、タイトル、AchievementsやStatsを表示させます。
# Builtin packages
from pprint import pprint
from typing import List, Tuple, TypeVar
import functools
import datetime
# Third party packages
import panel as pn
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.figure
import datetime as dt
from bokeh.models import CustomJS, Slider
from steam_web_api import Steam
# Local packages
import steam
import config as cfg
pn.extension(comms="vscode")
def format_from_timestamp_to_format_iso_datetime(timestamp: int, will_be_utc: bool) -> str:
"""
Convert a Unix timestamp into an ISO 8601 formatted date string.
:param timestamp: Unix timestamp (int or float)
:param will_be_utc: Boolean representing whether the date is going to format in UTC time
:type timestamp: int or float
:return: ISO 8601 formatted date string or empty string if input is invalid
"""
if not timestamp:
return None
if not isinstance(timestamp, (int, float)):
raise ValueError("Timestamp must be an integer or float.")
if will_be_utc:
jst_delta = 9 * 60 * 60
timestamp += jst_delta
try:
return datetime.datetime.fromtimestamp(timestamp).isoformat()
except (ValueError, TypeError):
return ""
def convert_from_iso_datetime_to_format_date_str(iso_date_string: str) -> str:
"""
Format ISO date string into a datetime object or return an empty string.
:param date_string: String representing a date
:return: Formatted datetime object or empty string
"""
if not iso_date_string:
return None
if not isinstance(iso_date_string, str):
raise ValueError("ISO date string must be a string.")
try:
return datetime.datetime.fromisoformat(iso_date_string).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
return ""
def format_minutes_to_hours(minutes: int) -> str:
hours = minutes // 60
remaining_minutes = minutes % 60
spacer = " "
if hours > 0 and remaining_minutes > 0:
return f"{hours}{spacer}hours {remaining_minutes}{spacer}minutes"
elif hours > 0:
return f"{hours}{spacer}hours "
else:
return f"{remaining_minutes}{spacer}minutes"
def _create_h2(game: steam.SteamGameProfile) -> Tuple[pn.pane.image.Image, pn.pane.Markdown]:
icon = _get_game_img(game, "icon")
h2 = pn.pane.Markdown(f"## {game.name}", styles={})
pprint(icon.object if icon is not None else icon)
pprint(h2.object if h2 is not None else h2)
return (icon, h2)
def _create_playtime(game: steam.SteamGameProfile) -> pn.pane.Markdown:
iso_datetime = format_from_timestamp_to_format_iso_datetime(game.rtime_last_played, True)
date_str = convert_from_iso_datetime_to_format_date_str(iso_datetime)
playtime_2weeks_formatted = format_minutes_to_hours(game.playtime_2weeks) if game.playtime_2weeks is not None else game.playtime_2weeks
playtime_disconnected_formatted = format_minutes_to_hours(game.playtime_disconnected) if game.playtime_disconnected is not None else game.playtime_disconnected
playtime_forever_formatted = format_minutes_to_hours(game.playtime_forever) if game.playtime_forever is not None else game.playtime_forever
playtime = pn.pane.Markdown(f"""
| Playtime | Value |
| ----------- | ----------- |
| Playtime recent 2 weeks | {playtime_2weeks_formatted} |
| Disconnected Playtime | {playtime_disconnected_formatted} |
| Forever Playtime | {playtime_forever_formatted} |
| Recent time last played | {date_str} |
""", styles={})
pprint(playtime.object if playtime is not None else playtime)
return playtime
def _create_stats_data_info(stats_data: steam.SteamGameStatsData) -> TypeVar("T", pn.pane.Markdown, None):
stats_info = None
if stats_data is not None:
if stats_data.stats is not None:
def create_stats_value_table(a: steam.Achievement) -> str:
return f"| {a.name} | {a.value} |"
stats = tuple(map(create_stats_value_table, stats_data.stats))
stats_info = functools.reduce(lambda a, b: f"{a}\n{b}", stats)
stats_info = f"| Stat Name | Stat Value |\n| ----------- | ----------- |\n{stats_info}"
stats_info = pn.pane.Markdown(stats_info)
pprint(stats_info.object if stats_info is not None else stats_info)
return stats_info
def _create_achievement_info(achievements: steam.SteamGameAchievements) -> Tuple[TypeVar("T", pn.pane.Markdown, None), TypeVar("T", pn.pane.Markdown, None)]:
achievement_info = None
description_info = None
if achievements is not None:
achievement_info = pn.pane.Markdown(f"""
| Playtime | Value |
| ----------- | ----------- |
| Achievements can be retrieved | {achievements.success} |
""", styles={})
if achievements.achievements is not None:
def create_achievement_value_table(a: steam.Achievement) -> str:
iso_datetime = format_from_timestamp_to_format_iso_datetime(a.unlocktime, True)
date_str = convert_from_iso_datetime_to_format_date_str(iso_datetime)
return f"| {a.name} | {a.apiname} | {a.description} | {a.achieved} | {date_str} |"
descriptions = tuple(map(create_achievement_value_table, achievements.achievements))
description_info = functools.reduce(lambda a, b: f"{a}\n{b}", descriptions)
description_info = f"| Achievement Name | Achievement API Name | Description | Is Achieved | Unlock Time |\n| ----------- | ----------- | ----------- | ----------- | ----------- |\n{description_info}"
description_info = pn.pane.Markdown(description_info)
pprint(achievement_info.object if achievement_info is not None else achievement_info)
pprint(description_info.object if description_info is not None else description_info)
return (achievement_info, description_info)
def _get_game_detail(game: steam.SteamGameProfile, steam_client: Steam, STEAM_ID: str) -> pn.layout.base.Column:
# Retrieve game data to show
game_data = steam.retrieve_game_data(steam_client, STEAM_ID, game.appid)
achievements = steam.retrieve_achievements(steam_client, STEAM_ID, game.appid)
# Create Heading 2
(icon, h2) = _create_h2(game)
# Create playtime info
playtime = _create_playtime(game)
# Create achievement info
(achievement_info, description_info) = _create_achievement_info(achievements)
# Create stat info
stats_data = _create_stats_data_info(game_data)
# Create image
thumbnail = _get_game_img(game, "thumbnail")
# Create layout in the panel
row1 = pn.Row(icon, h2, margin=(0, 0))
row2_2 = pn.Column(achievement_info, description_info)
row2_3 = pn.Row(stats_data)
row2 = pn.Row(thumbnail, playtime, row2_2, row2_3)
result = pn.Column(row1, row2)
return result
@steam.function_decorator
def create_game_details() -> pn.template.base.BaseTemplate.servable:
my_steam_client = steam.get_steam_client()
MY_STEAM_ID = cfg.get_my_steam_id()
games = steam.retrieve_owned_games(my_steam_client, MY_STEAM_ID)
dashboard = create_dashboard_with_html_header()
iterable_01 = lambda x, y: [x] * len(y)
# games = games[:4] # tmp TODO
game_details = tuple(map(_get_game_detail, games, iterable_01(my_steam_client, games), iterable_01(MY_STEAM_ID, games)))
dashboard.main.append(pn.Column(*game_details, sizing_mode="scale_width"))
return dashboard.servable()
panel.pane.Markdownのクラスを使って、ゲームのAchievementsを表示させることが出来ました。
同様に、ゲームのStatsも右の方に表示することが出来ました。PanelではMarkdownを書けるのが便利ですね。
まあ、上記のコードだと処理が直列になっているので、並行もしくは並列化したいですね。とりあえず今回はこんなところで終わっておきます・・・。
まとめ
今回は、Pythonを使って、matplotlibでプロットしたチャートを、FastAPIおよびPanelでWebページとして表示するツールを作る流れを紹介しました。
以下、本記事のまとめです。
- 「python-steam-api」パッケージの方が、Steam Web APIよりもレスポンスの情報が整理されている。
- 「python-steam-api」で、ユーザーの基本情報や所持しているゲームの情報を取得することが出来る。
- PanelではMarkdownを使ってページの描画を制御できる。
今回、プロットするための情報があまり無かったのでグラフは作りませんでした。なぜなら、python-steam-apiで時系列データを取得できなかったからです。これに関しては、毎月のタイミングとかでデータを取得するしかないですかね。データが貯まったらプロットしたチャートも映してみたいです。
ゲームとプログラミングのお供に
Logicool G ゲーミングキーボード G515 LIGHTSPEED TKL 薄型 ワイヤレス テンキーレス キーボード G515-WL-TCBK タクタイル 茶軸 確かな打鍵感 日本語配列 LIGHTSPEED Bluetooth 接続対応 LIGHTSYNC RGB ブラック PC 国内正規品
エレコム ラピッドトリガー ゲーミングキーボード V custom VK720AL 有線 磁気式アナログ検知スイッチ搭載 75%サイズ ロープロファイル ブラック TK-VK720ALBK
Logicool G(ロジクール G) ゲーミングマウスパッド G240 クロス表面 標準サイズ 340×280×1mm マウスパッド G240f 国内正規品
ATK ワイヤレス ゲーミングマウス VXE Dragonfly R1 Pro Black 軽量48グラム Pixart PAW3395搭載 最大75時間 冷感コーティング 4Kポーリングレート対応 国内正規品
おしまい
なんか作れそうな感じはするなあ。
データが貯まるまで待ちですか。
以上になります!
コメント