Spaces:
Sleeping
Sleeping
File size: 9,837 Bytes
dfd46e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import requests
BASE_WEB = "https://api.tracker.gg"
PROFILE_WEB = "https://rocketleague.tracker.network/rocket-league/profile"
class TrackerError(RuntimeError):
pass
def _dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
d = datetime.fromisoformat(value.replace("Z", "+00:00"))
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc)
except ValueError:
return None
def _stat_value(stats: Dict[str, Any], name: str, default=None):
return (stats.get(name) or {}).get("value", default)
@dataclass
class PlayerProfile:
platform: str
identifier: str
handle: str
player_id: Optional[int]
last_updated: Optional[datetime]
season: Optional[int]
avatar_url: Optional[str]
lifetime: Dict[str, Any]
playlists: List[Dict[str, Any]]
raw: Dict[str, Any]
class TrackerClient:
"""Best-effort client for Tracker web endpoints observed in supplied samples.
This intentionally does not solve/bypass Cloudflare or Warden challenges. A 401/403
is returned to the bot as an error instead.
"""
def __init__(self, timeout: int = 20):
self.session = requests.Session()
self.timeout = timeout
self.session.headers.update(
{
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://rocketleague.tracker.network",
"Referer": "https://rocketleague.tracker.network/",
"User-Agent": os.getenv(
"TRACKER_USER_AGENT",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
),
}
)
cookie = os.getenv("TRACKER_COOKIE", "").strip()
if cookie:
self.session.headers["Cookie"] = cookie
def _prime_profile_session(self, platform: str, identifier: str) -> None:
"""Visit the normal public Tracker profile page first so browser cookies/state exist.
This does not solve CAPTCHA/WAF challenges. It only mirrors the normal browser flow
of opening a profile page before its JSON requests are made.
"""
page_url = f"{PROFILE_WEB}/{platform}/{identifier}"
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Upgrade-Insecure-Requests": "1",
}
try:
self.session.get(page_url, headers=headers, timeout=self.timeout, allow_redirects=True)
except requests.RequestException:
# The API request below is still worth attempting; it will produce the useful error.
pass
@staticmethod
def _extract_profile_from_path(path: str) -> tuple[Optional[str], Optional[str]]:
marker = "/api/v2/rocket-league/standard/profile/"
if not path.startswith(marker):
return None, None
rest = path[len(marker):].strip("/")
parts = rest.split("/")
if len(parts) < 2:
return None, None
return parts[0], parts[1]
def _get(self, path: str, params: Optional[dict] = None) -> Dict[str, Any]:
url = f"{BASE_WEB}{path}"
platform, identifier = self._extract_profile_from_path(path)
if platform and identifier:
self._prime_profile_session(platform, identifier)
api_headers = {
"Accept": "application/json, text/plain, */*",
"Referer": (
f"{PROFILE_WEB}/{platform}/{identifier}"
if platform and identifier
else "https://rocketleague.tracker.network/"
),
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
}
try:
r = self.session.get(url, params=params, headers=api_headers, timeout=self.timeout)
except requests.RequestException as exc:
raise TrackerError(f"Network error contacting Tracker: {exc}") from exc
if r.status_code in (401, 403):
preview = " ".join((r.text or "").split())[:240]
server = r.headers.get("server", "unknown")
raise TrackerError(
f"HTTP {r.status_code} from Tracker (server={server}). "
f"Response preview: {preview or '<empty>'}"
)
try:
r.raise_for_status()
except requests.HTTPError as exc:
raise TrackerError(f"HTTP {r.status_code} for {r.url}") from exc
try:
return r.json()
except ValueError as exc:
raise TrackerError("Tracker returned non-JSON content") from exc
def profile(self, platform: str, identifier: str) -> PlayerProfile:
payload = self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}")
data = payload.get("data") or {}
pinfo = data.get("platformInfo") or {}
meta = data.get("metadata") or {}
segments = data.get("segments") or []
lifetime: Dict[str, Any] = {}
playlists: List[Dict[str, Any]] = []
for seg in segments:
stats = seg.get("stats") or {}
if seg.get("type") == "overview":
lifetime = {k: (v or {}).get("value") for k, v in stats.items()}
elif seg.get("type") == "playlist":
attrs = seg.get("attributes") or {}
smeta = seg.get("metadata") or {}
tier = stats.get("tier") or {}
div = stats.get("division") or {}
rating = stats.get("rating") or {}
peak = stats.get("peakRating") or {}
playlists.append(
{
"playlist_id": attrs.get("playlistId"),
"name": smeta.get("name", f"Playlist {attrs.get('playlistId')}"),
"season": attrs.get("season"),
"rating": rating.get("value"),
"tier": (rating.get("metadata") or {}).get("tierName")
or (tier.get("metadata") or {}).get("name"),
"division": (div.get("metadata") or {}).get("name"),
"matches_played": _stat_value(stats, "matchesPlayed"),
"win_streak": _stat_value(stats, "winStreak"),
"peak_rating": peak.get("value"),
}
)
return PlayerProfile(
platform=pinfo.get("platformSlug") or platform,
identifier=pinfo.get("platformUserIdentifier") or identifier,
handle=pinfo.get("platformUserHandle") or identifier,
player_id=meta.get("playerId"),
last_updated=_dt((meta.get("lastUpdated") or {}).get("value")),
season=meta.get("currentSeason"),
avatar_url=pinfo.get("avatarUrl"),
lifetime=lifetime,
playlists=playlists,
raw=payload,
)
def sessions(self, platform: str, identifier: str) -> Dict[str, Any]:
return self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}/sessions")
def mmr_history(self, player_id: int) -> Dict[str, Any]:
return self._get(f"/api/v1/rocket-league/player-history/mmr/{player_id}")
def overview_history(self, player_id: int) -> Dict[str, Any]:
return self._get(f"/api/v1/rocket-league/player-history/overview/{player_id}", params={"type": "graph"})
def aggregated(self, platform: str, identifier: str, local_offset: int = 0) -> Dict[str, Any]:
return self._get(
f"/api/v1/rocket-league/matches/{platform}/{identifier}/aggregated",
params={"localOffset": local_offset},
)
@staticmethod
def flatten_session_matches(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for session in ((payload.get("data") or {}).get("items") or []):
for m in session.get("matches") or []:
meta = m.get("metadata") or {}
stats = m.get("stats") or {}
r = stats.get("rating") or {}
rmeta = r.get("metadata") or {}
out.append(
{
"match_id": m.get("id"),
"collected_at": meta.get("dateCollected"),
"playlist": meta.get("playlist"),
"result": meta.get("result"),
"is_grouped": bool(meta.get("isGrouped")),
"rating": r.get("value"),
"rating_delta": rmeta.get("ratingDelta"),
"tier": rmeta.get("tier"),
"division": rmeta.get("division"),
"matches_played": _stat_value(stats, "matchesPlayed"),
"wins": _stat_value(stats, "wins"),
"goals": _stat_value(stats, "goals"),
"assists": _stat_value(stats, "assists"),
"saves": _stat_value(stats, "saves"),
"shots": _stat_value(stats, "shots"),
"mvps": _stat_value(stats, "mvps"),
}
)
out.sort(key=lambda x: x.get("collected_at") or "", reverse=True)
return out
|