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 +165 -0
- rivals_api/client.py +437 -0
- rivals_api/exceptions.py +17 -0
- rivals_api/extensions.py +161 -0
- rivals_api/hero_catalog.json +10091 -0
- rivals_api/hero_ids.py +107 -0
- rivals_api/history.py +138 -0
- rivals_api/mcp_server.py +742 -0
- rivals_api/models.py +1034 -0
- rivals_api/normalize.py +342 -0
- rivals_api/providers.py +118 -0
- rivals_api/resources.py +641 -0
- rivals_api/selection.py +129 -0
- rivals_api-2.0.0.dist-info/METADATA +325 -0
- rivals_api-2.0.0.dist-info/RECORD +29 -0
- rivals_api-2.0.0.dist-info/WHEEL +4 -0
- rivals_api-2.0.0.dist-info/licenses/LICENSE +21 -0
- rivalsdata/__init__.py +2 -0
- rivalsdata/client.py +8 -0
- rivalsdata/exceptions.py +8 -0
- rivalsdata/extensions.py +8 -0
- rivalsdata/hero_ids.py +8 -0
- rivalsdata/history.py +8 -0
- rivalsdata/mcp_server.py +8 -0
- rivalsdata/models.py +8 -0
- rivalsdata/normalize.py +8 -0
- rivalsdata/providers.py +8 -0
- rivalsdata/resources.py +8 -0
- rivalsdata/selection.py +8 -0
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()
|
rivals_api/exceptions.py
ADDED
|
@@ -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."""
|