rivals-api 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rivals_api/__init__.py ADDED
@@ -0,0 +1,165 @@
1
+ """Multi-source client and read-only tracker for public Marvel Rivals data."""
2
+
3
+ from .client import RivalsClient
4
+ from .exceptions import (
5
+ CloudflareError,
6
+ PlayerNotFoundError,
7
+ PrivacyError,
8
+ RivalsDataError,
9
+ RivalsDataHTTPError,
10
+ )
11
+ from .hero_ids import HERO_CLASSES, HERO_NAMES, hero_class, hero_id, hero_name
12
+
13
+ RivalsDataClient = RivalsClient
14
+ RivalsAPIError = RivalsDataError
15
+ RivalsAPIHTTPError = RivalsDataHTTPError
16
+ from .models import (
17
+ BanRecord,
18
+ Character,
19
+ ClassModeStats,
20
+ ClassStatsRecord,
21
+ ClassStatsResponse,
22
+ CombatAverages,
23
+ CommBanHero,
24
+ CommBanInsights,
25
+ CrosshairRecord,
26
+ DataModel,
27
+ DraftEntry,
28
+ Faction,
29
+ FactionConfig,
30
+ FactionGame,
31
+ FactionMember,
32
+ FactionRank,
33
+ FactionResult,
34
+ FactionSummary,
35
+ FavoritePlayer,
36
+ FavoritesResponse,
37
+ HeroDetail,
38
+ HeroInsights,
39
+ HeroLeaderboardPlayer,
40
+ HeroLeaderboardResponse,
41
+ HeroMeta,
42
+ HeroMetaPoint,
43
+ HeroModeStats,
44
+ HeroProficiency,
45
+ HeroStatsRecord,
46
+ LeaderboardPlayer,
47
+ LeaderboardResponse,
48
+ LeaverHero,
49
+ LeaverInsights,
50
+ LiveGame,
51
+ LiveGamePlayer,
52
+ MapModeStats,
53
+ MapRecord,
54
+ Match,
55
+ MatchHistory,
56
+ MatchPlayer,
57
+ MatchTeam,
58
+ NameHistoryRecord,
59
+ Player,
60
+ PlayerPunishments,
61
+ PlayerSearchResult,
62
+ PlayerStatus,
63
+ PlayerSummary,
64
+ Proficiency,
65
+ ProficiencyResponse,
66
+ ProfileCard,
67
+ PunishmentEntry,
68
+ PunishmentRecord,
69
+ PunishmentsPage,
70
+ RankRecord,
71
+ StatRecord,
72
+ Teammate,
73
+ TeamScore,
74
+ TeamUpRecord,
75
+ TeamUpsResponse,
76
+ TierListResponse,
77
+ Top500Player,
78
+ Top500Response,
79
+ Top500Season,
80
+ XPPage,
81
+ XPRecord,
82
+ )
83
+
84
+ __all__ = [
85
+ "HERO_CLASSES",
86
+ "HERO_NAMES",
87
+ "BanRecord",
88
+ "Character",
89
+ "ClassModeStats",
90
+ "ClassStatsRecord",
91
+ "ClassStatsResponse",
92
+ "CloudflareError",
93
+ "CombatAverages",
94
+ "CommBanHero",
95
+ "CommBanInsights",
96
+ "CrosshairRecord",
97
+ "DataModel",
98
+ "DraftEntry",
99
+ "Faction",
100
+ "FactionConfig",
101
+ "FactionGame",
102
+ "FactionMember",
103
+ "FactionRank",
104
+ "FactionResult",
105
+ "FactionSummary",
106
+ "FavoritePlayer",
107
+ "FavoritesResponse",
108
+ "HeroDetail",
109
+ "HeroInsights",
110
+ "HeroLeaderboardPlayer",
111
+ "HeroLeaderboardResponse",
112
+ "HeroMeta",
113
+ "HeroMetaPoint",
114
+ "HeroModeStats",
115
+ "HeroProficiency",
116
+ "HeroStatsRecord",
117
+ "LeaderboardPlayer",
118
+ "LeaderboardResponse",
119
+ "LeaverHero",
120
+ "LeaverInsights",
121
+ "LiveGame",
122
+ "LiveGamePlayer",
123
+ "MapModeStats",
124
+ "MapRecord",
125
+ "Match",
126
+ "MatchHistory",
127
+ "MatchPlayer",
128
+ "MatchTeam",
129
+ "NameHistoryRecord",
130
+ "Player",
131
+ "PlayerNotFoundError",
132
+ "PlayerPunishments",
133
+ "PlayerSearchResult",
134
+ "PlayerStatus",
135
+ "PlayerSummary",
136
+ "PrivacyError",
137
+ "Proficiency",
138
+ "ProficiencyResponse",
139
+ "ProfileCard",
140
+ "PunishmentEntry",
141
+ "PunishmentRecord",
142
+ "PunishmentsPage",
143
+ "RankRecord",
144
+ "RivalsAPIError",
145
+ "RivalsAPIHTTPError",
146
+ "RivalsClient",
147
+ "RivalsDataClient",
148
+ "RivalsDataError",
149
+ "RivalsDataHTTPError",
150
+ "StatRecord",
151
+ "TeamScore",
152
+ "TeamUpRecord",
153
+ "TeamUpsResponse",
154
+ "Teammate",
155
+ "TierListResponse",
156
+ "Top500Player",
157
+ "Top500Response",
158
+ "Top500Season",
159
+ "XPPage",
160
+ "XPRecord",
161
+ "hero_class",
162
+ "hero_id",
163
+ "hero_name",
164
+ ]
165
+ __version__ = "2.0.0"
rivals_api/client.py ADDED
@@ -0,0 +1,437 @@
1
+ """Client for public Marvel Rivals data from multiple tracker providers.
2
+
3
+ RivalsData, RivalsTracker, and Tracker.gg endpoints are unofficial or
4
+ undocumented and may change. See the provider integration documentation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ import unicodedata
12
+ from typing import Any
13
+ from urllib.parse import urlencode
14
+
15
+ from curl_cffi import requests
16
+
17
+ from .exceptions import (
18
+ CloudflareError,
19
+ PlayerNotFoundError,
20
+ PrivacyError,
21
+ RivalsDataError,
22
+ RivalsDataHTTPError,
23
+ )
24
+ from .models import Player, PlayerSearchResult
25
+ from .normalize import merge, rt_profile, tracker_stats
26
+ from .providers import Providers
27
+ from .resources import (
28
+ Factions,
29
+ Favorites,
30
+ HeroStats,
31
+ Insights,
32
+ Leaderboards,
33
+ Matches,
34
+ Profiles,
35
+ TeamUps,
36
+ )
37
+
38
+
39
+ class RivalsClient:
40
+ """Access and compare public Marvel Rivals data across providers."""
41
+
42
+ base_url = "https://rivalsdata.com"
43
+ api_url = "https://api.rivalsdata.com"
44
+
45
+ leaderboards: Leaderboards
46
+ heroes: HeroStats
47
+ team_ups: TeamUps
48
+ insights: Insights
49
+ factions: Factions
50
+ profiles: Profiles
51
+ favorites: Favorites
52
+ matches: Matches
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ timeout: float = 20,
58
+ impersonate: str = "chrome",
59
+ use_browser_fallback: bool = False,
60
+ enrich: bool = True,
61
+ provider_cache_ttl: float = 60,
62
+ ) -> None:
63
+ self.timeout = timeout
64
+ self.impersonate = impersonate
65
+ self.use_browser_fallback = use_browser_fallback
66
+ self.enrich = enrich
67
+ self.provider_cache_ttl = provider_cache_ttl
68
+ self.providers = Providers(self)
69
+ self._player_names: dict[int, str] = {}
70
+ self.provider_errors: list[dict[str, str]] = []
71
+ self.session = requests.Session(impersonate=impersonate)
72
+ self.session.headers.update(
73
+ {
74
+ "Accept": "application/json",
75
+ "Content-Type": "application/json",
76
+ "Origin": self.base_url,
77
+ "Referer": f"{self.base_url}/",
78
+ }
79
+ )
80
+ self.leaderboards = Leaderboards(self)
81
+ self.heroes = HeroStats(self)
82
+ self.team_ups = TeamUps(self)
83
+ self.insights = Insights(self)
84
+ self.factions = Factions(self)
85
+ self.profiles = Profiles(self)
86
+ self.favorites = Favorites(self)
87
+ self.matches = Matches(self)
88
+ from .extensions import Community, GameAnalytics
89
+
90
+ self.analytics = GameAnalytics(self)
91
+ self.community = Community(self)
92
+
93
+ def close(self) -> None:
94
+ """Close the underlying HTTP session."""
95
+ self.session.close()
96
+ self.providers.close()
97
+
98
+ def __enter__(self) -> RivalsClient:
99
+ return self
100
+
101
+ def __exit__(self, *_: object) -> None:
102
+ self.close()
103
+
104
+ def _request_with_gateway_retries(
105
+ self, method: Any, path: str, **kwargs: Any
106
+ ) -> Any:
107
+ """Retry short-lived upstream gateway failures on read-only requests."""
108
+ url = f"{self.api_url}{path}"
109
+ for attempt in range(3):
110
+ try:
111
+ response = method(url, timeout=self.timeout, **kwargs)
112
+ except requests.exceptions.RequestException as exc:
113
+ raise RivalsDataHTTPError(f"RivalsData request failed for {path}: {exc}") from exc
114
+ if response.status_code not in (502, 504) or attempt == 2:
115
+ break
116
+ time.sleep(0.25 * (attempt + 1))
117
+ return response
118
+
119
+ def resolve_player(self, username: str) -> PlayerSearchResult:
120
+ """Search by username; add numeric uid to the source result."""
121
+ query = username.strip()
122
+ if not query:
123
+ raise ValueError("username must not be empty")
124
+ try:
125
+ result = self._post_json("/players/search", {"name": query})
126
+ except RivalsDataError:
127
+ if not self.enrich:
128
+ raise
129
+ result = self.providers.rt.request("/find-player", payload={"name": query})
130
+ # Provider fallback must never silently resolve a fuzzy candidate.
131
+ result = [row for row in result if isinstance(row, dict)
132
+ and self._normalize_name(str(row.get("name", "")))
133
+ == self._normalize_name(query)]
134
+ if isinstance(result, dict):
135
+ result = result.get("players", result.get("results", []))
136
+ if not isinstance(result, list) or not result:
137
+ raise PlayerNotFoundError(f"No player found for {username!r}")
138
+ wanted = self._normalize_name(query)
139
+ matches = [
140
+ player for player in result
141
+ if isinstance(player, dict)
142
+ and self._normalize_name(str(player.get("name", ""))) == wanted
143
+ ]
144
+ player = (matches or [result[0]])[0]
145
+ if not isinstance(player, dict):
146
+ raise PlayerNotFoundError(f"No player found for {username!r}")
147
+ uid = self._uid_from_search_result(player)
148
+ if uid is None:
149
+ raise RivalsDataHTTPError(
150
+ "Search returned a player without a recognizable numeric UID"
151
+ )
152
+ return PlayerSearchResult({**player, "uid": uid})
153
+
154
+ def search_players(self, name: str) -> list[PlayerSearchResult]:
155
+ """Return all provider search candidates, deduplicated by game UID."""
156
+ if not name.strip():
157
+ raise ValueError("name must not be empty")
158
+ results = self.providers.rt.request("/find-player", payload={"name": name.strip()})
159
+ found = {}
160
+ for row in results:
161
+ uid = self._uid_from_search_result(row)
162
+ if uid is not None:
163
+ found[uid] = PlayerSearchResult({**row, "uid": uid})
164
+ return list(found.values())
165
+
166
+ def _optional_provider(self, source: str, path: str, **kwargs: Any) -> Any:
167
+ """Supplementary outages do not erase a successful primary response."""
168
+ if not self.enrich:
169
+ return None
170
+ try:
171
+ return getattr(self.providers, source).request(path, **kwargs)
172
+ except RivalsDataError as exc:
173
+ self.provider_errors.append({"source": source, "path": path, "error": str(exc)})
174
+ self.provider_errors[:] = self.provider_errors[-100:]
175
+ return None
176
+
177
+ def _rt_player(self, uid: int, season: Any = None) -> Any:
178
+ body = self._optional_provider("rt", f"/player/{uid}", params={"season": season})
179
+ if (isinstance(body, dict) and isinstance(body.get("player"), dict)
180
+ and str(body["player"].get("_id")) == str(uid)):
181
+ return body
182
+ return None
183
+
184
+ def _tracker_path(self, uid: int) -> str:
185
+ from urllib.parse import quote
186
+
187
+ name = self._player_names.get(uid)
188
+ if not name:
189
+ name = self.get_player(uid).get("name")
190
+ if not name:
191
+ raise PlayerNotFoundError("No verified in-game name for this UID")
192
+ return "/api/v2/marvel-rivals/standard/profile/ign/" + quote(name, safe="")
193
+
194
+ def get_player(self, uuid_or_username: str | int) -> Player:
195
+ """Fetch a typed public profile by numeric UID or username.
196
+
197
+ Profile JSON keys are exposed as attributes (``player.level``), while
198
+ subresources are lazy managers (``player.stats.maps(season=20)``).
199
+ """
200
+ value = str(uuid_or_username).strip()
201
+ if not value:
202
+ raise ValueError("uuid_or_username must not be empty")
203
+ uid = value if value.isdecimal() else str(self.resolve_player(value)["uid"])
204
+ try:
205
+ result = self._post_json("/player", {"uid": int(uid)})
206
+ if self.enrich and isinstance(result, dict):
207
+ result.setdefault("provider_metadata", {"sources": ["rivalsdata"]})
208
+ result["provider_metadata"].setdefault("evidence", {})["rivalsdata"] = {
209
+ "kind": "profile", "scope": {"uid": int(uid)},
210
+ "updated_at": result.get("cached_at")}
211
+ except RivalsDataError:
212
+ if not self.enrich:
213
+ raise
214
+ body = self.providers.rt.request(f"/player/{uid}")
215
+ result = rt_profile(body)
216
+ if not isinstance(result, dict) or str(result.get("uid")) != uid:
217
+ raise PlayerNotFoundError(f"Player {value!r} was not found")
218
+ body = self._rt_player(int(uid))
219
+ if body and str(body.get("player", {}).get("_id")) == uid:
220
+ result = merge(result, rt_profile(body), "rivalstracker")
221
+ if result.get("name"):
222
+ self._player_names[int(uid)] = result["name"]
223
+ if self.enrich:
224
+ self._select_career_summary(result, int(uid))
225
+ return Player(result, self)
226
+
227
+ def _select_career_summary(self, result: dict, uid: int) -> None:
228
+ """Canonical career counts are distinct from raw rank-system counts."""
229
+ ranks = result.get("rank_game_season", {})
230
+ seasons = [row.get("rank_game_id") for key, row in ranks.items()
231
+ if str(key).startswith("1001") and isinstance(row, dict)
232
+ and isinstance(row.get("rank_game_id"), int)]
233
+ if not seasons or uid not in self._player_names:
234
+ return
235
+ season = max(seasons)
236
+ body = self._rt_player(uid, season)
237
+ stats = body.get("stats", {}) if body else {}
238
+ games, wins = stats.get("ranked_matches"), stats.get("ranked_matches_wins")
239
+ summary = {}
240
+ scope = {"uid": uid, "season": season, "mode": "competitive", "counts_basis": "career_matches"}
241
+ if isinstance(games, (int, float)) and isinstance(wins, (int, float)) and 0 <= wins <= games:
242
+ summary = {"games": games, "wins": wins, "losses": games - wins,
243
+ "provider_metadata": {"sources": ["rivalstracker"], "evidence": {
244
+ "rivalstracker": {"kind": "career", "scope": scope}}}}
245
+ segments = self._optional_provider("tracker", self._tracker_path(uid) + "/segments/career",
246
+ params={"mode": "competitive", "season": season})
247
+ for segment in segments or []:
248
+ attrs = segment.get("attributes", {})
249
+ if (segment.get("type") != "overview" or attrs.get("mode") != "competitive"
250
+ or str(attrs.get("season")) != str(season)):
251
+ continue
252
+ values = tracker_stats(segment)
253
+ games, wins = values.get("matchesPlayed"), values.get("matchesWon")
254
+ if not isinstance(games, (int, float)) or not isinstance(wins, (int, float)):
255
+ continue
256
+ summary = merge(summary, {"games": games, "wins": wins, "losses": games - wins}, "tracker",
257
+ context={"kind": "career", "scope": scope})
258
+ if summary and summary.get("games") is not None:
259
+ result["career_summary"] = {"season": season, "competitive": summary,
260
+ "counts_basis": "career_matches"}
261
+
262
+ def get_player_by_uid(self, uid: str | int) -> Player:
263
+ """Fetch a profile by numeric UID."""
264
+ if not str(uid).isdecimal():
265
+ raise ValueError("uid must contain only digits")
266
+ return self.get_player(uid)
267
+
268
+ def _get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
269
+ query = {key: value for key, value in (params or {}).items() if value is not None}
270
+ response = self._request_with_gateway_retries(
271
+ self.session.get, path, params=query
272
+ )
273
+ body = response.text
274
+ if response.status_code == 429:
275
+ raise RivalsDataHTTPError(f"RivalsData rate limited the request for {path} (HTTP 429)")
276
+ if response.status_code == 403 and body.lstrip().startswith("{") and "private" in body.lower():
277
+ raise PrivacyError(f"RivalsData section is private: {path}")
278
+ blocked = (
279
+ response.status_code in (403, 429, 503)
280
+ or "cf-chl-" in body
281
+ or "Just a moment" in body
282
+ )
283
+ if blocked:
284
+ if not self.use_browser_fallback:
285
+ raise CloudflareError(
286
+ "Cloudflare blocked this request. Install "
287
+ "rivals-api[browser] and set use_browser_fallback=True."
288
+ )
289
+ return self._camoufox_get_json(path, query)
290
+ if not response.ok:
291
+ raise RivalsDataHTTPError(
292
+ f"RivalsData API returned HTTP {response.status_code} for {path}: "
293
+ f"{body[:300]}"
294
+ )
295
+ try:
296
+ return response.json()
297
+ except ValueError as exc:
298
+ raise RivalsDataHTTPError(
299
+ f"RivalsData API returned invalid JSON for {path}"
300
+ ) from exc
301
+
302
+ def _camoufox_get_json(self, path: str, params: dict[str, Any]) -> Any:
303
+ try:
304
+ from camoufox.sync_api import Camoufox
305
+ except ImportError as exc:
306
+ raise CloudflareError(
307
+ "Install Camoufox with pip install 'rivals-api[browser]' "
308
+ "and run 'python -m camoufox fetch'."
309
+ ) from exc
310
+ query = urlencode(params)
311
+ url = f"{self.api_url}{path}" + (f"?{query}" if query else "")
312
+ try:
313
+ with Camoufox(headless=True) as browser:
314
+ page = browser.new_page()
315
+ page.goto(
316
+ self.base_url,
317
+ wait_until="domcontentloaded",
318
+ timeout=int(self.timeout * 1000),
319
+ )
320
+ response = page.evaluate(
321
+ """async ({url}) => {
322
+ const response = await fetch(url, {method: "GET"});
323
+ return {status: response.status, text: await response.text()};
324
+ }""",
325
+ {"url": url},
326
+ )
327
+ except Exception as exc:
328
+ raise CloudflareError(f"Camoufox request failed: {exc}") from exc
329
+ if response["status"] in (403, 429, 503) or "cf-chl-" in response["text"]:
330
+ raise CloudflareError("Camoufox could not complete the API request")
331
+ if response["status"] < 200 or response["status"] >= 300:
332
+ raise RivalsDataHTTPError(
333
+ f"RivalsData API returned HTTP {response['status']} for {path}: "
334
+ f"{response['text'][:300]}"
335
+ )
336
+ try:
337
+ return json.loads(response["text"])
338
+ except json.JSONDecodeError as exc:
339
+ raise RivalsDataHTTPError(
340
+ f"RivalsData API returned invalid JSON for {path}"
341
+ ) from exc
342
+
343
+ def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
344
+ response = self._request_with_gateway_retries(
345
+ self.session.post, path, json=payload
346
+ )
347
+ body = response.text
348
+ if response.status_code == 429:
349
+ raise RivalsDataHTTPError(f"RivalsData rate limited the request for {path} (HTTP 429)")
350
+ if response.status_code == 403 and body.lstrip().startswith("{") and "private" in body.lower():
351
+ raise PrivacyError(f"RivalsData section is private: {path}")
352
+ blocked = (
353
+ response.status_code in (403, 429, 503)
354
+ or "cf-chl-" in body
355
+ or "Just a moment" in body
356
+ )
357
+ if blocked:
358
+ if not self.use_browser_fallback:
359
+ raise CloudflareError(
360
+ "Cloudflare blocked this request. Install "
361
+ "rivals-api[browser] and set use_browser_fallback=True."
362
+ )
363
+ return self._camoufox_post_json(path, payload)
364
+ if not response.ok:
365
+ raise RivalsDataHTTPError(
366
+ f"RivalsData API returned HTTP {response.status_code} for {path}: "
367
+ f"{body[:300]}"
368
+ )
369
+ try:
370
+ return response.json()
371
+ except ValueError as exc:
372
+ raise RivalsDataHTTPError(
373
+ f"RivalsData API returned invalid JSON for {path}"
374
+ ) from exc
375
+
376
+ def _camoufox_post_json(self, path: str, payload: dict[str, Any]) -> Any:
377
+ """Retry an API POST from a browser context after loading the site."""
378
+ try:
379
+ from camoufox.sync_api import Camoufox
380
+ except ImportError as exc:
381
+ raise CloudflareError(
382
+ "Install Camoufox with pip install 'rivals-api[browser]' "
383
+ "and run 'python -m camoufox fetch'."
384
+ ) from exc
385
+ try:
386
+ with Camoufox(headless=True) as browser:
387
+ page = browser.new_page()
388
+ page.goto(
389
+ self.base_url,
390
+ wait_until="domcontentloaded",
391
+ timeout=int(self.timeout * 1000),
392
+ )
393
+ response = page.evaluate(
394
+ """async ({url, payload}) => {
395
+ const response = await fetch(url, {
396
+ method: "POST",
397
+ headers: {"Content-Type": "application/json"},
398
+ body: JSON.stringify(payload)
399
+ });
400
+ return {
401
+ status: response.status,
402
+ text: await response.text()
403
+ };
404
+ }""",
405
+ {"url": f"{self.api_url}{path}", "payload": payload},
406
+ )
407
+ except Exception as exc:
408
+ raise CloudflareError(f"Camoufox request failed: {exc}") from exc
409
+ if (
410
+ response["status"] in (403, 429, 503)
411
+ or "cf-chl-" in response["text"]
412
+ ):
413
+ raise CloudflareError("Camoufox could not complete the API request")
414
+ if response["status"] < 200 or response["status"] >= 300:
415
+ raise RivalsDataHTTPError(
416
+ f"RivalsData API returned HTTP {response['status']} for {path}: "
417
+ f"{response['text'][:300]}"
418
+ )
419
+ try:
420
+ return json.loads(response["text"])
421
+ except json.JSONDecodeError as exc:
422
+ raise RivalsDataHTTPError(
423
+ f"RivalsData API returned invalid JSON for {path}"
424
+ ) from exc
425
+
426
+ @staticmethod
427
+ def _uid_from_search_result(player: dict[str, Any]) -> int | None:
428
+ for key in ("uid", "uuid", "playerId", "player_id"):
429
+ value = player.get(key)
430
+ if value is not None and str(value).isdecimal():
431
+ return int(value)
432
+ suffix = str(player.get("aid", "")).rsplit("_", 1)[-1]
433
+ return int(suffix) if suffix.isdecimal() else None
434
+
435
+ @staticmethod
436
+ def _normalize_name(name: str) -> str:
437
+ return unicodedata.normalize("NFKC", name).casefold().strip()
@@ -0,0 +1,17 @@
1
+ """Exceptions raised by the RivalsData client."""
2
+
3
+ class RivalsDataError(Exception):
4
+ """Base exception for client errors."""
5
+
6
+ class RivalsDataHTTPError(RivalsDataError):
7
+ """The site returned an unsuccessful HTTP response."""
8
+
9
+ class CloudflareError(RivalsDataError):
10
+ """Cloudflare blocked the HTTP client; use the optional browser extra."""
11
+
12
+ class PlayerNotFoundError(RivalsDataError):
13
+ """No player matched the supplied UID or username."""
14
+
15
+
16
+ class PrivacyError(RivalsDataHTTPError):
17
+ """The provider explicitly reports that the requested section is private."""