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