feat(steam): add GetPlayerSummaries client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
465a103c3a
commit
f88d07a473
2 changed files with 154 additions and 0 deletions
76
l4d2web/services/steam_users.py
Normal file
76
l4d2web/services/steam_users.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Steam Web API client for player profile lookups.
|
||||||
|
|
||||||
|
Mirrors the shape of l4d2web/services/steam_workshop.py:17-43:
|
||||||
|
- single thread-local requests.Session
|
||||||
|
- 30s timeout
|
||||||
|
- HTTPS only
|
||||||
|
|
||||||
|
Difference: GetPlayerSummaries requires an API key in the querystring,
|
||||||
|
unlike the anonymous workshop endpoints.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
GET_PLAYER_SUMMARIES_URL = (
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/"
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 30.0
|
||||||
|
MAX_IDS_PER_CALL = 100
|
||||||
|
|
||||||
|
_session_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> requests.Session:
|
||||||
|
sess = getattr(_session_local, "session", None)
|
||||||
|
if sess is None:
|
||||||
|
sess = requests.Session()
|
||||||
|
_session_local.session = sess
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def _session_get(url: str, params: dict, timeout: float = REQUEST_TIMEOUT_SECONDS):
|
||||||
|
"""Indirection seam so tests can monkeypatch a fake here."""
|
||||||
|
return _session().get(url, params=params, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class SteamProfile:
|
||||||
|
steam_id_64: str
|
||||||
|
persona_name: str
|
||||||
|
avatar_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_profiles_batch(
|
||||||
|
steam_ids: Iterable[str], *, api_key: str
|
||||||
|
) -> list[SteamProfile]:
|
||||||
|
"""Resolve a batch of SteamID64 strings to persona name + avatar URL.
|
||||||
|
|
||||||
|
Steam's API caps each call at 100 IDs; this helper chunks transparently.
|
||||||
|
IDs that Steam can't resolve (private, deleted) are simply absent from
|
||||||
|
the response and from the returned list.
|
||||||
|
"""
|
||||||
|
ids = list(steam_ids)
|
||||||
|
out: list[SteamProfile] = []
|
||||||
|
for i in range(0, len(ids), MAX_IDS_PER_CALL):
|
||||||
|
chunk = ids[i : i + MAX_IDS_PER_CALL]
|
||||||
|
params = {"key": api_key, "steamids": ",".join(chunk)}
|
||||||
|
resp = _session_get(GET_PLAYER_SUMMARIES_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json() or {}
|
||||||
|
players = (payload.get("response") or {}).get("players") or []
|
||||||
|
for p in players:
|
||||||
|
out.append(
|
||||||
|
SteamProfile(
|
||||||
|
steam_id_64=str(p["steamid"]),
|
||||||
|
persona_name=str(p.get("personaname", "")),
|
||||||
|
avatar_url=str(p.get("avatarmedium", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
78
l4d2web/tests/test_steam_users.py
Normal file
78
l4d2web/tests/test_steam_users.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from l4d2web.services import steam_users
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, json_body: dict[str, Any], status: int = 200) -> None:
|
||||||
|
self._body = json_body
|
||||||
|
self.status_code = status
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RuntimeError(f"http {self.status_code}")
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_get(monkeypatch: pytest.MonkeyPatch, body: dict, capture: list) -> None:
|
||||||
|
def fake_get(url: str, params: dict, timeout: float = 30.0) -> _FakeResponse:
|
||||||
|
capture.append({"url": url, "params": params, "timeout": timeout})
|
||||||
|
return _FakeResponse(body)
|
||||||
|
|
||||||
|
monkeypatch.setattr(steam_users, "_session_get", fake_get)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_builds_correct_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: list = []
|
||||||
|
body = {"response": {"players": [
|
||||||
|
{"steamid": "76561197960828710", "personaname": "Alice",
|
||||||
|
"avatarmedium": "https://avatars.../alice_medium.jpg"},
|
||||||
|
]}}
|
||||||
|
_patched_get(monkeypatch, body, captured)
|
||||||
|
|
||||||
|
profiles = steam_users.fetch_profiles_batch(
|
||||||
|
["76561197960828710", "76561198021234567"], api_key="KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured[0]["url"].endswith("/GetPlayerSummaries/v0002/")
|
||||||
|
assert captured[0]["params"]["key"] == "KEY"
|
||||||
|
assert captured[0]["params"]["steamids"] == "76561197960828710,76561198021234567"
|
||||||
|
assert len(profiles) == 1
|
||||||
|
p = profiles[0]
|
||||||
|
assert p.steam_id_64 == "76561197960828710"
|
||||||
|
assert p.persona_name == "Alice"
|
||||||
|
assert p.avatar_url.endswith("alice_medium.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_skips_private_or_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# The Steam API simply omits non-resolvable IDs from the response. Caller
|
||||||
|
# should accept that and return only what's there.
|
||||||
|
body = {"response": {"players": [
|
||||||
|
{"steamid": "76561197960828710", "personaname": "Alice",
|
||||||
|
"avatarmedium": "https://avatars.../alice_medium.jpg"},
|
||||||
|
]}}
|
||||||
|
_patched_get(monkeypatch, body, [])
|
||||||
|
profiles = steam_users.fetch_profiles_batch(
|
||||||
|
["76561197960828710", "76561197999999999"], api_key="KEY"
|
||||||
|
)
|
||||||
|
assert len(profiles) == 1
|
||||||
|
assert profiles[0].steam_id_64 == "76561197960828710"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_chunks_by_100(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
ids = [str(76561197960000000 + i) for i in range(150)]
|
||||||
|
calls: list = []
|
||||||
|
body = {"response": {"players": []}}
|
||||||
|
_patched_get(monkeypatch, body, calls)
|
||||||
|
|
||||||
|
steam_users.fetch_profiles_batch(ids, api_key="KEY")
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0]["params"]["steamids"].count(",") == 99 # 100 ids -> 99 commas
|
||||||
|
assert calls[1]["params"]["steamids"].count(",") == 49 # 50 ids
|
||||||
Loading…
Reference in a new issue