rivalsdata-api 1.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.
- rivalsdata/__init__.py +22 -0
- rivalsdata/client.py +282 -0
- rivalsdata/exceptions.py +13 -0
- rivalsdata/mcp_server.py +444 -0
- rivalsdata/models.py +172 -0
- rivalsdata/resources.py +248 -0
- rivalsdata_api-1.0.0.dist-info/METADATA +189 -0
- rivalsdata_api-1.0.0.dist-info/RECORD +11 -0
- rivalsdata_api-1.0.0.dist-info/WHEEL +4 -0
- rivalsdata_api-1.0.0.dist-info/entry_points.txt +2 -0
- rivalsdata_api-1.0.0.dist-info/licenses/LICENSE +21 -0
rivalsdata/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Unofficial client for public RivalsData player data."""
|
|
2
|
+
|
|
3
|
+
from .client import RivalsDataClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
CloudflareError,
|
|
6
|
+
PlayerNotFoundError,
|
|
7
|
+
RivalsDataError,
|
|
8
|
+
RivalsDataHTTPError,
|
|
9
|
+
)
|
|
10
|
+
from .models import DataModel, Player, StatRecord
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CloudflareError",
|
|
14
|
+
"DataModel",
|
|
15
|
+
"Player",
|
|
16
|
+
"PlayerNotFoundError",
|
|
17
|
+
"RivalsDataClient",
|
|
18
|
+
"RivalsDataError",
|
|
19
|
+
"RivalsDataHTTPError",
|
|
20
|
+
"StatRecord",
|
|
21
|
+
]
|
|
22
|
+
__version__ = "0.2.0"
|
rivalsdata/client.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""HTTP client for RivalsData's public player API.
|
|
2
|
+
|
|
3
|
+
The upstream API is undocumented and may change. These routes were observed
|
|
4
|
+
from the site's public player page and search box.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import unicodedata
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
from curl_cffi import requests
|
|
15
|
+
|
|
16
|
+
from .exceptions import CloudflareError, PlayerNotFoundError, RivalsDataHTTPError
|
|
17
|
+
from .models import Player
|
|
18
|
+
from .resources import (
|
|
19
|
+
Factions,
|
|
20
|
+
Favorites,
|
|
21
|
+
HeroStats,
|
|
22
|
+
Insights,
|
|
23
|
+
Leaderboards,
|
|
24
|
+
Matches,
|
|
25
|
+
Profiles,
|
|
26
|
+
TeamUps,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RivalsDataClient:
|
|
31
|
+
"""Access public player data from rivalsdata.com."""
|
|
32
|
+
|
|
33
|
+
base_url = "https://rivalsdata.com"
|
|
34
|
+
api_url = "https://api.rivalsdata.com"
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
timeout: float = 20,
|
|
40
|
+
impersonate: str = "chrome",
|
|
41
|
+
use_browser_fallback: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self.impersonate = impersonate
|
|
45
|
+
self.use_browser_fallback = use_browser_fallback
|
|
46
|
+
self.session = requests.Session(impersonate=impersonate)
|
|
47
|
+
self.session.headers.update(
|
|
48
|
+
{
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"Origin": self.base_url,
|
|
52
|
+
"Referer": f"{self.base_url}/",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
self.leaderboards = Leaderboards(self)
|
|
56
|
+
self.heroes = HeroStats(self)
|
|
57
|
+
self.team_ups = TeamUps(self)
|
|
58
|
+
self.insights = Insights(self)
|
|
59
|
+
self.factions = Factions(self)
|
|
60
|
+
self.profiles = Profiles(self)
|
|
61
|
+
self.favorites = Favorites(self)
|
|
62
|
+
self.matches = Matches(self)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Close the underlying HTTP session."""
|
|
66
|
+
self.session.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> RivalsDataClient:
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *_: object) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
def resolve_player(self, username: str) -> dict[str, Any]:
|
|
75
|
+
"""Search by username; add numeric uid to the source result."""
|
|
76
|
+
query = username.strip()
|
|
77
|
+
if not query:
|
|
78
|
+
raise ValueError("username must not be empty")
|
|
79
|
+
result = self._post_json("/players/search", {"name": query})
|
|
80
|
+
if isinstance(result, dict):
|
|
81
|
+
result = result.get("players", result.get("results", []))
|
|
82
|
+
if not isinstance(result, list) or not result:
|
|
83
|
+
raise PlayerNotFoundError(f"No player found for {username!r}")
|
|
84
|
+
wanted = self._normalize_name(query)
|
|
85
|
+
matches = [
|
|
86
|
+
player for player in result
|
|
87
|
+
if isinstance(player, dict)
|
|
88
|
+
and self._normalize_name(str(player.get("name", ""))) == wanted
|
|
89
|
+
]
|
|
90
|
+
player = (matches or [result[0]])[0]
|
|
91
|
+
if not isinstance(player, dict):
|
|
92
|
+
raise PlayerNotFoundError(f"No player found for {username!r}")
|
|
93
|
+
uid = self._uid_from_search_result(player)
|
|
94
|
+
if uid is None:
|
|
95
|
+
raise RivalsDataHTTPError(
|
|
96
|
+
"Search returned a player without a recognizable numeric UID"
|
|
97
|
+
)
|
|
98
|
+
return {**player, "uid": uid}
|
|
99
|
+
|
|
100
|
+
def get_player(self, uuid_or_username: str | int) -> Player:
|
|
101
|
+
"""Fetch a typed public profile by numeric UID or username.
|
|
102
|
+
|
|
103
|
+
Profile JSON keys are exposed as attributes (``player.level``), while
|
|
104
|
+
subresources are lazy managers (``player.stats.maps(season=20)``).
|
|
105
|
+
"""
|
|
106
|
+
value = str(uuid_or_username).strip()
|
|
107
|
+
if not value:
|
|
108
|
+
raise ValueError("uuid_or_username must not be empty")
|
|
109
|
+
uid = value if value.isdecimal() else str(self.resolve_player(value)["uid"])
|
|
110
|
+
result = self._post_json("/player", {"uid": int(uid)})
|
|
111
|
+
if not isinstance(result, dict) or not result.get("uid"):
|
|
112
|
+
raise PlayerNotFoundError(f"Player {value!r} was not found")
|
|
113
|
+
return Player(result, self)
|
|
114
|
+
|
|
115
|
+
def get_player_by_uid(self, uid: str | int) -> Player:
|
|
116
|
+
"""Fetch a profile by numeric UID."""
|
|
117
|
+
if not str(uid).isdecimal():
|
|
118
|
+
raise ValueError("uid must contain only digits")
|
|
119
|
+
return self.get_player(uid)
|
|
120
|
+
|
|
121
|
+
def _get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
122
|
+
query = {key: value for key, value in (params or {}).items() if value is not None}
|
|
123
|
+
response = self.session.get(
|
|
124
|
+
f"{self.api_url}{path}", params=query, timeout=self.timeout
|
|
125
|
+
)
|
|
126
|
+
body = response.text
|
|
127
|
+
blocked = (
|
|
128
|
+
response.status_code in (403, 429, 503)
|
|
129
|
+
or "cf-chl-" in body
|
|
130
|
+
or "Just a moment" in body
|
|
131
|
+
)
|
|
132
|
+
if blocked:
|
|
133
|
+
if not self.use_browser_fallback:
|
|
134
|
+
raise CloudflareError(
|
|
135
|
+
"Cloudflare blocked this request. Install "
|
|
136
|
+
"rivalsdata-api[browser] and set use_browser_fallback=True."
|
|
137
|
+
)
|
|
138
|
+
return self._camoufox_get_json(path, query)
|
|
139
|
+
if not response.ok:
|
|
140
|
+
raise RivalsDataHTTPError(
|
|
141
|
+
f"RivalsData API returned HTTP {response.status_code} for {path}: "
|
|
142
|
+
f"{body[:300]}"
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
return response.json()
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise RivalsDataHTTPError(
|
|
148
|
+
f"RivalsData API returned invalid JSON for {path}"
|
|
149
|
+
) from exc
|
|
150
|
+
|
|
151
|
+
def _camoufox_get_json(self, path: str, params: dict[str, Any]) -> Any:
|
|
152
|
+
try:
|
|
153
|
+
from camoufox.sync_api import Camoufox
|
|
154
|
+
except ImportError as exc:
|
|
155
|
+
raise CloudflareError(
|
|
156
|
+
"Install Camoufox with pip install 'rivalsdata-api[browser]' "
|
|
157
|
+
"and run 'python -m camoufox fetch'."
|
|
158
|
+
) from exc
|
|
159
|
+
query = urlencode(params)
|
|
160
|
+
url = f"{self.api_url}{path}" + (f"?{query}" if query else "")
|
|
161
|
+
try:
|
|
162
|
+
with Camoufox(headless=True) as browser:
|
|
163
|
+
page = browser.new_page()
|
|
164
|
+
page.goto(
|
|
165
|
+
self.base_url,
|
|
166
|
+
wait_until="domcontentloaded",
|
|
167
|
+
timeout=int(self.timeout * 1000),
|
|
168
|
+
)
|
|
169
|
+
response = page.evaluate(
|
|
170
|
+
"""async ({url}) => {
|
|
171
|
+
const response = await fetch(url, {method: "GET"});
|
|
172
|
+
return {status: response.status, text: await response.text()};
|
|
173
|
+
}""",
|
|
174
|
+
{"url": url},
|
|
175
|
+
)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise CloudflareError(f"Camoufox request failed: {exc}") from exc
|
|
178
|
+
if response["status"] in (403, 429, 503) or "cf-chl-" in response["text"]:
|
|
179
|
+
raise CloudflareError("Camoufox could not complete the API request")
|
|
180
|
+
if response["status"] < 200 or response["status"] >= 300:
|
|
181
|
+
raise RivalsDataHTTPError(
|
|
182
|
+
f"RivalsData API returned HTTP {response['status']} for {path}: "
|
|
183
|
+
f"{response['text'][:300]}"
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
return json.loads(response["text"])
|
|
187
|
+
except json.JSONDecodeError as exc:
|
|
188
|
+
raise RivalsDataHTTPError(
|
|
189
|
+
f"RivalsData API returned invalid JSON for {path}"
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
|
|
193
|
+
response = self.session.post(
|
|
194
|
+
f"{self.api_url}{path}", json=payload, timeout=self.timeout
|
|
195
|
+
)
|
|
196
|
+
body = response.text
|
|
197
|
+
blocked = (
|
|
198
|
+
response.status_code in (403, 429, 503)
|
|
199
|
+
or "cf-chl-" in body
|
|
200
|
+
or "Just a moment" in body
|
|
201
|
+
)
|
|
202
|
+
if blocked:
|
|
203
|
+
if not self.use_browser_fallback:
|
|
204
|
+
raise CloudflareError(
|
|
205
|
+
"Cloudflare blocked this request. Install "
|
|
206
|
+
"rivalsdata-api[browser] and set use_browser_fallback=True."
|
|
207
|
+
)
|
|
208
|
+
return self._camoufox_post_json(path, payload)
|
|
209
|
+
if not response.ok:
|
|
210
|
+
raise RivalsDataHTTPError(
|
|
211
|
+
f"RivalsData API returned HTTP {response.status_code} for {path}: "
|
|
212
|
+
f"{body[:300]}"
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
return response.json()
|
|
216
|
+
except ValueError as exc:
|
|
217
|
+
raise RivalsDataHTTPError(
|
|
218
|
+
f"RivalsData API returned invalid JSON for {path}"
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
def _camoufox_post_json(self, path: str, payload: dict[str, Any]) -> Any:
|
|
222
|
+
"""Retry an API POST from a browser context after loading the site."""
|
|
223
|
+
try:
|
|
224
|
+
from camoufox.sync_api import Camoufox
|
|
225
|
+
except ImportError as exc:
|
|
226
|
+
raise CloudflareError(
|
|
227
|
+
"Install Camoufox with pip install 'rivalsdata-api[browser]' "
|
|
228
|
+
"and run 'python -m camoufox fetch'."
|
|
229
|
+
) from exc
|
|
230
|
+
try:
|
|
231
|
+
with Camoufox(headless=True) as browser:
|
|
232
|
+
page = browser.new_page()
|
|
233
|
+
page.goto(
|
|
234
|
+
self.base_url,
|
|
235
|
+
wait_until="domcontentloaded",
|
|
236
|
+
timeout=int(self.timeout * 1000),
|
|
237
|
+
)
|
|
238
|
+
response = page.evaluate(
|
|
239
|
+
"""async ({url, payload}) => {
|
|
240
|
+
const response = await fetch(url, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {"Content-Type": "application/json"},
|
|
243
|
+
body: JSON.stringify(payload)
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
status: response.status,
|
|
247
|
+
text: await response.text()
|
|
248
|
+
};
|
|
249
|
+
}""",
|
|
250
|
+
{"url": f"{self.api_url}{path}", "payload": payload},
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
raise CloudflareError(f"Camoufox request failed: {exc}") from exc
|
|
254
|
+
if (
|
|
255
|
+
response["status"] in (403, 429, 503)
|
|
256
|
+
or "cf-chl-" in response["text"]
|
|
257
|
+
):
|
|
258
|
+
raise CloudflareError("Camoufox could not complete the API request")
|
|
259
|
+
if response["status"] < 200 or response["status"] >= 300:
|
|
260
|
+
raise RivalsDataHTTPError(
|
|
261
|
+
f"RivalsData API returned HTTP {response['status']} for {path}: "
|
|
262
|
+
f"{response['text'][:300]}"
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
return json.loads(response["text"])
|
|
266
|
+
except json.JSONDecodeError as exc:
|
|
267
|
+
raise RivalsDataHTTPError(
|
|
268
|
+
f"RivalsData API returned invalid JSON for {path}"
|
|
269
|
+
) from exc
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _uid_from_search_result(player: dict[str, Any]) -> int | None:
|
|
273
|
+
for key in ("uid", "uuid", "playerId", "player_id"):
|
|
274
|
+
value = player.get(key)
|
|
275
|
+
if value is not None and str(value).isdecimal():
|
|
276
|
+
return int(value)
|
|
277
|
+
suffix = str(player.get("aid", "")).rsplit("_", 1)[-1]
|
|
278
|
+
return int(suffix) if suffix.isdecimal() else None
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _normalize_name(name: str) -> str:
|
|
282
|
+
return unicodedata.normalize("NFKC", name).casefold().strip()
|
rivalsdata/exceptions.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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."""
|