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

Code

はじまり

リサちゃん
リサちゃん

さて、今までどんなゲームを遊んだかなあ。

ペンギン
ペンギン

Pythonでデータを可視化でございますか。

PythonからSteamの情報を覗く。

なんか最近、Pythonを再び触り始めたので、今回は少し趣向を変えてやっていこうと思います。

そこで今回は、自分のSteamアカウントに紐付いている情報を引っ張ってきて、そのデータを何らかのグラフというかチャートの形でプロットしていきたいと思います。

Steam コミュニティ :: Steam Web API ドキュメント

まあでも最近あんまりゲームしていないんで見栄えの良いグラフになるかが不安です・・・。それじゃあ行ってみましょう。

今回使うパッケージ

Pythonのバージョンは、3.12です。

今回は、Steam Web APIを直接叩くのではなく、PyPIにある「python-steam-api」パッケージを使って、Steamの情報を抜き出していきたいと思います。このパッケージは、「Steam Web API」という公式APIを叩くためのラッパーのようなものです。

このパッケージ、リファレンスは見つけられなかったのですが、パッケージの使用例の部分が沢山書いてあるので、リファレンスを見る必要はないかなと思いました。気になったところはレスポンスをもう少し深く見れば良いし。豊富なドキュメントで助かります。

https://pypi.org/project/python-steam-api

可視化するためのパッケージとしては、matplotlibの結果をFastAPIとPanelで表示する感じで行きます。

Overview — Panel v1.5.4

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 国内正規品

Amazon.co.jp: Logicool G ゲーミングキーボード G515 LIGHTSPEED TKL 薄型 ワイヤレス テンキーレス キーボード G515-WL-TCBK タクタイル 茶軸 確かな打鍵感 日本語配列 LIGHTSPEED Bluetooth 接続対応 LIGHTSYNC RGB ブラック PC 国内正規品 : パソコン・周辺機器
Amazon.co.jp: Logicool G ゲーミングキーボード G515 LIGHTSPEED TKL 薄型 ワイヤレス テンキーレス キーボード G515-WL-TCBK タクタイル 茶軸 確かな打鍵感 日本語配列 LIGHTSP...

エレコム ラピッドトリガー ゲーミングキーボード V custom VK720AL 有線 磁気式アナログ検知スイッチ搭載 75%サイズ ロープロファイル ブラック TK-VK720ALBK

Amazon.co.jp

Logicool G(ロジクール G) ゲーミングマウスパッド G240 クロス表面 標準サイズ 340×280×1mm マウスパッド G240f 国内正規品

Amazon.co.jp: Logicool G(ロジクール G) ゲーミングマウスパッド G240 クロス表面 標準サイズ 340×280×1mm マウスパッド G240f 国内正規品 : パソコン・周辺機器
Amazon.co.jp: Logicool G(ロジクール G) ゲーミングマウスパッド G240 クロス表面 標準サイズ 340×280×1mm マウスパッド G240f 国内正規品 : パソコン・周辺機器

ATK ワイヤレス ゲーミングマウス VXE Dragonfly R1 Pro Black 軽量48グラム Pixart PAW3395搭載 最大75時間 冷感コーティング 4Kポーリングレート対応 国内正規品

Amazon.co.jp: ATK ワイヤレス ゲーミングマウス VXE Dragonfly R1 Pro Black 軽量48グラム Pixart PAW3395搭載 最大75時間 冷感コーティング 4Kポーリングレート対応 国内正規品 : パソコン・周辺機器
Amazon.co.jp: ATK ワイヤレス ゲーミングマウス VXE Dragonfly R1 Pro Black 軽量48グラム Pixart PAW3395搭載 最大75時間 冷感コーティング 4Kポーリングレート対応 国内正規品 ...

おしまい

リサちゃん
リサちゃん

なんか作れそうな感じはするなあ。

ペンギン
ペンギン

データが貯まるまで待ちですか。

以上になります!

コメント

タイトルとURLをコピーしました