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 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()
@@ -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."""