【Python】Steam Web API Keyを発行して、そこから最近遊んだゲームを可視化する
はじまり


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 osfrom 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 osfrom 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 osfrom pprint import pprintfrom typing import List, Optionalfrom pydantic import BaseModel, HttpUrlimport inspect
from steam_web_api import Steam# Local packagesimport 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 pprintfrom typing import List, Optionalfrom pydantic import BaseModel, HttpUrlfrom 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_decoratordef 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)]185185'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_decoratordef 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_decoratordef 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 FastAPIpip install Panel[FastAPI]app.pyでFastAPIのルーター的なものを実装して、panel_creator.pyで実際にSteamの情報を表示する処理を書いていきます。
src/app.py:
# Builtin packagesimport os# Third party packagesfrom fastapi import FastAPIfrom panel.io.fastapi import add_applicationsfrom fastapi.middleware.cors import CORSMiddlewareimport uvicorn# Local packagesimport 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 packagesfrom pprint import pprintfrom typing import List# Third party packagesimport panel as pnimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport matplotlib.figureimport datetime as dtfrom bokeh.models import CustomJS, Slider# Local packagesimport steamimport 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 packagesfrom pprint import pprintfrom typing import List# Third party packagesimport panel as pnimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport matplotlib.figureimport datetime as dtfrom bokeh.models import CustomJS, Slider# Local packagesimport steamimport config as cfg
pn.extension(comms="vscode") # to use on VSCode
@steam.function_decoratordef 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_decoratordef _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_decoratordef 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_decoratordef 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_decoratordef 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 packagesfrom pprint import pprintfrom typing import List, Tuple, TypeVarimport functoolsimport datetime# Third party packagesimport panel as pnimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport matplotlib.figureimport datetime as dtfrom bokeh.models import CustomJS, Sliderfrom steam_web_api import Steam# Local packagesimport steamimport 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_decoratordef 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で時系列データを取得できなかったからです。これに関しては、毎月のタイミングとかでデータを取得するしかないですかね。データが貯まったらプロットしたチャートも映してみたいです。
ゲームとプログラミングのお供に
おしまい


以上になります!
記事を共有
この記事が役に立ったなら、ぜひ他の人と共有してください!